refactor: Rename Intel to Discover and apply Landing Page 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

- Renamed /intel to /discover
- Updated styles to match dark/cinematic landing page theme
- Updated Header, Footer, and Sitemap
- Added redirects from /intel and /tld-pricing to /discover
- Optimized SEO metadata for new paths
This commit is contained in:
yves.gugger
2025-12-12 16:35:34 +01:00
parent 58228e3d33
commit b5c456af1c
35 changed files with 11279 additions and 1228 deletions

542
backend/app/routes/portfolio.py Executable file
View File

@ -0,0 +1,542 @@
"""Portfolio API routes."""
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
from app.database import get_db
from app.routes.auth import get_current_user
from app.models.user import User
from app.models.portfolio import PortfolioDomain, DomainValuation
from app.services.valuation import valuation_service
router = APIRouter(prefix="/portfolio", tags=["portfolio"])
# ============== Schemas ==============
class PortfolioDomainCreate(BaseModel):
"""Schema for creating a portfolio domain."""
domain: str = Field(..., min_length=3, max_length=255)
purchase_date: Optional[datetime] = None
purchase_price: Optional[float] = Field(None, ge=0)
purchase_registrar: Optional[str] = None
registrar: Optional[str] = None
renewal_date: Optional[datetime] = None
renewal_cost: Optional[float] = Field(None, ge=0)
auto_renew: bool = True
notes: Optional[str] = None
tags: Optional[str] = None
class PortfolioDomainUpdate(BaseModel):
"""Schema for updating a portfolio domain."""
purchase_date: Optional[datetime] = None
purchase_price: Optional[float] = Field(None, ge=0)
purchase_registrar: Optional[str] = None
registrar: Optional[str] = None
renewal_date: Optional[datetime] = None
renewal_cost: Optional[float] = Field(None, ge=0)
auto_renew: Optional[bool] = None
status: Optional[str] = None
notes: Optional[str] = None
tags: Optional[str] = None
class PortfolioDomainSell(BaseModel):
"""Schema for marking a domain as sold."""
sale_date: datetime
sale_price: float = Field(..., ge=0)
class PortfolioDomainResponse(BaseModel):
"""Response schema for portfolio domain."""
id: int
domain: str
purchase_date: Optional[datetime]
purchase_price: Optional[float]
purchase_registrar: Optional[str]
registrar: Optional[str]
renewal_date: Optional[datetime]
renewal_cost: Optional[float]
auto_renew: bool
estimated_value: Optional[float]
value_updated_at: Optional[datetime]
is_sold: bool
sale_date: Optional[datetime]
sale_price: Optional[float]
status: str
notes: Optional[str]
tags: Optional[str]
roi: Optional[float]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class PortfolioSummary(BaseModel):
"""Summary of user's portfolio."""
total_domains: int
active_domains: int
sold_domains: int
total_invested: float
total_value: float
total_sold_value: float
unrealized_profit: float
realized_profit: float
overall_roi: float
class ValuationResponse(BaseModel):
"""Response schema for domain valuation."""
domain: str
estimated_value: float
currency: str
scores: dict
factors: dict
confidence: str
source: str
calculated_at: str
# ============== Portfolio Endpoints ==============
@router.get("", response_model=List[PortfolioDomainResponse])
async def get_portfolio(
status: Optional[str] = Query(None, description="Filter by status"),
sort_by: str = Query("created_at", description="Sort field"),
sort_order: str = Query("desc", description="Sort order (asc/desc)"),
limit: int = Query(100, le=500),
offset: int = Query(0, ge=0),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get user's portfolio domains."""
query = select(PortfolioDomain).where(PortfolioDomain.user_id == current_user.id)
# Filter by status
if status:
query = query.where(PortfolioDomain.status == status)
# Sorting
sort_column = getattr(PortfolioDomain, sort_by, PortfolioDomain.created_at)
if sort_order == "asc":
query = query.order_by(sort_column.asc())
else:
query = query.order_by(sort_column.desc())
# Pagination
query = query.offset(offset).limit(limit)
result = await db.execute(query)
domains = result.scalars().all()
# Calculate ROI for each domain
responses = []
for d in domains:
response = PortfolioDomainResponse(
id=d.id,
domain=d.domain,
purchase_date=d.purchase_date,
purchase_price=d.purchase_price,
purchase_registrar=d.purchase_registrar,
registrar=d.registrar,
renewal_date=d.renewal_date,
renewal_cost=d.renewal_cost,
auto_renew=d.auto_renew,
estimated_value=d.estimated_value,
value_updated_at=d.value_updated_at,
is_sold=d.is_sold,
sale_date=d.sale_date,
sale_price=d.sale_price,
status=d.status,
notes=d.notes,
tags=d.tags,
roi=d.roi,
created_at=d.created_at,
updated_at=d.updated_at,
)
responses.append(response)
return responses
@router.get("/summary", response_model=PortfolioSummary)
async def get_portfolio_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get portfolio summary statistics."""
result = await db.execute(
select(PortfolioDomain).where(PortfolioDomain.user_id == current_user.id)
)
domains = result.scalars().all()
total_domains = len(domains)
active_domains = sum(1 for d in domains if d.status == "active" and not d.is_sold)
sold_domains = sum(1 for d in domains if d.is_sold)
total_invested = sum(d.purchase_price or 0 for d in domains)
total_value = sum(d.estimated_value or 0 for d in domains if not d.is_sold)
total_sold_value = sum(d.sale_price or 0 for d in domains if d.is_sold)
# Calculate active investment for ROI
active_investment = sum(d.purchase_price or 0 for d in domains if not d.is_sold)
sold_investment = sum(d.purchase_price or 0 for d in domains if d.is_sold)
unrealized_profit = total_value - active_investment
realized_profit = total_sold_value - sold_investment
overall_roi = 0.0
if total_invested > 0:
overall_roi = ((total_value + total_sold_value - total_invested) / total_invested) * 100
return PortfolioSummary(
total_domains=total_domains,
active_domains=active_domains,
sold_domains=sold_domains,
total_invested=round(total_invested, 2),
total_value=round(total_value, 2),
total_sold_value=round(total_sold_value, 2),
unrealized_profit=round(unrealized_profit, 2),
realized_profit=round(realized_profit, 2),
overall_roi=round(overall_roi, 2),
)
@router.post("", response_model=PortfolioDomainResponse, status_code=status.HTTP_201_CREATED)
async def add_portfolio_domain(
data: PortfolioDomainCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Add a domain to portfolio."""
# Check if domain already exists in user's portfolio
existing = await db.execute(
select(PortfolioDomain).where(
and_(
PortfolioDomain.user_id == current_user.id,
PortfolioDomain.domain == data.domain.lower(),
)
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Domain already in portfolio",
)
# Get initial valuation
valuation = await valuation_service.estimate_value(data.domain, db, save_result=True)
estimated_value = valuation.get("estimated_value") if "error" not in valuation else None
# Create portfolio entry
domain = PortfolioDomain(
user_id=current_user.id,
domain=data.domain.lower(),
purchase_date=data.purchase_date,
purchase_price=data.purchase_price,
purchase_registrar=data.purchase_registrar,
registrar=data.registrar or data.purchase_registrar,
renewal_date=data.renewal_date,
renewal_cost=data.renewal_cost,
auto_renew=data.auto_renew,
estimated_value=estimated_value,
value_updated_at=datetime.utcnow() if estimated_value else None,
notes=data.notes,
tags=data.tags,
)
db.add(domain)
await db.commit()
await db.refresh(domain)
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,
created_at=domain.created_at,
updated_at=domain.updated_at,
)
@router.get("/{domain_id}", response_model=PortfolioDomainResponse)
async def get_portfolio_domain(
domain_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get a specific portfolio domain."""
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",
)
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,
created_at=domain.created_at,
updated_at=domain.updated_at,
)
@router.put("/{domain_id}", response_model=PortfolioDomainResponse)
async def update_portfolio_domain(
domain_id: int,
data: PortfolioDomainUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Update a portfolio domain."""
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",
)
# Update fields
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(domain, field, value)
await db.commit()
await db.refresh(domain)
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,
created_at=domain.created_at,
updated_at=domain.updated_at,
)
@router.post("/{domain_id}/sell", response_model=PortfolioDomainResponse)
async def mark_domain_sold(
domain_id: int,
data: PortfolioDomainSell,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Mark a domain as sold."""
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",
)
domain.is_sold = True
domain.sale_date = data.sale_date
domain.sale_price = data.sale_price
domain.status = "sold"
await db.commit()
await db.refresh(domain)
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,
created_at=domain.created_at,
updated_at=domain.updated_at,
)
@router.delete("/{domain_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_portfolio_domain(
domain_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Delete a domain from portfolio."""
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",
)
await db.delete(domain)
await db.commit()
@router.post("/{domain_id}/refresh-value", response_model=PortfolioDomainResponse)
async def refresh_domain_value(
domain_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Refresh the estimated value of a portfolio domain."""
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",
)
# Get new valuation
valuation = await valuation_service.estimate_value(domain.domain, db, save_result=True)
if "error" not in valuation:
domain.estimated_value = valuation["estimated_value"]
domain.value_updated_at = datetime.utcnow()
await db.commit()
await db.refresh(domain)
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,
created_at=domain.created_at,
updated_at=domain.updated_at,
)
# ============== Valuation Endpoints ==============
@router.get("/valuation/{domain}", response_model=ValuationResponse)
async def get_domain_valuation(
domain: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get estimated value for any domain."""
valuation = await valuation_service.estimate_value(domain, db, save_result=True)
if "error" in valuation:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=valuation["error"],
)
return ValuationResponse(**valuation)

View File

@ -0,0 +1,36 @@
"""Seed auction data for development."""
import asyncio
import sys
import os
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import AsyncSessionLocal
from app.services.auction_scraper import auction_scraper
async def main():
"""Seed auction data."""
async with AsyncSessionLocal() as db:
print("Seeding sample auction data...")
result = await auction_scraper.seed_sample_auctions(db)
print(f"✓ Seeded {result['found']} auctions ({result['new']} new, {result['updated']} updated)")
# Also try to scrape real data
print("\nAttempting to scrape real auction data...")
try:
scrape_result = await auction_scraper.scrape_all_platforms(db)
print(f"✓ Scraped {scrape_result['total_found']} auctions from platforms:")
for platform, stats in scrape_result['platforms'].items():
print(f" - {platform}: {stats.get('found', 0)} found")
if scrape_result['errors']:
print(f" Errors: {scrape_result['errors']}")
except Exception as e:
print(f" Scraping failed (this is okay): {e}")
print("\n✓ Done!")
if __name__ == "__main__":
asyncio.run(main())

View File

@ -103,6 +103,28 @@ const nextConfig = {
destination: '/terminal/intel/:tld*', destination: '/terminal/intel/:tld*',
permanent: true, permanent: true,
}, },
// Public Intel → Discover
{
source: '/intel',
destination: '/discover',
permanent: true,
},
{
source: '/intel/:tld*',
destination: '/discover/:tld*',
permanent: true,
},
// Old TLD pricing → Discover
{
source: '/tld-pricing',
destination: '/discover',
permanent: true,
},
{
source: '/tld-pricing/:tld*',
destination: '/discover/:tld*',
permanent: true,
},
// Listings → LISTING // Listings → LISTING
{ {
source: '/terminal/listings', source: '/terminal/listings',

View File

@ -9,9 +9,9 @@ Disallow: /forgot-password
Disallow: /reset-password Disallow: /reset-password
# Allow specific public pages # Allow specific public pages
Allow: /intel/$ Allow: /discover/$
Allow: /intel/*.css Allow: /discover/*.css
Allow: /intel/*.js Allow: /discover/*.js
Allow: /market Allow: /market
Allow: /pricing Allow: /pricing
Allow: /about Allow: /about

View File

@ -0,0 +1,106 @@
import { Metadata } from 'next'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.ch'
export const metadata: Metadata = {
title: 'Domain Auctions — Smart Pounce',
description: 'Find undervalued domain auctions from GoDaddy, Sedo, NameJet, SnapNames & DropCatch. Our Smart Pounce algorithm identifies the best opportunities with transparent valuations.',
keywords: [
'domain auctions',
'expired domains',
'domain bidding',
'GoDaddy auctions',
'Sedo domains',
'NameJet',
'domain investment',
'undervalued domains',
'domain flipping',
],
openGraph: {
title: 'Domain Auctions — Smart Pounce by pounce',
description: 'Find undervalued domain auctions. Transparent valuations, multiple platforms, no payment handling.',
url: `${siteUrl}/auctions`,
type: 'website',
images: [
{
url: `${siteUrl}/og-auctions.png`,
width: 1200,
height: 630,
alt: 'Smart Pounce - Domain Auction Aggregator',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Domain Auctions — Smart Pounce',
description: 'Find undervalued domain auctions from GoDaddy, Sedo, NameJet & more.',
},
alternates: {
canonical: `${siteUrl}/auctions`,
},
}
// JSON-LD for Auctions page
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: 'Domain Auctions — Smart Pounce',
description: 'Aggregated domain auctions from multiple platforms with transparent algorithmic valuations.',
url: `${siteUrl}/auctions`,
isPartOf: {
'@type': 'WebSite',
name: 'pounce',
url: siteUrl,
},
about: {
'@type': 'Service',
name: 'Smart Pounce',
description: 'Domain auction aggregation and opportunity analysis',
provider: {
'@type': 'Organization',
name: 'pounce',
},
},
mainEntity: {
'@type': 'ItemList',
name: 'Domain Auctions',
description: 'Live domain auctions from GoDaddy, Sedo, NameJet, SnapNames, and DropCatch',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'GoDaddy Auctions',
url: 'https://auctions.godaddy.com',
},
{
'@type': 'ListItem',
position: 2,
name: 'Sedo',
url: 'https://sedo.com',
},
{
'@type': 'ListItem',
position: 3,
name: 'NameJet',
url: 'https://namejet.com',
},
],
},
}
export default function AuctionsLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{children}
</>
)
}

View File

@ -9,11 +9,11 @@ import { useRouter } from 'next/navigation'
*/ */
export default function AuctionsRedirect() { export default function AuctionsRedirect() {
const router = useRouter() const router = useRouter()
useEffect(() => { useEffect(() => {
router.replace('/market') router.replace('/market')
}, [router]) }, [router])
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background"> <div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center"> <div className="text-center">

209
frontend/src/app/careers/page.tsx Executable file
View File

@ -0,0 +1,209 @@
'use client'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import { Briefcase, MapPin, Clock, ArrowRight, Code, Palette, LineChart, Users, Heart, Coffee, Laptop, Globe } from 'lucide-react'
import Link from 'next/link'
const openPositions = [
{
title: 'Senior Backend Engineer',
department: 'Engineering',
location: 'Remote (Europe)',
type: 'Full-time',
description: 'Build and scale our domain intelligence infrastructure using Python and FastAPI.',
icon: Code,
},
{
title: 'Frontend Developer',
department: 'Engineering',
location: 'Remote (Worldwide)',
type: 'Full-time',
description: 'Create beautiful, performant user interfaces with React and Next.js.',
icon: Palette,
},
{
title: 'Data Engineer',
department: 'Data',
location: 'Zurich, Switzerland',
type: 'Full-time',
description: 'Design data pipelines for domain pricing and market analytics.',
icon: LineChart,
},
{
title: 'Customer Success Manager',
department: 'Customer Success',
location: 'Remote (Europe)',
type: 'Full-time',
description: 'Help our customers succeed and get the most out of pounce.',
icon: Users,
},
]
const benefits = [
{
icon: Globe,
title: 'Remote-First',
description: 'Work from anywhere in the world with flexible hours.',
},
{
icon: Heart,
title: 'Health & Wellness',
description: 'Comprehensive health insurance and wellness budget.',
},
{
icon: Coffee,
title: 'Learning Budget',
description: 'Annual budget for courses, conferences, and books.',
},
{
icon: Laptop,
title: 'Equipment',
description: 'Top-of-the-line hardware and home office setup.',
},
]
const values = [
'We ship fast and iterate based on feedback',
'We value transparency and open communication',
'We prioritize user experience over features',
'We believe in work-life balance',
]
export default function CareersPage() {
return (
<div className="min-h-screen bg-background relative flex flex-col">
{/* Ambient glow */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 right-1/4 w-[500px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" />
</div>
<Header />
<main className="relative pt-32 sm:pt-36 pb-20 px-4 sm:px-6 flex-1">
<div className="max-w-5xl mx-auto">
{/* Hero */}
<div className="text-center mb-16 sm:mb-20 animate-fade-in">
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-background-secondary/80 backdrop-blur-sm border border-border rounded-full mb-6">
<Briefcase className="w-4 h-4 text-accent" />
<span className="text-ui-sm text-foreground-muted">Join Our Team</span>
</div>
<h1 className="font-display text-[2.25rem] sm:text-[3rem] md:text-[3.75rem] leading-[1.1] tracking-[-0.035em] text-foreground mb-6">
Build the future of
<br />
<span className="text-foreground-muted">domain intelligence</span>
</h1>
<p className="text-body-lg text-foreground-muted max-w-2xl mx-auto">
We're a small, focused team building tools that help thousands of people
monitor and acquire valuable domains. Join us.
</p>
</div>
{/* Values */}
<div className="mb-16 p-8 sm:p-10 bg-gradient-to-br from-accent/10 via-accent/5 to-transparent border border-accent/20 rounded-2xl animate-slide-up">
<h2 className="text-heading-sm font-medium text-foreground mb-6">How We Work</h2>
<ul className="grid sm:grid-cols-2 gap-3">
{values.map((value) => (
<li key={value} className="flex items-center gap-3 text-body text-foreground-muted">
<div className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" />
{value}
</li>
))}
</ul>
</div>
{/* Benefits */}
<section className="mb-16">
<h2 className="text-heading-sm font-medium text-foreground mb-8 text-center">Benefits & Perks</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
{benefits.map((benefit, i) => (
<div
key={benefit.title}
className="p-6 bg-background-secondary/50 border border-border rounded-xl animate-slide-up"
style={{ animationDelay: `${i * 50}ms` }}
>
<div className="w-10 h-10 bg-background-tertiary rounded-lg flex items-center justify-center mb-4">
<benefit.icon className="w-5 h-5 text-accent" />
</div>
<h3 className="text-body font-medium text-foreground mb-1">{benefit.title}</h3>
<p className="text-body-sm text-foreground-muted">{benefit.description}</p>
</div>
))}
</div>
</section>
{/* Open Positions */}
<section className="mb-16">
<h2 className="text-heading-sm font-medium text-foreground mb-8 text-center">
Open Positions
</h2>
<div className="space-y-4">
{openPositions.map((position, i) => (
<div
key={position.title}
className="group p-6 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover transition-all duration-300 animate-slide-up"
style={{ animationDelay: `${i * 50}ms` }}
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-background-tertiary rounded-xl flex items-center justify-center shrink-0 group-hover:bg-accent/10 transition-colors">
<position.icon className="w-6 h-6 text-foreground-muted group-hover:text-accent transition-colors" />
</div>
<div>
<h3 className="text-body-lg font-medium text-foreground mb-1 group-hover:text-accent transition-colors">
{position.title}
</h3>
<p className="text-body-sm text-foreground-muted mb-3">{position.description}</p>
<div className="flex flex-wrap items-center gap-3">
<span className="text-ui-xs text-accent bg-accent-muted px-2 py-0.5 rounded-full">
{position.department}
</span>
<span className="flex items-center gap-1 text-ui-xs text-foreground-subtle">
<MapPin className="w-3 h-3" />
{position.location}
</span>
<span className="flex items-center gap-1 text-ui-xs text-foreground-subtle">
<Clock className="w-3 h-3" />
{position.type}
</span>
</div>
</div>
</div>
<Link
href={`mailto:careers@pounce.dev?subject=Application: ${position.title}`}
className="shrink-0 flex items-center gap-2 px-5 py-2.5 bg-foreground text-background font-medium rounded-xl hover:bg-foreground/90 transition-all"
>
Apply
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
))}
</div>
</section>
{/* CTA */}
<section className="text-center p-8 sm:p-10 bg-background-secondary/30 border border-border rounded-2xl animate-slide-up">
<h3 className="text-heading-sm font-medium text-foreground mb-3">
Don't see the right role?
</h3>
<p className="text-body text-foreground-muted mb-6 max-w-lg mx-auto">
We're always looking for talented people. Send us your resume
and we'll keep you in mind for future opportunities.
</p>
<Link
href="mailto:careers@pounce.dev?subject=General Application"
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
>
Send General Application
<ArrowRight className="w-4 h-4" />
</Link>
</section>
</div>
</main>
<Footer />
</div>
)
}

View File

@ -0,0 +1,597 @@
'use client'
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
import { PageContainer, StatCard, Badge, ActionButton } from '@/components/PremiumTable'
import {
Plus,
Bell,
Target,
Zap,
Loader2,
Trash2,
CheckCircle,
AlertCircle,
X,
Play,
Pause,
Mail,
Settings,
TestTube,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import clsx from 'clsx'
interface SniperAlert {
id: number
name: string
description: string | null
tlds: string | null
keywords: string | null
exclude_keywords: string | null
max_length: number | null
min_length: number | null
max_price: number | null
min_price: number | null
max_bids: number | null
ending_within_hours: number | null
platforms: string | null
no_numbers: boolean
no_hyphens: boolean
exclude_chars: string | null
notify_email: boolean
notify_sms: boolean
is_active: boolean
matches_count: number
notifications_sent: number
last_matched_at: string | null
created_at: string
}
interface TestResult {
alert_name: string
auctions_checked: number
matches_found: number
matches: Array<{
domain: string
platform: string
current_bid: number
num_bids: number
end_time: string
}>
message: string
}
export default function SniperAlertsPage() {
const { subscription } = useStore()
const [alerts, setAlerts] = useState<SniperAlert[]>([])
const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false)
const [creating, setCreating] = useState(false)
const [testing, setTesting] = useState<number | null>(null)
const [testResult, setTestResult] = useState<TestResult | null>(null)
const [expandedAlert, setExpandedAlert] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
// Create form
const [newAlert, setNewAlert] = useState({
name: '',
description: '',
tlds: '',
keywords: '',
exclude_keywords: '',
max_length: '',
min_length: '',
max_price: '',
min_price: '',
max_bids: '',
no_numbers: false,
no_hyphens: false,
exclude_chars: '',
notify_email: true,
})
const loadAlerts = useCallback(async () => {
setLoading(true)
try {
const data = await api.request<SniperAlert[]>('/sniper-alerts')
setAlerts(data)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadAlerts()
}, [loadAlerts])
const handleCreate = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
setCreating(true)
setError(null)
try {
await api.request('/sniper-alerts', {
method: 'POST',
body: JSON.stringify({
name: newAlert.name,
description: newAlert.description || null,
tlds: newAlert.tlds || null,
keywords: newAlert.keywords || null,
exclude_keywords: newAlert.exclude_keywords || null,
max_length: newAlert.max_length ? parseInt(newAlert.max_length) : null,
min_length: newAlert.min_length ? parseInt(newAlert.min_length) : null,
max_price: newAlert.max_price ? parseFloat(newAlert.max_price) : null,
min_price: newAlert.min_price ? parseFloat(newAlert.min_price) : null,
max_bids: newAlert.max_bids ? parseInt(newAlert.max_bids) : null,
no_numbers: newAlert.no_numbers,
no_hyphens: newAlert.no_hyphens,
exclude_chars: newAlert.exclude_chars || null,
notify_email: newAlert.notify_email,
}),
})
setSuccess('Sniper Alert created!')
setShowCreateModal(false)
setNewAlert({
name: '', description: '', tlds: '', keywords: '', exclude_keywords: '',
max_length: '', min_length: '', max_price: '', min_price: '', max_bids: '',
no_numbers: false, no_hyphens: false, exclude_chars: '', notify_email: true,
})
loadAlerts()
} catch (err: any) {
setError(err.message)
} finally {
setCreating(false)
}
}, [newAlert, loadAlerts])
const handleToggle = useCallback(async (alert: SniperAlert) => {
try {
await api.request(`/sniper-alerts/${alert.id}`, {
method: 'PUT',
body: JSON.stringify({ is_active: !alert.is_active }),
})
loadAlerts()
} catch (err: any) {
setError(err.message)
}
}, [loadAlerts])
const handleDelete = useCallback(async (alert: SniperAlert) => {
if (!confirm(`Delete alert "${alert.name}"?`)) return
try {
await api.request(`/sniper-alerts/${alert.id}`, { method: 'DELETE' })
setSuccess('Alert deleted')
loadAlerts()
} catch (err: any) {
setError(err.message)
}
}, [loadAlerts])
const handleTest = useCallback(async (alert: SniperAlert) => {
setTesting(alert.id)
setTestResult(null)
try {
const result = await api.request<TestResult>(`/sniper-alerts/${alert.id}/test`, {
method: 'POST',
})
setTestResult(result)
setExpandedAlert(alert.id)
} catch (err: any) {
setError(err.message)
} finally {
setTesting(null)
}
}, [])
// Memoized stats
const stats = useMemo(() => ({
activeAlerts: alerts.filter(a => a.is_active).length,
totalMatches: alerts.reduce((sum, a) => sum + a.matches_count, 0),
notificationsSent: alerts.reduce((sum, a) => sum + a.notifications_sent, 0),
}), [alerts])
const tier = subscription?.tier || 'scout'
const limits = { scout: 2, trader: 10, tycoon: 50 }
const maxAlerts = limits[tier as keyof typeof limits] || 2
return (
<CommandCenterLayout
title="Sniper Alerts"
subtitle={`Hyper-personalized auction notifications (${alerts.length}/${maxAlerts})`}
actions={
<ActionButton onClick={() => setShowCreateModal(true)} disabled={alerts.length >= maxAlerts} icon={Plus}>
New Alert
</ActionButton>
}
>
<PageContainer>
{/* Messages */}
{error && (
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-400" />
<p className="text-sm text-red-400 flex-1">{error}</p>
<button onClick={() => setError(null)}><X className="w-4 h-4 text-red-400" /></button>
</div>
)}
{success && (
<div className="p-4 bg-accent/10 border border-accent/20 rounded-xl flex items-center gap-3">
<CheckCircle className="w-5 h-5 text-accent" />
<p className="text-sm text-accent flex-1">{success}</p>
<button onClick={() => setSuccess(null)}><X className="w-4 h-4 text-accent" /></button>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="Active Alerts" value={stats.activeAlerts} icon={Bell} />
<StatCard title="Total Matches" value={stats.totalMatches} icon={Target} />
<StatCard title="Notifications Sent" value={stats.notificationsSent} icon={Zap} />
<StatCard title="Alert Slots" value={`${alerts.length}/${maxAlerts}`} icon={Settings} />
</div>
{/* Alerts List */}
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
</div>
) : alerts.length === 0 ? (
<div className="text-center py-16 bg-background-secondary/30 border border-border rounded-2xl">
<Target className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
<h2 className="text-xl font-medium text-foreground mb-2">No Sniper Alerts</h2>
<p className="text-foreground-muted mb-6 max-w-md mx-auto">
Create alerts to get notified when domains matching your criteria appear in auctions.
</p>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
>
<Plus className="w-5 h-5" />
Create Alert
</button>
</div>
) : (
<div className="space-y-4">
{alerts.map((alert) => (
<div
key={alert.id}
className="bg-background-secondary/30 border border-border rounded-2xl overflow-hidden transition-all hover:border-border-hover"
>
{/* Header */}
<div className="p-5">
<div className="flex flex-wrap items-start gap-4">
<div className="flex-1 min-w-[200px]">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-medium text-foreground">{alert.name}</h3>
<Badge variant={alert.is_active ? 'success' : 'default'}>
{alert.is_active ? 'Active' : 'Paused'}
</Badge>
</div>
{alert.description && (
<p className="text-sm text-foreground-muted">{alert.description}</p>
)}
</div>
{/* Stats */}
<div className="flex items-center gap-6 text-sm">
<div className="text-center">
<p className="text-lg font-semibold text-foreground">{alert.matches_count}</p>
<p className="text-xs text-foreground-muted">Matches</p>
</div>
<div className="text-center">
<p className="text-lg font-semibold text-foreground">{alert.notifications_sent}</p>
<p className="text-xs text-foreground-muted">Notified</p>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={() => handleTest(alert)}
disabled={testing === alert.id}
className="flex items-center gap-1.5 px-3 py-2 bg-foreground/5 text-foreground-muted text-sm font-medium rounded-lg hover:bg-foreground/10 transition-all"
>
{testing === alert.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<TestTube className="w-4 h-4" />
)}
Test
</button>
<button
onClick={() => handleToggle(alert)}
className={clsx(
"flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-all",
alert.is_active
? "bg-amber-500/10 text-amber-400 hover:bg-amber-500/20"
: "bg-accent/10 text-accent hover:bg-accent/20"
)}
>
{alert.is_active ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
{alert.is_active ? 'Pause' : 'Activate'}
</button>
<button
onClick={() => handleDelete(alert)}
className="p-2 text-foreground-subtle hover:text-red-400 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
<button
onClick={() => setExpandedAlert(expandedAlert === alert.id ? null : alert.id)}
className="p-2 text-foreground-subtle hover:text-foreground transition-colors"
>
{expandedAlert === alert.id ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
</div>
</div>
{/* Filter Summary */}
<div className="mt-4 flex flex-wrap gap-2">
{alert.tlds && (
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
TLDs: {alert.tlds}
</span>
)}
{alert.max_length && (
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
Max {alert.max_length} chars
</span>
)}
{alert.max_price && (
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
Max ${alert.max_price}
</span>
)}
{alert.no_numbers && (
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
No numbers
</span>
)}
{alert.no_hyphens && (
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
No hyphens
</span>
)}
{alert.notify_email && (
<span className="px-2 py-1 bg-accent/10 text-accent text-xs rounded flex items-center gap-1">
<Mail className="w-3 h-3" /> Email
</span>
)}
</div>
</div>
{/* Test Results */}
{expandedAlert === alert.id && testResult && testResult.alert_name === alert.name && (
<div className="px-5 pb-5">
<div className="p-4 bg-background rounded-xl border border-border">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium text-foreground">Test Results</p>
<p className="text-xs text-foreground-muted">
Checked {testResult.auctions_checked} auctions
</p>
</div>
{testResult.matches_found === 0 ? (
<p className="text-sm text-foreground-muted">{testResult.message}</p>
) : (
<div className="space-y-2">
<p className="text-sm text-accent">
Found {testResult.matches_found} matching domains!
</p>
<div className="max-h-48 overflow-y-auto space-y-1">
{testResult.matches.map((match, idx) => (
<div key={idx} className="flex items-center justify-between text-sm py-1">
<span className="font-mono text-foreground">{match.domain}</span>
<span className="text-foreground-muted">${match.current_bid}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</PageContainer>
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm overflow-y-auto">
<div className="w-full max-w-lg bg-background-secondary border border-border rounded-2xl p-6 my-8">
<h2 className="text-xl font-semibold text-foreground mb-2">Create Sniper Alert</h2>
<p className="text-sm text-foreground-muted mb-6">
Get notified when domains matching your criteria appear in auctions.
</p>
<form onSubmit={handleCreate} className="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
<div>
<label className="block text-sm text-foreground-muted mb-1">Alert Name *</label>
<input
type="text"
required
value={newAlert.name}
onChange={(e) => setNewAlert({ ...newAlert, name: e.target.value })}
placeholder="4-letter .com without numbers"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1">Description</label>
<input
type="text"
value={newAlert.description}
onChange={(e) => setNewAlert({ ...newAlert, description: e.target.value })}
placeholder="Optional description"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-foreground-muted mb-1">TLDs (comma-separated)</label>
<input
type="text"
value={newAlert.tlds}
onChange={(e) => setNewAlert({ ...newAlert, tlds: e.target.value })}
placeholder="com,io,ai"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1">Keywords (must contain)</label>
<input
type="text"
value={newAlert.keywords}
onChange={(e) => setNewAlert({ ...newAlert, keywords: e.target.value })}
placeholder="ai,tech,crypto"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-foreground-muted mb-1">Min Length</label>
<input
type="number"
min="1"
max="63"
value={newAlert.min_length}
onChange={(e) => setNewAlert({ ...newAlert, min_length: e.target.value })}
placeholder="3"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1">Max Length</label>
<input
type="number"
min="1"
max="63"
value={newAlert.max_length}
onChange={(e) => setNewAlert({ ...newAlert, max_length: e.target.value })}
placeholder="6"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-foreground-muted mb-1">Max Price ($)</label>
<input
type="number"
min="0"
value={newAlert.max_price}
onChange={(e) => setNewAlert({ ...newAlert, max_price: e.target.value })}
placeholder="500"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1">Max Bids (low competition)</label>
<input
type="number"
min="0"
value={newAlert.max_bids}
onChange={(e) => setNewAlert({ ...newAlert, max_bids: e.target.value })}
placeholder="5"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1">Exclude Characters</label>
<input
type="text"
value={newAlert.exclude_chars}
onChange={(e) => setNewAlert({ ...newAlert, exclude_chars: e.target.value })}
placeholder="q,x,z"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={newAlert.no_numbers}
onChange={(e) => setNewAlert({ ...newAlert, no_numbers: e.target.checked })}
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
/>
<span className="text-sm text-foreground">No numbers</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={newAlert.no_hyphens}
onChange={(e) => setNewAlert({ ...newAlert, no_hyphens: e.target.checked })}
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
/>
<span className="text-sm text-foreground">No hyphens</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={newAlert.notify_email}
onChange={(e) => setNewAlert({ ...newAlert, notify_email: e.target.checked })}
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
/>
<span className="text-sm text-foreground flex items-center gap-1">
<Mail className="w-4 h-4" /> Email alerts
</span>
</label>
</div>
</form>
<div className="flex gap-3 mt-6">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="flex-1 px-4 py-3 border border-border text-foreground-muted rounded-xl hover:bg-foreground/5 transition-all"
>
Cancel
</button>
<button
onClick={handleCreate}
disabled={creating || !newAlert.name}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all disabled:opacity-50"
>
{creating ? <Loader2 className="w-5 h-5 animate-spin" /> : <Target className="w-5 h-5" />}
{creating ? 'Creating...' : 'Create Alert'}
</button>
</div>
</div>
</div>
)}
</CommandCenterLayout>
)
}

View File

@ -0,0 +1,578 @@
'use client'
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
import {
PremiumTable,
Badge,
PlatformBadge,
StatCard,
PageContainer,
SearchInput,
TabBar,
FilterBar,
SelectDropdown,
ActionButton,
} from '@/components/PremiumTable'
import {
Clock,
ExternalLink,
Flame,
Timer,
Gavel,
DollarSign,
RefreshCw,
Target,
Loader2,
Sparkles,
Eye,
Zap,
Crown,
Plus,
Check,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
interface Auction {
domain: string
platform: string
platform_url: string
current_bid: number
currency: string
num_bids: number
end_time: string
time_remaining: string
buy_now_price: number | null
reserve_met: boolean | null
traffic: number | null
age_years: number | null
tld: string
affiliate_url: string
}
interface Opportunity {
auction: Auction
analysis: {
opportunity_score: number
urgency?: string
competition?: string
price_range?: string
recommendation: string
reasoning?: string
}
}
type TabType = 'all' | 'ending' | 'hot' | 'opportunities'
type SortField = 'ending' | 'bid_asc' | 'bid_desc' | 'bids' | 'score'
type FilterPreset = 'all' | 'no-trash' | 'short' | 'high-value' | 'low-competition'
const PLATFORMS = [
{ value: 'All', label: 'All Sources' },
{ value: 'GoDaddy', label: 'GoDaddy' },
{ value: 'Sedo', label: 'Sedo' },
{ value: 'NameJet', label: 'NameJet' },
{ value: 'DropCatch', label: 'DropCatch' },
]
const FILTER_PRESETS: { id: FilterPreset, label: string, icon: typeof Gavel, description: string, proOnly?: boolean }[] = [
{ id: 'all', label: 'All', icon: Gavel, description: 'Show all auctions' },
{ id: 'no-trash', label: 'No Trash', icon: Sparkles, description: 'Clean domains only (no spam)', proOnly: true },
{ id: 'short', label: 'Short', icon: Zap, description: '4-letter domains or less' },
{ id: 'high-value', label: 'High Value', icon: Crown, description: 'Premium TLDs with high activity', proOnly: true },
{ id: 'low-competition', label: 'Low Competition', icon: Target, description: 'Under 5 bids' },
]
const PREMIUM_TLDS = ['com', 'io', 'ai', 'co', 'net', 'org', 'app', 'dev']
// Pure functions (no hooks needed)
function isCleanDomain(auction: Auction): boolean {
const name = auction.domain.split('.')[0]
if (name.includes('-')) return false
if (name.length > 4 && /\d/.test(name)) return false
if (name.length > 12) return false
if (!PREMIUM_TLDS.includes(auction.tld)) return false
return true
}
function calculateDealScore(auction: Auction): number {
let score = 50
const name = auction.domain.split('.')[0]
if (name.length <= 4) score += 25
else if (name.length <= 6) score += 15
else if (name.length <= 8) score += 5
if (['com', 'io', 'ai'].includes(auction.tld)) score += 15
else if (['co', 'net', 'org'].includes(auction.tld)) score += 5
if (auction.age_years && auction.age_years > 10) score += 15
else if (auction.age_years && auction.age_years > 5) score += 10
if (auction.num_bids >= 20) score += 10
else if (auction.num_bids >= 10) score += 5
if (isCleanDomain(auction)) score += 10
return Math.min(score, 100)
}
function getTimeColor(timeRemaining: string): string {
if (timeRemaining.includes('m') && !timeRemaining.includes('h') && !timeRemaining.includes('d')) return 'text-red-400'
if (timeRemaining.includes('h') && parseInt(timeRemaining) < 2) return 'text-amber-400'
return 'text-foreground-muted'
}
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value)
}
export default function AuctionsPage() {
const { isAuthenticated, subscription } = useStore()
const [allAuctions, setAllAuctions] = useState<Auction[]>([])
const [endingSoon, setEndingSoon] = useState<Auction[]>([])
const [hotAuctions, setHotAuctions] = useState<Auction[]>([])
const [opportunities, setOpportunities] = useState<Opportunity[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [activeTab, setActiveTab] = useState<TabType>('all')
const [sortBy, setSortBy] = useState<SortField>('ending')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
// Filters
const [searchQuery, setSearchQuery] = useState('')
const [selectedPlatform, setSelectedPlatform] = useState('All')
const [maxBid, setMaxBid] = useState('')
const [filterPreset, setFilterPreset] = useState<FilterPreset>('all')
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set())
const [trackingInProgress, setTrackingInProgress] = useState<string | null>(null)
const isPaidUser = subscription?.tier === 'trader' || subscription?.tier === 'tycoon'
// Data loading
const loadData = useCallback(async () => {
setLoading(true)
try {
const [auctionsData, hotData, endingData] = await Promise.all([
api.getAuctions(),
api.getHotAuctions(50),
api.getEndingSoonAuctions(24, 50),
])
setAllAuctions(auctionsData.auctions || [])
setHotAuctions(hotData || [])
setEndingSoon(endingData || [])
} catch (error) {
console.error('Failed to load auction data:', error)
} finally {
setLoading(false)
}
}, [])
const loadOpportunities = useCallback(async () => {
try {
const oppData = await api.getAuctionOpportunities()
setOpportunities(oppData.opportunities || [])
} catch (e) {
console.error('Failed to load opportunities:', e)
}
}, [])
useEffect(() => {
loadData()
}, [loadData])
useEffect(() => {
if (isAuthenticated && opportunities.length === 0) {
loadOpportunities()
}
}, [isAuthenticated, opportunities.length, loadOpportunities])
const handleRefresh = useCallback(async () => {
setRefreshing(true)
await loadData()
if (isAuthenticated) await loadOpportunities()
setRefreshing(false)
}, [loadData, loadOpportunities, isAuthenticated])
const handleTrackDomain = useCallback(async (domain: string) => {
if (trackedDomains.has(domain)) return
setTrackingInProgress(domain)
try {
await api.addDomainToWatchlist({ domain })
setTrackedDomains(prev => new Set([...prev, domain]))
} catch (error) {
console.error('Failed to track domain:', error)
} finally {
setTrackingInProgress(null)
}
}, [trackedDomains])
const handleSort = useCallback((field: string) => {
const f = field as SortField
if (sortBy === f) {
setSortDirection(d => d === 'asc' ? 'desc' : 'asc')
} else {
setSortBy(f)
setSortDirection('asc')
}
}, [sortBy])
// Memoized tabs
const tabs = useMemo(() => [
{ id: 'all', label: 'All', icon: Gavel, count: allAuctions.length },
{ id: 'ending', label: 'Ending Soon', icon: Timer, count: endingSoon.length, color: 'warning' as const },
{ id: 'hot', label: 'Hot', icon: Flame, count: hotAuctions.length },
{ id: 'opportunities', label: 'Opportunities', icon: Target, count: opportunities.length },
], [allAuctions.length, endingSoon.length, hotAuctions.length, opportunities.length])
// Filter and sort auctions
const sortedAuctions = useMemo(() => {
// Get base auctions for current tab
let auctions: Auction[] = []
switch (activeTab) {
case 'ending': auctions = [...endingSoon]; break
case 'hot': auctions = [...hotAuctions]; break
case 'opportunities': auctions = opportunities.map(o => o.auction); break
default: auctions = [...allAuctions]
}
// Apply preset filter
const baseFilter = filterPreset === 'all' && isPaidUser ? 'no-trash' : filterPreset
switch (baseFilter) {
case 'no-trash': auctions = auctions.filter(isCleanDomain); break
case 'short': auctions = auctions.filter(a => a.domain.split('.')[0].length <= 4); break
case 'high-value': auctions = auctions.filter(a =>
PREMIUM_TLDS.slice(0, 3).includes(a.tld) && a.num_bids >= 5 && calculateDealScore(a) >= 70
); break
case 'low-competition': auctions = auctions.filter(a => a.num_bids < 5); break
}
// Apply search
if (searchQuery) {
const q = searchQuery.toLowerCase()
auctions = auctions.filter(a => a.domain.toLowerCase().includes(q))
}
// Apply platform filter
if (selectedPlatform !== 'All') {
auctions = auctions.filter(a => a.platform === selectedPlatform)
}
// Apply max bid
if (maxBid) {
const max = parseFloat(maxBid)
auctions = auctions.filter(a => a.current_bid <= max)
}
// Sort (skip for opportunities - already sorted by score)
if (activeTab !== 'opportunities') {
const mult = sortDirection === 'asc' ? 1 : -1
auctions.sort((a, b) => {
switch (sortBy) {
case 'ending': return mult * (new Date(a.end_time).getTime() - new Date(b.end_time).getTime())
case 'bid_asc':
case 'bid_desc': return mult * (a.current_bid - b.current_bid)
case 'bids': return mult * (b.num_bids - a.num_bids)
case 'score': return mult * (calculateDealScore(b) - calculateDealScore(a))
default: return 0
}
})
}
return auctions
}, [activeTab, allAuctions, endingSoon, hotAuctions, opportunities, filterPreset, isPaidUser, searchQuery, selectedPlatform, maxBid, sortBy, sortDirection])
// Subtitle
const subtitle = useMemo(() => {
if (loading) return 'Loading live auctions...'
const total = allAuctions.length
if (total === 0) return 'No active auctions found'
return `${sortedAuctions.length.toLocaleString()} auctions ${sortedAuctions.length < total ? `(filtered from ${total.toLocaleString()})` : 'across 4 platforms'}`
}, [loading, allAuctions.length, sortedAuctions.length])
// Get opportunity data helper
const getOpportunityData = useCallback((domain: string) => {
if (activeTab !== 'opportunities') return null
return opportunities.find(o => o.auction.domain === domain)?.analysis
}, [activeTab, opportunities])
// Table columns - memoized
const columns = useMemo(() => [
{
key: 'domain',
header: 'Domain',
sortable: true,
render: (a: Auction) => (
<div>
<a
href={a.affiliate_url}
target="_blank"
rel="noopener noreferrer"
className="font-mono font-medium text-foreground hover:text-accent transition-colors"
>
{a.domain}
</a>
<div className="flex items-center gap-2 mt-1 lg:hidden">
<PlatformBadge platform={a.platform} />
{a.age_years && <span className="text-xs text-foreground-subtle">{a.age_years}y</span>}
</div>
</div>
),
},
{
key: 'platform',
header: 'Platform',
hideOnMobile: true,
render: (a: Auction) => (
<div className="space-y-1">
<PlatformBadge platform={a.platform} />
{a.age_years && (
<span className="text-xs text-foreground-subtle flex items-center gap-1">
<Clock className="w-3 h-3" /> {a.age_years}y
</span>
)}
</div>
),
},
{
key: 'bid_asc',
header: 'Bid',
sortable: true,
align: 'right' as const,
render: (a: Auction) => (
<div>
<span className="font-medium text-foreground">{formatCurrency(a.current_bid)}</span>
{a.buy_now_price && (
<p className="text-xs text-accent">Buy: {formatCurrency(a.buy_now_price)}</p>
)}
</div>
),
},
{
key: 'score',
header: 'Deal Score',
sortable: true,
align: 'center' as const,
hideOnMobile: true,
render: (a: Auction) => {
if (activeTab === 'opportunities') {
const oppData = getOpportunityData(a.domain)
if (oppData) {
return (
<span className="inline-flex items-center justify-center w-9 h-9 bg-accent/10 text-accent font-bold rounded-lg">
{oppData.opportunity_score}
</span>
)
}
}
if (!isPaidUser) {
return (
<Link
href="/pricing"
className="inline-flex items-center justify-center w-9 h-9 bg-foreground/5 text-foreground-subtle rounded-lg hover:bg-accent/10 hover:text-accent transition-all"
title="Upgrade to see Deal Score"
>
<Crown className="w-4 h-4" />
</Link>
)
}
const score = calculateDealScore(a)
return (
<div className="inline-flex flex-col items-center">
<span className={clsx(
"inline-flex items-center justify-center w-9 h-9 rounded-lg font-bold text-sm",
score >= 75 ? "bg-accent/20 text-accent" :
score >= 50 ? "bg-amber-500/20 text-amber-400" :
"bg-foreground/10 text-foreground-muted"
)}>
{score}
</span>
{score >= 75 && <span className="text-[10px] text-accent mt-0.5 font-medium">Undervalued</span>}
</div>
)
},
},
{
key: 'bids',
header: 'Bids',
sortable: true,
align: 'right' as const,
hideOnMobile: true,
render: (a: Auction) => (
<span className={clsx(
"font-medium flex items-center justify-end gap-1",
a.num_bids >= 20 ? "text-accent" : a.num_bids >= 10 ? "text-amber-400" : "text-foreground-muted"
)}>
{a.num_bids}
{a.num_bids >= 20 && <Flame className="w-3 h-3" />}
</span>
),
},
{
key: 'ending',
header: 'Time Left',
sortable: true,
align: 'right' as const,
hideOnMobile: true,
render: (a: Auction) => (
<span className={clsx("font-medium", getTimeColor(a.time_remaining))}>
{a.time_remaining}
</span>
),
},
{
key: 'actions',
header: '',
align: 'right' as const,
render: (a: Auction) => (
<div className="flex items-center gap-2 justify-end">
<button
onClick={(e) => { e.preventDefault(); handleTrackDomain(a.domain) }}
disabled={trackedDomains.has(a.domain) || trackingInProgress === a.domain}
className={clsx(
"inline-flex items-center justify-center w-8 h-8 rounded-lg transition-all",
trackedDomains.has(a.domain)
? "bg-accent/20 text-accent cursor-default"
: "bg-foreground/5 text-foreground-subtle hover:bg-accent/10 hover:text-accent"
)}
title={trackedDomains.has(a.domain) ? 'Already tracked' : 'Add to Watchlist'}
>
{trackingInProgress === a.domain ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : trackedDomains.has(a.domain) ? (
<Check className="w-4 h-4" />
) : (
<Plus className="w-4 h-4" />
)}
</button>
<a
href={a.affiliate_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-4 py-2 bg-foreground text-background text-xs font-medium rounded-lg hover:bg-foreground/90 transition-all"
>
Bid <ExternalLink className="w-3 h-3" />
</a>
</div>
),
},
], [activeTab, isPaidUser, trackedDomains, trackingInProgress, handleTrackDomain, getOpportunityData])
return (
<CommandCenterLayout
title="Auctions"
subtitle={subtitle}
actions={
<ActionButton onClick={handleRefresh} disabled={refreshing} variant="ghost" icon={refreshing ? Loader2 : RefreshCw}>
{refreshing ? '' : 'Refresh'}
</ActionButton>
}
>
<PageContainer>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="All Auctions" value={allAuctions.length} icon={Gavel} />
<StatCard title="Ending Soon" value={endingSoon.length} icon={Timer} />
<StatCard title="Hot Auctions" value={hotAuctions.length} subtitle="20+ bids" icon={Flame} />
<StatCard title="Opportunities" value={opportunities.length} icon={Target} />
</div>
{/* Tabs */}
<TabBar tabs={tabs} activeTab={activeTab} onChange={(id) => setActiveTab(id as TabType)} />
{/* Smart Filter Presets */}
<div className="flex flex-wrap gap-2 p-1.5 bg-background-secondary/30 border border-border/30 rounded-xl">
{FILTER_PRESETS.map((preset) => {
const isDisabled = preset.proOnly && !isPaidUser
const isActive = filterPreset === preset.id
const Icon = preset.icon
return (
<button
key={preset.id}
onClick={() => !isDisabled && setFilterPreset(preset.id)}
disabled={isDisabled}
title={isDisabled ? 'Upgrade to Trader to use this filter' : preset.description}
className={clsx(
"flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg transition-all",
isActive
? "bg-accent text-background shadow-md"
: isDisabled
? "text-foreground-subtle opacity-50 cursor-not-allowed"
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
)}
>
<Icon className="w-4 h-4" />
<span className="hidden sm:inline">{preset.label}</span>
{preset.proOnly && !isPaidUser && <Crown className="w-3 h-3 text-amber-400" />}
</button>
)
})}
</div>
{/* Tier notification for Scout users */}
{!isPaidUser && (
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-center gap-3">
<div className="w-10 h-10 bg-amber-500/20 rounded-xl flex items-center justify-center shrink-0">
<Eye className="w-5 h-5 text-amber-400" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-foreground">You&apos;re seeing the raw auction feed</p>
<p className="text-xs text-foreground-muted">
Upgrade to Trader for spam-free listings, Deal Scores, and Smart Filters.
</p>
</div>
<Link
href="/pricing"
className="shrink-0 px-4 py-2 bg-accent text-background text-sm font-medium rounded-lg hover:bg-accent-hover transition-all"
>
Upgrade
</Link>
</div>
)}
{/* Filters */}
<FilterBar>
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search domains..."
className="flex-1 min-w-[200px] max-w-md"
/>
<SelectDropdown value={selectedPlatform} onChange={setSelectedPlatform} options={PLATFORMS} />
<div className="relative">
<DollarSign className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted" />
<input
type="number"
placeholder="Max bid"
value={maxBid}
onChange={(e) => setMaxBid(e.target.value)}
className="w-28 h-10 pl-9 pr-3 bg-background-secondary/50 border border-border/40 rounded-xl
text-sm text-foreground placeholder:text-foreground-subtle
focus:outline-none focus:border-accent/50 transition-all"
/>
</div>
</FilterBar>
{/* Table */}
<PremiumTable
data={sortedAuctions}
keyExtractor={(a) => `${a.domain}-${a.platform}`}
loading={loading}
sortBy={sortBy}
sortDirection={sortDirection}
onSort={handleSort}
emptyIcon={<Gavel className="w-12 h-12 text-foreground-subtle" />}
emptyTitle={searchQuery ? `No auctions matching "${searchQuery}"` : "No auctions found"}
emptyDescription="Try adjusting your filters or check back later"
columns={columns}
/>
</PageContainer>
</CommandCenterLayout>
)
}

View File

@ -0,0 +1,402 @@
'use client'
import { useEffect, useState, useMemo, useCallback } from 'react'
import { useSearchParams } from 'next/navigation'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
import { PremiumTable, StatCard, PageContainer, Badge, SectionHeader, SearchInput, ActionButton } from '@/components/PremiumTable'
import { Toast, useToast } from '@/components/Toast'
import {
Eye,
Briefcase,
TrendingUp,
Gavel,
Clock,
ExternalLink,
Sparkles,
ChevronRight,
Plus,
Zap,
Crown,
Activity,
Loader2,
Search,
} from 'lucide-react'
import clsx from 'clsx'
import Link from 'next/link'
interface HotAuction {
domain: string
current_bid: number
time_remaining: string
platform: string
affiliate_url?: string
}
interface TrendingTld {
tld: string
current_price: number
price_change: number
reason: string
}
export default function DashboardPage() {
const searchParams = useSearchParams()
const {
isAuthenticated,
isLoading,
user,
domains,
subscription
} = useStore()
const { toast, showToast, hideToast } = useToast()
const [hotAuctions, setHotAuctions] = useState<HotAuction[]>([])
const [trendingTlds, setTrendingTlds] = useState<TrendingTld[]>([])
const [loadingAuctions, setLoadingAuctions] = useState(true)
const [loadingTlds, setLoadingTlds] = useState(true)
const [quickDomain, setQuickDomain] = useState('')
const [addingDomain, setAddingDomain] = useState(false)
// Check for upgrade success
useEffect(() => {
if (searchParams.get('upgraded') === 'true') {
showToast('Welcome to your upgraded plan! 🎉', 'success')
window.history.replaceState({}, '', '/command/dashboard')
}
}, [searchParams])
const loadDashboardData = useCallback(async () => {
try {
const [auctions, trending] = await Promise.all([
api.getEndingSoonAuctions(5).catch(() => []),
api.getTrendingTlds().catch(() => ({ trending: [] }))
])
setHotAuctions(auctions.slice(0, 5))
setTrendingTlds(trending.trending?.slice(0, 4) || [])
} catch (error) {
console.error('Failed to load dashboard data:', error)
} finally {
setLoadingAuctions(false)
setLoadingTlds(false)
}
}, [])
// Load dashboard data
useEffect(() => {
if (isAuthenticated) {
loadDashboardData()
}
}, [isAuthenticated, loadDashboardData])
const handleQuickAdd = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
if (!quickDomain.trim()) return
setAddingDomain(true)
try {
const store = useStore.getState()
await store.addDomain(quickDomain.trim())
setQuickDomain('')
showToast(`Added ${quickDomain.trim()} to watchlist`, 'success')
} catch (err: any) {
showToast(err.message || 'Failed to add domain', 'error')
} finally {
setAddingDomain(false)
}
}, [quickDomain, showToast])
// Memoized computed values
const { availableDomains, totalDomains, tierName, TierIcon, greeting, subtitle } = useMemo(() => {
const availableDomains = domains?.filter(d => d.is_available) || []
const totalDomains = domains?.length || 0
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
const TierIcon = tierName === 'Tycoon' ? Crown : tierName === 'Trader' ? TrendingUp : Zap
const hour = new Date().getHours()
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening'
let subtitle = ''
if (availableDomains.length > 0) {
subtitle = `${availableDomains.length} domain${availableDomains.length !== 1 ? 's' : ''} ready to pounce!`
} else if (totalDomains > 0) {
subtitle = `Monitoring ${totalDomains} domain${totalDomains !== 1 ? 's' : ''} for you`
} else {
subtitle = 'Start tracking domains to find opportunities'
}
return { availableDomains, totalDomains, tierName, TierIcon, greeting, subtitle }
}, [domains, subscription])
if (isLoading || !isAuthenticated) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
)
}
return (
<CommandCenterLayout
title={`${greeting}${user?.name ? `, ${user.name.split(' ')[0]}` : ''}`}
subtitle={subtitle}
>
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
<PageContainer>
{/* Quick Add */}
<div className="relative p-5 sm:p-6 bg-gradient-to-r from-accent/10 via-accent/5 to-transparent border border-accent/20 rounded-2xl overflow-hidden">
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
<div className="relative">
<h2 className="text-base font-semibold text-foreground mb-4 flex items-center gap-2">
<div className="w-8 h-8 bg-accent/20 rounded-lg flex items-center justify-center">
<Search className="w-4 h-4 text-accent" />
</div>
Quick Add to Watchlist
</h2>
<form onSubmit={handleQuickAdd} className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
<input
type="text"
value={quickDomain}
onChange={(e) => setQuickDomain(e.target.value)}
placeholder="Enter domain to track (e.g., dream.com)"
className="w-full h-11 pl-11 pr-4 bg-background/80 backdrop-blur-sm border border-border/50 rounded-xl
text-sm text-foreground placeholder:text-foreground-subtle
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
/>
</div>
<button
type="submit"
disabled={addingDomain || !quickDomain.trim()}
className="flex items-center justify-center gap-2 h-11 px-6 bg-gradient-to-r from-accent to-accent/80 text-background rounded-xl
font-medium hover:shadow-[0_0_20px_-5px_rgba(16,185,129,0.4)] transition-all
disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-4 h-4" />
<span>Add</span>
</button>
</form>
</div>
</div>
{/* Stats Overview */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Link href="/command/watchlist" className="group">
<StatCard
title="Domains Watched"
value={totalDomains}
icon={Eye}
/>
</Link>
<Link href="/command/watchlist?filter=available" className="group">
<StatCard
title="Available Now"
value={availableDomains.length}
icon={Sparkles}
accent={availableDomains.length > 0}
/>
</Link>
<Link href="/command/portfolio" className="group">
<StatCard
title="Portfolio"
value={0}
icon={Briefcase}
/>
</Link>
<StatCard
title="Plan"
value={tierName}
subtitle={`${subscription?.domains_used || 0}/${subscription?.domain_limit || 5} slots`}
icon={TierIcon}
/>
</div>
{/* Activity Feed + Market Pulse */}
<div className="grid lg:grid-cols-2 gap-6">
{/* Activity Feed */}
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm">
<div className="p-5 border-b border-border/30">
<SectionHeader
title="Activity Feed"
icon={Activity}
compact
action={
<Link href="/command/watchlist" className="text-sm text-accent hover:text-accent/80 transition-colors">
View all
</Link>
}
/>
</div>
<div className="p-5">
{availableDomains.length > 0 ? (
<div className="space-y-3">
{availableDomains.slice(0, 4).map((domain) => (
<div
key={domain.id}
className="flex items-center gap-4 p-3 bg-accent/5 border border-accent/20 rounded-xl"
>
<div className="relative">
<span className="w-3 h-3 bg-accent rounded-full block" />
<span className="absolute inset-0 bg-accent rounded-full animate-ping opacity-50" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{domain.name}</p>
<p className="text-xs text-accent">Available for registration!</p>
</div>
<a
href={`https://www.namecheap.com/domains/registration/results/?domain=${domain.name}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-accent hover:underline flex items-center gap-1"
>
Register <ExternalLink className="w-3 h-3" />
</a>
</div>
))}
{availableDomains.length > 4 && (
<p className="text-center text-sm text-foreground-muted">
+{availableDomains.length - 4} more available
</p>
)}
</div>
) : totalDomains > 0 ? (
<div className="text-center py-8">
<Bell className="w-10 h-10 text-foreground-subtle mx-auto mb-3" />
<p className="text-foreground-muted">All domains are still registered</p>
<p className="text-sm text-foreground-subtle mt-1">
We're monitoring {totalDomains} domains for you
</p>
</div>
) : (
<div className="text-center py-8">
<Plus className="w-10 h-10 text-foreground-subtle mx-auto mb-3" />
<p className="text-foreground-muted">No domains tracked yet</p>
<p className="text-sm text-foreground-subtle mt-1">
Add a domain above to start monitoring
</p>
</div>
)}
</div>
</div>
{/* Market Pulse */}
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm">
<div className="p-5 border-b border-border/30">
<SectionHeader
title="Market Pulse"
icon={Gavel}
compact
action={
<Link href="/command/auctions" className="text-sm text-accent hover:text-accent/80 transition-colors">
View all →
</Link>
}
/>
</div>
<div className="p-5">
{loadingAuctions ? (
<div className="space-y-3">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-14 bg-foreground/5 rounded-xl animate-pulse" />
))}
</div>
) : hotAuctions.length > 0 ? (
<div className="space-y-3">
{hotAuctions.map((auction, idx) => (
<a
key={`${auction.domain}-${idx}`}
href={auction.affiliate_url || '#'}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 p-3 bg-foreground/5 rounded-xl
hover:bg-foreground/10 transition-colors group"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{auction.domain}</p>
<p className="text-xs text-foreground-muted flex items-center gap-2">
<Clock className="w-3 h-3" />
{auction.time_remaining}
<span className="text-foreground-subtle">• {auction.platform}</span>
</p>
</div>
<div className="text-right">
<p className="text-sm font-semibold text-foreground">${auction.current_bid}</p>
<p className="text-xs text-foreground-subtle">current bid</p>
</div>
<ExternalLink className="w-4 h-4 text-foreground-subtle group-hover:text-foreground" />
</a>
))}
</div>
) : (
<div className="text-center py-8">
<Gavel className="w-10 h-10 text-foreground-subtle mx-auto mb-3" />
<p className="text-foreground-muted">No auctions ending soon</p>
</div>
)}
</div>
</div>
</div>
{/* Trending TLDs */}
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm">
<div className="p-5 border-b border-border/30">
<SectionHeader
title="Trending TLDs"
icon={TrendingUp}
compact
action={
<Link href="/command/pricing" className="text-sm text-accent hover:text-accent/80 transition-colors">
View all →
</Link>
}
/>
</div>
<div className="p-5">
{loadingTlds ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-24 bg-foreground/5 rounded-xl animate-pulse" />
))}
</div>
) : trendingTlds.length > 0 ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{trendingTlds.map((tld) => (
<Link
key={tld.tld}
href={`/tld-pricing/${tld.tld}`}
className="group relative p-4 bg-foreground/5 border border-border/30 rounded-xl
hover:border-accent/30 transition-all duration-300 overflow-hidden"
>
<div className="absolute inset-0 bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative">
<div className="flex items-center justify-between mb-3">
<span className="font-mono text-2xl font-semibold text-foreground group-hover:text-accent transition-colors">.{tld.tld}</span>
<span className={clsx(
"text-xs font-bold px-2.5 py-1 rounded-lg border",
(tld.price_change || 0) > 0
? "text-orange-400 bg-orange-400/10 border-orange-400/20"
: "text-accent bg-accent/10 border-accent/20"
)}>
{(tld.price_change || 0) > 0 ? '+' : ''}{(tld.price_change || 0).toFixed(1)}%
</span>
</div>
<p className="text-sm text-foreground-muted truncate">{tld.reason}</p>
</div>
</Link>
))}
</div>
) : (
<div className="text-center py-8">
<TrendingUp className="w-10 h-10 text-foreground-subtle mx-auto mb-3" />
<p className="text-foreground-muted">No trending TLDs available</p>
</div>
)}
</div>
</div>
</PageContainer>
</CommandCenterLayout>
)
}

View File

@ -0,0 +1,582 @@
'use client'
import { useEffect, useState, useMemo, useCallback } from 'react'
import { useSearchParams } from 'next/navigation'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
import { PageContainer, StatCard, Badge, ActionButton } from '@/components/PremiumTable'
import {
Plus,
Shield,
Eye,
MessageSquare,
ExternalLink,
Loader2,
Trash2,
CheckCircle,
AlertCircle,
Copy,
RefreshCw,
DollarSign,
X,
Tag,
Store,
Sparkles,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
interface Listing {
id: number
domain: string
slug: string
title: string | null
description: string | null
asking_price: number | null
min_offer: number | null
currency: string
price_type: string
pounce_score: number | null
estimated_value: number | null
verification_status: string
is_verified: boolean
status: string
show_valuation: boolean
allow_offers: boolean
view_count: number
inquiry_count: number
public_url: string
created_at: string
published_at: string | null
}
interface VerificationInfo {
verification_code: string
dns_record_type: string
dns_record_name: string
dns_record_value: string
instructions: string
status: string
}
export default function MyListingsPage() {
const { subscription } = useStore()
const searchParams = useSearchParams()
const prefillDomain = searchParams.get('domain')
const [listings, setListings] = useState<Listing[]>([])
const [loading, setLoading] = useState(true)
// Modals - auto-open if domain is prefilled
const [showCreateModal, setShowCreateModal] = useState(false)
const [showVerifyModal, setShowVerifyModal] = useState(false)
const [selectedListing, setSelectedListing] = useState<Listing | null>(null)
const [verificationInfo, setVerificationInfo] = useState<VerificationInfo | null>(null)
const [verifying, setVerifying] = useState(false)
const [creating, setCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
// Create form
const [newListing, setNewListing] = useState({
domain: '',
title: '',
description: '',
asking_price: '',
price_type: 'negotiable',
allow_offers: true,
})
const loadListings = useCallback(async () => {
setLoading(true)
try {
const data = await api.request<Listing[]>('/listings/my')
setListings(data)
} catch (err: any) {
console.error('Failed to load listings:', err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadListings()
}, [loadListings])
// Auto-open create modal if domain is prefilled from portfolio
useEffect(() => {
if (prefillDomain) {
setNewListing(prev => ({ ...prev, domain: prefillDomain }))
setShowCreateModal(true)
}
}, [prefillDomain])
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
setCreating(true)
setError(null)
try {
await api.request('/listings', {
method: 'POST',
body: JSON.stringify({
domain: newListing.domain,
title: newListing.title || null,
description: newListing.description || null,
asking_price: newListing.asking_price ? parseFloat(newListing.asking_price) : null,
price_type: newListing.price_type,
allow_offers: newListing.allow_offers,
}),
})
setSuccess('Listing created! Now verify ownership to publish.')
setShowCreateModal(false)
setNewListing({ domain: '', title: '', description: '', asking_price: '', price_type: 'negotiable', allow_offers: true })
loadListings()
} catch (err: any) {
setError(err.message)
} finally {
setCreating(false)
}
}
const handleStartVerification = async (listing: Listing) => {
setSelectedListing(listing)
setVerifying(true)
try {
const info = await api.request<VerificationInfo>(`/listings/${listing.id}/verify-dns`, {
method: 'POST',
})
setVerificationInfo(info)
setShowVerifyModal(true)
} catch (err: any) {
setError(err.message)
} finally {
setVerifying(false)
}
}
const handleCheckVerification = async () => {
if (!selectedListing) return
setVerifying(true)
try {
const result = await api.request<{ verified: boolean; message: string }>(
`/listings/${selectedListing.id}/verify-dns/check`
)
if (result.verified) {
setSuccess('Domain verified! You can now publish your listing.')
setShowVerifyModal(false)
loadListings()
} else {
setError(result.message)
}
} catch (err: any) {
setError(err.message)
} finally {
setVerifying(false)
}
}
const handlePublish = async (listing: Listing) => {
try {
await api.request(`/listings/${listing.id}`, {
method: 'PUT',
body: JSON.stringify({ status: 'active' }),
})
setSuccess('Listing published!')
loadListings()
} catch (err: any) {
setError(err.message)
}
}
const handleDelete = async (listing: Listing) => {
if (!confirm(`Delete listing for ${listing.domain}?`)) return
try {
await api.request(`/listings/${listing.id}`, { method: 'DELETE' })
setSuccess('Listing deleted')
loadListings()
} catch (err: any) {
setError(err.message)
}
}
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
setSuccess('Copied to clipboard!')
}
const formatPrice = (price: number | null, currency: string) => {
if (!price) return 'Make Offer'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 0,
}).format(price)
}
const getStatusBadge = (status: string, isVerified: boolean) => {
if (status === 'active') return <Badge variant="success">Live</Badge>
if (status === 'draft' && !isVerified) return <Badge variant="warning">Needs Verification</Badge>
if (status === 'draft') return <Badge>Draft</Badge>
if (status === 'sold') return <Badge variant="accent">Sold</Badge>
return <Badge>{status}</Badge>
}
const tier = subscription?.tier || 'scout'
const limits = { scout: 2, trader: 10, tycoon: 50 }
const maxListings = limits[tier as keyof typeof limits] || 2
return (
<CommandCenterLayout
title="My Listings"
subtitle={`Manage your domains for sale • ${listings.length}/${maxListings} slots used`}
actions={
<div className="flex items-center gap-3">
<Link
href="/buy"
className="flex items-center gap-2 px-4 py-2 text-foreground-muted text-sm font-medium
border border-border rounded-lg hover:bg-foreground/5 transition-all"
>
<Store className="w-4 h-4" />
Browse Marketplace
</Link>
<button
onClick={() => setShowCreateModal(true)}
disabled={listings.length >= maxListings}
className="flex items-center gap-2 px-4 py-2 bg-accent text-background text-sm font-medium rounded-lg
hover:bg-accent-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-4 h-4" />
List Domain
</button>
</div>
}
>
<PageContainer>
{/* Messages */}
{error && (
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-400" />
<p className="text-sm text-red-400 flex-1">{error}</p>
<button onClick={() => setError(null)}><X className="w-4 h-4 text-red-400" /></button>
</div>
)}
{success && (
<div className="p-4 bg-accent/10 border border-accent/20 rounded-xl flex items-center gap-3">
<CheckCircle className="w-5 h-5 text-accent" />
<p className="text-sm text-accent flex-1">{success}</p>
<button onClick={() => setSuccess(null)}><X className="w-4 h-4 text-accent" /></button>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="My Listings" value={`${listings.length}/${maxListings}`} icon={Tag} />
<StatCard
title="Published"
value={listings.filter(l => l.status === 'active').length}
icon={CheckCircle}
accent
/>
<StatCard
title="Total Views"
value={listings.reduce((sum, l) => sum + l.view_count, 0)}
icon={Eye}
/>
<StatCard
title="Inquiries"
value={listings.reduce((sum, l) => sum + l.inquiry_count, 0)}
icon={MessageSquare}
/>
</div>
{/* Listings */}
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
</div>
) : listings.length === 0 ? (
<div className="text-center py-16 bg-background-secondary/30 border border-border rounded-2xl">
<Sparkles className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
<h2 className="text-xl font-medium text-foreground mb-2">No Listings Yet</h2>
<p className="text-foreground-muted mb-6 max-w-md mx-auto">
Create your first listing to sell a domain on the Pounce marketplace.
</p>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
>
<Plus className="w-5 h-5" />
Create Listing
</button>
</div>
) : (
<div className="space-y-4">
{listings.map((listing) => (
<div
key={listing.id}
className="p-5 bg-background-secondary/30 border border-border rounded-2xl hover:border-border-hover transition-all"
>
<div className="flex flex-wrap items-start gap-4">
{/* Domain Info */}
<div className="flex-1 min-w-[200px]">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-mono text-lg font-medium text-foreground">{listing.domain}</h3>
{getStatusBadge(listing.status, listing.is_verified)}
{listing.is_verified && (
<div className="w-6 h-6 bg-accent/10 rounded flex items-center justify-center" title="Verified">
<Shield className="w-3 h-3 text-accent" />
</div>
)}
</div>
{listing.title && (
<p className="text-sm text-foreground-muted">{listing.title}</p>
)}
</div>
{/* Price */}
<div className="text-right">
<p className="text-xl font-semibold text-foreground">
{formatPrice(listing.asking_price, listing.currency)}
</p>
{listing.pounce_score && (
<p className="text-xs text-foreground-muted">Score: {listing.pounce_score}</p>
)}
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm text-foreground-muted">
<span className="flex items-center gap-1">
<Eye className="w-4 h-4" /> {listing.view_count}
</span>
<span className="flex items-center gap-1">
<MessageSquare className="w-4 h-4" /> {listing.inquiry_count}
</span>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{!listing.is_verified && (
<button
onClick={() => handleStartVerification(listing)}
disabled={verifying}
className="flex items-center gap-1.5 px-3 py-2 bg-amber-500/10 text-amber-400 text-sm font-medium rounded-lg hover:bg-amber-500/20 transition-all"
>
<Shield className="w-4 h-4" />
Verify
</button>
)}
{listing.is_verified && listing.status === 'draft' && (
<button
onClick={() => handlePublish(listing)}
className="flex items-center gap-1.5 px-3 py-2 bg-accent text-background text-sm font-medium rounded-lg hover:bg-accent-hover transition-all"
>
<CheckCircle className="w-4 h-4" />
Publish
</button>
)}
{listing.status === 'active' && (
<Link
href={`/buy/${listing.slug}`}
target="_blank"
className="flex items-center gap-1.5 px-3 py-2 bg-foreground/5 text-foreground-muted text-sm font-medium rounded-lg hover:bg-foreground/10 transition-all"
>
<ExternalLink className="w-4 h-4" />
View
</Link>
)}
<button
onClick={() => handleDelete(listing)}
className="p-2 text-foreground-subtle hover:text-red-400 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</PageContainer>
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm">
<div className="w-full max-w-lg bg-background-secondary border border-border rounded-2xl p-6">
<h2 className="text-xl font-semibold text-foreground mb-6">List Domain for Sale</h2>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm text-foreground-muted mb-1">Domain *</label>
<input
type="text"
required
value={newListing.domain}
onChange={(e) => setNewListing({ ...newListing, domain: e.target.value })}
placeholder="example.com"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1">Headline</label>
<input
type="text"
value={newListing.title}
onChange={(e) => setNewListing({ ...newListing, title: e.target.value })}
placeholder="Perfect for AI startups"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1">Description</label>
<textarea
rows={3}
value={newListing.description}
onChange={(e) => setNewListing({ ...newListing, description: e.target.value })}
placeholder="Tell potential buyers about this domain..."
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-foreground-muted mb-1">Asking Price (USD)</label>
<div className="relative">
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
<input
type="number"
value={newListing.asking_price}
onChange={(e) => setNewListing({ ...newListing, asking_price: e.target.value })}
placeholder="Leave empty for 'Make Offer'"
className="w-full pl-9 pr-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1">Price Type</label>
<select
value={newListing.price_type}
onChange={(e) => setNewListing({ ...newListing, price_type: e.target.value })}
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground focus:outline-none focus:border-accent"
>
<option value="negotiable">Negotiable</option>
<option value="fixed">Fixed Price</option>
<option value="make_offer">Make Offer Only</option>
</select>
</div>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={newListing.allow_offers}
onChange={(e) => setNewListing({ ...newListing, allow_offers: e.target.checked })}
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
/>
<span className="text-sm text-foreground">Allow buyers to make offers</span>
</label>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="flex-1 px-4 py-3 border border-border text-foreground-muted rounded-xl hover:bg-foreground/5 transition-all"
>
Cancel
</button>
<button
type="submit"
disabled={creating}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all disabled:opacity-50"
>
{creating ? <Loader2 className="w-5 h-5 animate-spin" /> : <Plus className="w-5 h-5" />}
{creating ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Verification Modal */}
{showVerifyModal && verificationInfo && selectedListing && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm">
<div className="w-full max-w-xl bg-background-secondary border border-border rounded-2xl p-6">
<h2 className="text-xl font-semibold text-foreground mb-2">Verify Domain Ownership</h2>
<p className="text-sm text-foreground-muted mb-6">
Add a DNS TXT record to prove you own <strong>{selectedListing.domain}</strong>
</p>
<div className="space-y-4">
<div className="p-4 bg-background rounded-xl border border-border">
<p className="text-sm text-foreground-muted mb-2">Record Type</p>
<p className="font-mono text-foreground">{verificationInfo.dns_record_type}</p>
</div>
<div className="p-4 bg-background rounded-xl border border-border">
<p className="text-sm text-foreground-muted mb-2">Name / Host</p>
<div className="flex items-center justify-between">
<p className="font-mono text-foreground">{verificationInfo.dns_record_name}</p>
<button
onClick={() => copyToClipboard(verificationInfo.dns_record_name)}
className="p-2 text-foreground-subtle hover:text-accent transition-colors"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
<div className="p-4 bg-background rounded-xl border border-border">
<p className="text-sm text-foreground-muted mb-2">Value</p>
<div className="flex items-center justify-between">
<p className="font-mono text-sm text-foreground break-all">{verificationInfo.dns_record_value}</p>
<button
onClick={() => copyToClipboard(verificationInfo.dns_record_value)}
className="p-2 text-foreground-subtle hover:text-accent transition-colors shrink-0"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
<div className="p-4 bg-accent/5 border border-accent/20 rounded-xl">
<p className="text-sm text-foreground-muted whitespace-pre-line">
{verificationInfo.instructions}
</p>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => setShowVerifyModal(false)}
className="flex-1 px-4 py-3 border border-border text-foreground-muted rounded-xl hover:bg-foreground/5 transition-all"
>
Close
</button>
<button
onClick={handleCheckVerification}
disabled={verifying}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all disabled:opacity-50"
>
{verifying ? <Loader2 className="w-5 h-5 animate-spin" /> : <RefreshCw className="w-5 h-5" />}
{verifying ? 'Checking...' : 'Check Verification'}
</button>
</div>
</div>
</div>
)}
</CommandCenterLayout>
)
}

View File

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

View File

@ -0,0 +1,19 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
export default function CommandPage() {
const router = useRouter()
useEffect(() => {
router.replace('/command/dashboard')
}, [router])
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
)
}

View File

@ -0,0 +1,955 @@
'use client'
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
import { useStore } from '@/lib/store'
import { api, PortfolioDomain, PortfolioSummary, DomainValuation, DomainHealthReport, HealthStatus } from '@/lib/api'
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
import { PremiumTable, StatCard, PageContainer, ActionButton } from '@/components/PremiumTable'
import { Toast, useToast } from '@/components/Toast'
import {
Plus,
Trash2,
Edit2,
DollarSign,
Calendar,
Building,
Loader2,
ArrowUpRight,
X,
Briefcase,
ShoppingCart,
Activity,
Shield,
AlertTriangle,
Tag,
MoreVertical,
ExternalLink,
} from 'lucide-react'
import clsx from 'clsx'
import Link from 'next/link'
// Health status configuration
const healthStatusConfig: Record<HealthStatus, {
label: string
color: string
bgColor: string
icon: typeof Activity
}> = {
healthy: { label: 'Healthy', color: 'text-accent', bgColor: 'bg-accent/10', icon: Activity },
weakening: { label: 'Weak', color: 'text-amber-400', bgColor: 'bg-amber-400/10', icon: AlertTriangle },
parked: { label: 'Parked', color: 'text-orange-400', bgColor: 'bg-orange-400/10', icon: ShoppingCart },
critical: { label: 'Critical', color: 'text-red-400', bgColor: 'bg-red-400/10', icon: AlertTriangle },
unknown: { label: 'Unknown', color: 'text-foreground-muted', bgColor: 'bg-foreground/5', icon: Activity },
}
export default function PortfolioPage() {
const { subscription } = useStore()
const { toast, showToast, hideToast } = useToast()
const [portfolio, setPortfolio] = useState<PortfolioDomain[]>([])
const [summary, setSummary] = useState<PortfolioSummary | null>(null)
const [loading, setLoading] = useState(true)
const [showAddModal, setShowAddModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [showSellModal, setShowSellModal] = useState(false)
const [showValuationModal, setShowValuationModal] = useState(false)
const [selectedDomain, setSelectedDomain] = useState<PortfolioDomain | null>(null)
const [valuation, setValuation] = useState<DomainValuation | null>(null)
const [valuatingDomain, setValuatingDomain] = useState('')
const [addingDomain, setAddingDomain] = useState(false)
const [savingEdit, setSavingEdit] = useState(false)
const [processingSale, setProcessingSale] = useState(false)
const [refreshingId, setRefreshingId] = useState<number | null>(null)
// Health monitoring state
const [healthReports, setHealthReports] = useState<Record<string, DomainHealthReport>>({})
const [loadingHealth, setLoadingHealth] = useState<Record<string, boolean>>({})
const [selectedHealthDomain, setSelectedHealthDomain] = useState<string | null>(null)
// Dropdown menu state
const [openMenuId, setOpenMenuId] = useState<number | null>(null)
const [addForm, setAddForm] = useState({
domain: '',
purchase_price: '',
purchase_date: '',
registrar: '',
renewal_date: '',
renewal_cost: '',
notes: '',
})
const [editForm, setEditForm] = useState({
purchase_price: '',
purchase_date: '',
registrar: '',
renewal_date: '',
renewal_cost: '',
notes: '',
})
const [sellForm, setSellForm] = useState({
sale_date: new Date().toISOString().split('T')[0],
sale_price: '',
})
const loadPortfolio = useCallback(async () => {
setLoading(true)
try {
const [portfolioData, summaryData] = await Promise.all([
api.getPortfolio(),
api.getPortfolioSummary(),
])
setPortfolio(portfolioData)
setSummary(summaryData)
} catch (error) {
console.error('Failed to load portfolio:', error)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadPortfolio()
}, [loadPortfolio])
const handleAddDomain = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
if (!addForm.domain.trim()) return
setAddingDomain(true)
try {
await api.addPortfolioDomain({
domain: addForm.domain.trim(),
purchase_price: addForm.purchase_price ? parseFloat(addForm.purchase_price) : undefined,
purchase_date: addForm.purchase_date || undefined,
registrar: addForm.registrar || undefined,
renewal_date: addForm.renewal_date || undefined,
renewal_cost: addForm.renewal_cost ? parseFloat(addForm.renewal_cost) : undefined,
notes: addForm.notes || undefined,
})
showToast(`Added ${addForm.domain} to portfolio`, 'success')
setAddForm({ domain: '', purchase_price: '', purchase_date: '', registrar: '', renewal_date: '', renewal_cost: '', notes: '' })
setShowAddModal(false)
loadPortfolio()
} catch (err: any) {
showToast(err.message || 'Failed to add domain', 'error')
} finally {
setAddingDomain(false)
}
}, [addForm, loadPortfolio, showToast])
const handleEditDomain = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedDomain) return
setSavingEdit(true)
try {
await api.updatePortfolioDomain(selectedDomain.id, {
purchase_price: editForm.purchase_price ? parseFloat(editForm.purchase_price) : undefined,
purchase_date: editForm.purchase_date || undefined,
registrar: editForm.registrar || undefined,
renewal_date: editForm.renewal_date || undefined,
renewal_cost: editForm.renewal_cost ? parseFloat(editForm.renewal_cost) : undefined,
notes: editForm.notes || undefined,
})
showToast('Domain updated', 'success')
setShowEditModal(false)
loadPortfolio()
} catch (err: any) {
showToast(err.message || 'Failed to update', 'error')
} finally {
setSavingEdit(false)
}
}, [selectedDomain, editForm, loadPortfolio, showToast])
const handleSellDomain = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedDomain || !sellForm.sale_price) return
setProcessingSale(true)
try {
await api.markDomainSold(selectedDomain.id, sellForm.sale_date, parseFloat(sellForm.sale_price))
showToast(`Marked ${selectedDomain.domain} as sold`, 'success')
setShowSellModal(false)
loadPortfolio()
} catch (err: any) {
showToast(err.message || 'Failed to process sale', 'error')
} finally {
setProcessingSale(false)
}
}, [selectedDomain, sellForm, loadPortfolio, showToast])
const handleValuate = useCallback(async (domain: PortfolioDomain) => {
setValuatingDomain(domain.domain)
setShowValuationModal(true)
try {
const result = await api.getDomainValuation(domain.domain)
setValuation(result)
} catch (err: any) {
showToast(err.message || 'Failed to get valuation', 'error')
setShowValuationModal(false)
} finally {
setValuatingDomain('')
}
}, [showToast])
const handleRefresh = useCallback(async (domain: PortfolioDomain) => {
setRefreshingId(domain.id)
try {
await api.refreshDomainValue(domain.id)
showToast('Valuation refreshed', 'success')
loadPortfolio()
} catch (err: any) {
showToast(err.message || 'Failed to refresh', 'error')
} finally {
setRefreshingId(null)
}
}, [loadPortfolio, showToast])
const handleHealthCheck = useCallback(async (domainName: string) => {
if (loadingHealth[domainName]) return
setLoadingHealth(prev => ({ ...prev, [domainName]: true }))
try {
const report = await api.quickHealthCheck(domainName)
setHealthReports(prev => ({ ...prev, [domainName]: report }))
setSelectedHealthDomain(domainName)
} catch (err: any) {
showToast(err.message || 'Health check failed', 'error')
} finally {
setLoadingHealth(prev => ({ ...prev, [domainName]: false }))
}
}, [loadingHealth, showToast])
const handleDelete = useCallback(async (domain: PortfolioDomain) => {
if (!confirm(`Remove ${domain.domain} from your portfolio?`)) return
try {
await api.deletePortfolioDomain(domain.id)
showToast(`Removed ${domain.domain}`, 'success')
loadPortfolio()
} catch (err: any) {
showToast(err.message || 'Failed to remove', 'error')
}
}, [loadPortfolio, showToast])
const openEditModal = useCallback((domain: PortfolioDomain) => {
setSelectedDomain(domain)
setEditForm({
purchase_price: domain.purchase_price?.toString() || '',
purchase_date: domain.purchase_date || '',
registrar: domain.registrar || '',
renewal_date: domain.renewal_date || '',
renewal_cost: domain.renewal_cost?.toString() || '',
notes: domain.notes || '',
})
setShowEditModal(true)
}, [])
const openSellModal = useCallback((domain: PortfolioDomain) => {
setSelectedDomain(domain)
setSellForm({
sale_date: new Date().toISOString().split('T')[0],
sale_price: '',
})
setShowSellModal(true)
}, [])
const portfolioLimit = subscription?.portfolio_limit || 0
const canAddMore = portfolioLimit === -1 || portfolio.length < portfolioLimit
// Memoized stats and subtitle
const { expiringSoonCount, subtitle } = useMemo(() => {
const expiring = portfolio.filter(d => {
if (!d.renewal_date) return false
const days = Math.ceil((new Date(d.renewal_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
return days <= 30 && days > 0
}).length
let sub = ''
if (loading) sub = 'Loading your portfolio...'
else if (portfolio.length === 0) sub = 'Start tracking your domains'
else if (expiring > 0) sub = `${portfolio.length} domain${portfolio.length !== 1 ? 's' : ''}${expiring} expiring soon`
else sub = `Managing ${portfolio.length} domain${portfolio.length !== 1 ? 's' : ''}`
return { expiringSoonCount: expiring, subtitle: sub }
}, [portfolio, loading])
return (
<CommandCenterLayout
title="Portfolio"
subtitle={subtitle}
actions={
<ActionButton onClick={() => setShowAddModal(true)} disabled={!canAddMore} icon={Plus}>
Add Domain
</ActionButton>
}
>
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
<PageContainer>
{/* Summary Stats - Only reliable data */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="Total Domains" value={summary?.total_domains || 0} icon={Briefcase} />
<StatCard title="Expiring Soon" value={expiringSoonCount} icon={Calendar} />
<StatCard
title="Need Attention"
value={Object.values(healthReports).filter(r => r.status !== 'healthy').length}
icon={AlertTriangle}
/>
<StatCard title="Listed for Sale" value={summary?.sold_domains || 0} icon={Tag} />
</div>
{!canAddMore && (
<div className="flex items-center justify-between p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl">
<p className="text-sm text-amber-400">
You've reached your portfolio limit. Upgrade to add more.
</p>
<Link
href="/pricing"
className="text-sm font-medium text-amber-400 hover:text-amber-300 flex items-center gap-1"
>
Upgrade <ArrowUpRight className="w-3 h-3" />
</Link>
</div>
)}
{/* Portfolio Table */}
<PremiumTable
data={portfolio}
keyExtractor={(d) => d.id}
loading={loading}
emptyIcon={<Briefcase className="w-12 h-12 text-foreground-subtle" />}
emptyTitle="Your portfolio is empty"
emptyDescription="Add your first domain to start tracking investments"
columns={[
{
key: 'domain',
header: 'Domain',
render: (domain) => (
<div>
<span className="font-mono font-medium text-foreground">{domain.domain}</span>
{domain.registrar && (
<p className="text-xs text-foreground-muted flex items-center gap-1 mt-0.5">
<Building className="w-3 h-3" /> {domain.registrar}
</p>
)}
</div>
),
},
{
key: 'added',
header: 'Added',
hideOnMobile: true,
hideOnTablet: true,
render: (domain) => (
<span className="text-sm text-foreground-muted">
{domain.purchase_date
? new Date(domain.purchase_date).toLocaleDateString()
: new Date(domain.created_at).toLocaleDateString()
}
</span>
),
},
{
key: 'renewal',
header: 'Expires',
hideOnMobile: true,
render: (domain) => {
if (!domain.renewal_date) {
return <span className="text-foreground-subtle">—</span>
}
const days = Math.ceil((new Date(domain.renewal_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
const isExpiringSoon = days <= 30 && days > 0
const isExpired = days <= 0
return (
<div className="flex items-center gap-2">
<span className={clsx(
"text-sm",
isExpired && "text-red-400",
isExpiringSoon && "text-amber-400",
!isExpired && !isExpiringSoon && "text-foreground-muted"
)}>
{new Date(domain.renewal_date).toLocaleDateString()}
</span>
{isExpiringSoon && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-amber-400/10 text-amber-400 rounded">
{days}d
</span>
)}
{isExpired && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-red-400/10 text-red-400 rounded">
EXPIRED
</span>
)}
</div>
)
},
},
{
key: 'health',
header: 'Health',
hideOnMobile: true,
render: (domain) => {
const report = healthReports[domain.domain]
if (loadingHealth[domain.domain]) {
return <Loader2 className="w-4 h-4 text-foreground-muted animate-spin" />
}
if (report) {
const config = healthStatusConfig[report.status]
const Icon = config.icon
return (
<button
onClick={() => setSelectedHealthDomain(domain.domain)}
className={clsx(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-medium",
config.bgColor, config.color
)}
>
<Icon className="w-3 h-3" />
{config.label}
</button>
)
}
return (
<button
onClick={() => handleHealthCheck(domain.domain)}
className="text-xs text-foreground-muted hover:text-accent transition-colors flex items-center gap-1"
>
<Activity className="w-3.5 h-3.5" />
Check
</button>
)
},
},
{
key: 'actions',
header: '',
align: 'right',
render: (domain) => (
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation()
setOpenMenuId(openMenuId === domain.id ? null : domain.id)
}}
className="p-2 text-foreground-muted hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
>
<MoreVertical className="w-4 h-4" />
</button>
{openMenuId === domain.id && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40"
onClick={() => setOpenMenuId(null)}
/>
{/* Menu - opens downward */}
<div className="absolute right-0 top-full mt-1 z-50 w-48 py-1 bg-background-secondary border border-border/50 rounded-xl shadow-xl">
<button
onClick={() => { handleHealthCheck(domain.domain); setOpenMenuId(null) }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
>
<Shield className="w-4 h-4" />
Health Check
</button>
<button
onClick={() => { openEditModal(domain); setOpenMenuId(null) }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
>
<Edit2 className="w-4 h-4" />
Edit Details
</button>
<div className="my-1 border-t border-border/30" />
<Link
href={`/command/listings?domain=${encodeURIComponent(domain.domain)}`}
onClick={() => setOpenMenuId(null)}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-accent hover:bg-accent/5 transition-colors"
>
<Tag className="w-4 h-4" />
List for Sale
</Link>
<a
href={`https://${domain.domain}`}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpenMenuId(null)}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
>
<ExternalLink className="w-4 h-4" />
Visit Website
</a>
<div className="my-1 border-t border-border/30" />
<button
onClick={() => { openSellModal(domain); setOpenMenuId(null) }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
>
<DollarSign className="w-4 h-4" />
Record Sale
</button>
<button
onClick={() => { handleDelete(domain); setOpenMenuId(null) }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-400 hover:bg-red-400/5 transition-colors"
>
<Trash2 className="w-4 h-4" />
Remove
</button>
</div>
</>
)}
</div>
),
},
]}
/>
</PageContainer>
{/* Add Modal */}
{showAddModal && (
<Modal title="Add Domain to Portfolio" onClose={() => setShowAddModal(false)}>
<form onSubmit={handleAddDomain} className="space-y-4">
<div>
<label className="block text-sm text-foreground-muted mb-1.5">Domain *</label>
<input
type="text"
value={addForm.domain}
onChange={(e) => setAddForm({ ...addForm, domain: e.target.value })}
placeholder="example.com"
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
focus:outline-none focus:border-accent/50 transition-all"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-foreground-muted mb-1.5">Purchase Price</label>
<input
type="number"
value={addForm.purchase_price}
onChange={(e) => setAddForm({ ...addForm, purchase_price: e.target.value })}
placeholder="100"
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
focus:outline-none focus:border-accent/50"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1.5">Purchase Date</label>
<input
type="date"
value={addForm.purchase_date}
onChange={(e) => setAddForm({ ...addForm, purchase_date: e.target.value })}
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
focus:outline-none focus:border-accent/50"
/>
</div>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1.5">Registrar</label>
<input
type="text"
value={addForm.registrar}
onChange={(e) => setAddForm({ ...addForm, registrar: e.target.value })}
placeholder="Namecheap"
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
focus:outline-none focus:border-accent/50"
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={() => setShowAddModal(false)}
className="px-4 py-2.5 text-foreground-muted hover:text-foreground transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={addingDomain || !addForm.domain.trim()}
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-accent to-accent/80
text-background rounded-xl font-medium disabled:opacity-50 transition-all"
>
{addingDomain && <Loader2 className="w-4 h-4 animate-spin" />}
Add Domain
</button>
</div>
</form>
</Modal>
)}
{/* Edit Modal */}
{showEditModal && selectedDomain && (
<Modal title={`Edit ${selectedDomain.domain}`} onClose={() => setShowEditModal(false)}>
<form onSubmit={handleEditDomain} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-foreground-muted mb-1.5">Purchase Price</label>
<input
type="number"
value={editForm.purchase_price}
onChange={(e) => setEditForm({ ...editForm, purchase_price: e.target.value })}
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
focus:outline-none focus:border-accent/50"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1.5">Registrar</label>
<input
type="text"
value={editForm.registrar}
onChange={(e) => setEditForm({ ...editForm, registrar: e.target.value })}
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
focus:outline-none focus:border-accent/50"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="px-4 py-2.5 text-foreground-muted hover:text-foreground transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={savingEdit}
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-accent to-accent/80
text-background rounded-xl font-medium disabled:opacity-50 transition-all"
>
{savingEdit && <Loader2 className="w-4 h-4 animate-spin" />}
Save Changes
</button>
</div>
</form>
</Modal>
)}
{/* Record Sale Modal - for tracking completed sales */}
{showSellModal && selectedDomain && (
<Modal title={`Record Sale: ${selectedDomain.domain}`} onClose={() => setShowSellModal(false)}>
<form onSubmit={handleSellDomain} className="space-y-4">
<div className="p-3 bg-accent/10 border border-accent/20 rounded-lg text-sm text-foreground-muted">
<p>Record a completed sale to track your profit/loss. This will mark the domain as sold in your portfolio.</p>
<p className="mt-2 text-accent">Want to list it for sale instead? Use the <strong>"List"</strong> button.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-foreground-muted mb-1.5">Sale Price *</label>
<input
type="number"
value={sellForm.sale_price}
onChange={(e) => setSellForm({ ...sellForm, sale_price: e.target.value })}
placeholder="1000"
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
focus:outline-none focus:border-accent/50"
required
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1.5">Sale Date</label>
<input
type="date"
value={sellForm.sale_date}
onChange={(e) => setSellForm({ ...sellForm, sale_date: e.target.value })}
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
focus:outline-none focus:border-accent/50"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={() => setShowSellModal(false)}
className="px-4 py-2.5 text-foreground-muted hover:text-foreground transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={processingSale || !sellForm.sale_price}
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-accent to-accent/80
text-background rounded-xl font-medium disabled:opacity-50 transition-all"
>
{processingSale && <Loader2 className="w-4 h-4 animate-spin" />}
Mark as Sold
</button>
</div>
</form>
</Modal>
)}
{/* Valuation Modal */}
{showValuationModal && (
<Modal title="Domain Valuation" onClose={() => { setShowValuationModal(false); setValuation(null); }}>
{valuatingDomain ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-accent" />
</div>
) : valuation ? (
<div className="space-y-4">
<div className="text-center p-6 bg-accent/5 border border-accent/20 rounded-xl">
<p className="text-4xl font-semibold text-accent">${valuation.estimated_value.toLocaleString()}</p>
<p className="text-sm text-foreground-muted mt-1">Pounce Score Estimate</p>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between items-center p-3 bg-foreground/5 rounded-lg">
<span className="text-foreground-muted">Confidence Level</span>
<span className={clsx(
"px-2 py-0.5 rounded text-xs font-medium capitalize",
valuation.confidence === 'high' && "bg-accent/20 text-accent",
valuation.confidence === 'medium' && "bg-amber-400/20 text-amber-400",
valuation.confidence === 'low' && "bg-foreground/10 text-foreground-muted"
)}>
{valuation.confidence}
</span>
</div>
<div className="p-3 bg-foreground/5 rounded-lg">
<p className="text-foreground-muted mb-1">Valuation Formula</p>
<p className="text-foreground font-mono text-xs break-all">{valuation.valuation_formula}</p>
</div>
<div className="p-3 bg-amber-400/10 border border-amber-400/20 rounded-lg text-xs text-amber-400">
<p>This is an algorithmic estimate based on domain length, TLD, and market patterns. Actual market value may vary.</p>
</div>
</div>
</div>
) : null}
</Modal>
)}
{/* Health Report Modal */}
{selectedHealthDomain && healthReports[selectedHealthDomain] && (
<HealthReportModal
report={healthReports[selectedHealthDomain]}
onClose={() => setSelectedHealthDomain(null)}
/>
)}
</CommandCenterLayout>
)
}
// Health Report Modal Component
function HealthReportModal({ report, onClose }: { report: DomainHealthReport; onClose: () => void }) {
const config = healthStatusConfig[report.status]
const Icon = config.icon
return (
<div
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="w-full max-w-lg bg-background-secondary border border-border/50 rounded-2xl shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-border/50">
<div className="flex items-center gap-3">
<div className={clsx("p-2 rounded-lg", config.bgColor)}>
<Icon className={clsx("w-5 h-5", config.color)} />
</div>
<div>
<h3 className="font-mono font-semibold text-foreground">{report.domain}</h3>
<p className={clsx("text-xs font-medium", config.color)}>{config.label}</p>
</div>
</div>
<button onClick={onClose} className="p-1 text-foreground-muted hover:text-foreground transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Score */}
<div className="p-5 border-b border-border/30">
<div className="flex items-center justify-between">
<span className="text-sm text-foreground-muted">Health Score</span>
<div className="flex items-center gap-3">
<div className="w-32 h-2 bg-foreground/10 rounded-full overflow-hidden">
<div
className={clsx(
"h-full rounded-full transition-all",
report.score >= 70 ? "bg-accent" :
report.score >= 40 ? "bg-amber-400" : "bg-red-400"
)}
style={{ width: `${report.score}%` }}
/>
</div>
<span className={clsx(
"text-lg font-bold tabular-nums",
report.score >= 70 ? "text-accent" :
report.score >= 40 ? "text-amber-400" : "text-red-400"
)}>
{report.score}/100
</span>
</div>
</div>
</div>
{/* Check Results */}
<div className="p-5 space-y-4">
{/* DNS */}
{report.dns && (
<div className="p-4 bg-foreground/5 rounded-xl">
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
<span className={clsx(
"w-2 h-2 rounded-full",
report.dns.has_ns && report.dns.has_a ? "bg-accent" : "bg-red-400"
)} />
DNS Infrastructure
</h4>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="flex items-center gap-1.5">
<span className={report.dns.has_ns ? "text-accent" : "text-red-400"}>
{report.dns.has_ns ? '' : ''}
</span>
<span className="text-foreground-muted">Nameservers</span>
</div>
<div className="flex items-center gap-1.5">
<span className={report.dns.has_a ? "text-accent" : "text-red-400"}>
{report.dns.has_a ? '' : ''}
</span>
<span className="text-foreground-muted">A Record</span>
</div>
<div className="flex items-center gap-1.5">
<span className={report.dns.has_mx ? "text-accent" : "text-foreground-muted"}>
{report.dns.has_mx ? '' : ''}
</span>
<span className="text-foreground-muted">MX Record</span>
</div>
</div>
{report.dns.is_parked && (
<p className="mt-2 text-xs text-orange-400">⚠ Parked at {report.dns.parking_provider || 'unknown provider'}</p>
)}
</div>
)}
{/* HTTP */}
{report.http && (
<div className="p-4 bg-foreground/5 rounded-xl">
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
<span className={clsx(
"w-2 h-2 rounded-full",
report.http.is_reachable && report.http.status_code === 200 ? "bg-accent" :
report.http.is_reachable ? "bg-amber-400" : "bg-red-400"
)} />
Website Status
</h4>
<div className="flex items-center gap-4 text-xs">
<span className={clsx(
report.http.is_reachable ? "text-accent" : "text-red-400"
)}>
{report.http.is_reachable ? 'Reachable' : 'Unreachable'}
</span>
{report.http.status_code && (
<span className="text-foreground-muted">
HTTP {report.http.status_code}
</span>
)}
</div>
{report.http.is_parked && (
<p className="mt-2 text-xs text-orange-400">⚠ Parking page detected</p>
)}
</div>
)}
{/* SSL */}
{report.ssl && (
<div className="p-4 bg-foreground/5 rounded-xl">
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
<span className={clsx(
"w-2 h-2 rounded-full",
report.ssl.has_certificate && report.ssl.is_valid ? "bg-accent" :
report.ssl.has_certificate ? "bg-amber-400" : "bg-foreground-muted"
)} />
SSL Certificate
</h4>
<div className="text-xs">
{report.ssl.has_certificate ? (
<div className="space-y-1">
<p className={report.ssl.is_valid ? "text-accent" : "text-red-400"}>
{report.ssl.is_valid ? ' Valid certificate' : ' Certificate invalid/expired'}
</p>
{report.ssl.days_until_expiry !== undefined && (
<p className={clsx(
report.ssl.days_until_expiry > 30 ? "text-foreground-muted" :
report.ssl.days_until_expiry > 7 ? "text-amber-400" : "text-red-400"
)}>
Expires in {report.ssl.days_until_expiry} days
</p>
)}
</div>
) : (
<p className="text-foreground-muted">No SSL certificate</p>
)}
</div>
</div>
)}
{/* Signals & Recommendations */}
{((report.signals?.length || 0) > 0 || (report.recommendations?.length || 0) > 0) && (
<div className="space-y-3">
{(report.signals?.length || 0) > 0 && (
<div>
<h4 className="text-xs font-medium text-foreground-muted uppercase tracking-wider mb-2">Signals</h4>
<ul className="space-y-1">
{report.signals?.map((signal, i) => (
<li key={i} className="text-xs text-foreground flex items-start gap-2">
<span className="text-accent mt-0.5"></span>
{signal}
</li>
))}
</ul>
</div>
)}
{(report.recommendations?.length || 0) > 0 && (
<div>
<h4 className="text-xs font-medium text-foreground-muted uppercase tracking-wider mb-2">Recommendations</h4>
<ul className="space-y-1">
{report.recommendations?.map((rec, i) => (
<li key={i} className="text-xs text-foreground flex items-start gap-2">
<span className="text-amber-400 mt-0.5"></span>
{rec}
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 bg-foreground/5 border-t border-border/30">
<p className="text-xs text-foreground-subtle text-center">
Checked at {new Date(report.checked_at).toLocaleString()}
</p>
</div>
</div>
</div>
)
}
// Modal Component
function Modal({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {
return (
<div
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="w-full max-w-md bg-background-secondary border border-border/50 rounded-2xl shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-5 border-b border-border/50">
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
<button onClick={onClose} className="p-1 text-foreground-muted hover:text-foreground transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-5">
{children}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,722 @@
'use client'
import { useEffect, useState, useMemo, useRef } from 'react'
import { useParams } from 'next/navigation'
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
import { PageContainer, StatCard } from '@/components/PremiumTable'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import {
ArrowLeft,
TrendingUp,
TrendingDown,
Minus,
Calendar,
Globe,
Building,
ExternalLink,
Search,
ChevronRight,
Check,
X,
RefreshCw,
AlertTriangle,
DollarSign,
BarChart3,
Shield,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
interface TldDetails {
tld: string
type: string
description: string
registry: string
introduced: number
trend: string
trend_reason: string
pricing: {
avg: number
min: number
max: number
}
registrars: Array<{
name: string
registration_price: number
renewal_price: number
transfer_price: number
}>
cheapest_registrar: string
// New fields from table
min_renewal_price: number
price_change_1y: number
price_change_3y: number
risk_level: 'low' | 'medium' | 'high'
risk_reason: string
}
interface TldHistory {
tld: string
current_price: number
price_change_7d: number
price_change_30d: number
price_change_90d: number
trend: string
trend_reason: string
history: Array<{
date: string
price: number
}>
}
interface DomainCheckResult {
domain: string
is_available: boolean
status: string
registrar?: string | null
creation_date?: string | null
expiration_date?: string | null
}
// Registrar URLs
const REGISTRAR_URLS: Record<string, string> = {
'Namecheap': 'https://www.namecheap.com/domains/registration/results/?domain=',
'Porkbun': 'https://porkbun.com/checkout/search?q=',
'Cloudflare': 'https://www.cloudflare.com/products/registrar/',
'Google Domains': 'https://domains.google.com/registrar/search?searchTerm=',
'GoDaddy': 'https://www.godaddy.com/domainsearch/find?domainToCheck=',
'porkbun': 'https://porkbun.com/checkout/search?q=',
'Dynadot': 'https://www.dynadot.com/domain/search?domain=',
}
type ChartPeriod = '1M' | '3M' | '1Y' | 'ALL'
// Premium Chart Component with real data
function PriceChart({
data,
chartStats,
}: {
data: Array<{ date: string; price: number }>
chartStats: { high: number; low: number; avg: number }
}) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
if (data.length === 0) {
return (
<div className="h-48 flex items-center justify-center text-foreground-muted">
No price history available
</div>
)
}
const minPrice = Math.min(...data.map(d => d.price))
const maxPrice = Math.max(...data.map(d => d.price))
const priceRange = maxPrice - minPrice || 1
const points = data.map((d, i) => ({
x: (i / (data.length - 1)) * 100,
y: 100 - ((d.price - minPrice) / priceRange) * 80 - 10,
...d,
}))
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
const areaPath = linePath + ` L${points[points.length - 1].x},100 L${points[0].x},100 Z`
const isRising = data[data.length - 1].price > data[0].price
const strokeColor = isRising ? '#f97316' : '#00d4aa'
return (
<div
ref={containerRef}
className="relative h-48"
onMouseLeave={() => setHoveredIndex(null)}
>
<svg
className="w-full h-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
onMouseMove={(e) => {
if (!containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
const x = ((e.clientX - rect.left) / rect.width) * 100
const idx = Math.round((x / 100) * (points.length - 1))
setHoveredIndex(Math.max(0, Math.min(idx, points.length - 1)))
}}
>
<defs>
<linearGradient id="chartGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.02" />
</linearGradient>
</defs>
<path d={areaPath} fill="url(#chartGradient)" />
<path d={linePath} fill="none" stroke={strokeColor} strokeWidth="2" />
{hoveredIndex !== null && points[hoveredIndex] && (
<circle
cx={points[hoveredIndex].x}
cy={points[hoveredIndex].y}
r="4"
fill={strokeColor}
stroke="#0a0a0a"
strokeWidth="2"
/>
)}
</svg>
{/* Tooltip */}
{hoveredIndex !== null && points[hoveredIndex] && (
<div
className="absolute -top-2 transform -translate-x-1/2 bg-background border border-border rounded-lg px-3 py-2 shadow-lg z-10 pointer-events-none"
style={{ left: `${points[hoveredIndex].x}%` }}
>
<p className="text-sm font-medium text-foreground">${points[hoveredIndex].price.toFixed(2)}</p>
<p className="text-xs text-foreground-muted">{new Date(points[hoveredIndex].date).toLocaleDateString()}</p>
</div>
)}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 flex flex-col justify-between text-xs text-foreground-subtle -ml-12 w-10 text-right">
<span>${maxPrice.toFixed(2)}</span>
<span>${((maxPrice + minPrice) / 2).toFixed(2)}</span>
<span>${minPrice.toFixed(2)}</span>
</div>
</div>
)
}
export default function CommandTldDetailPage() {
const params = useParams()
const { fetchSubscription } = useStore()
const tld = params.tld as string
const [details, setDetails] = useState<TldDetails | null>(null)
const [history, setHistory] = useState<TldHistory | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [chartPeriod, setChartPeriod] = useState<ChartPeriod>('1Y')
const [domainSearch, setDomainSearch] = useState('')
const [checkingDomain, setCheckingDomain] = useState(false)
const [domainResult, setDomainResult] = useState<DomainCheckResult | null>(null)
useEffect(() => {
fetchSubscription()
if (tld) {
loadData()
}
}, [tld, fetchSubscription])
const loadData = async () => {
try {
const [historyData, compareData, overviewData] = await Promise.all([
api.getTldHistory(tld, 365),
api.getTldCompare(tld),
api.getTldOverview(1, 0, 'popularity', tld), // Get the specific TLD data
])
if (historyData && compareData) {
const sortedRegistrars = [...(compareData.registrars || [])].sort((a, b) =>
a.registration_price - b.registration_price
)
// Get additional data from overview API (1y, 3y change, risk)
const tldFromOverview = overviewData?.tlds?.[0]
setDetails({
tld: compareData.tld || tld,
type: compareData.type || 'generic',
description: compareData.description || `Domain extension .${tld}`,
registry: compareData.registry || 'Various',
introduced: compareData.introduced || 0,
trend: historyData.trend || 'stable',
trend_reason: historyData.trend_reason || 'Price tracking available',
pricing: {
avg: compareData.price_range?.avg || historyData.current_price || 0,
min: compareData.price_range?.min || historyData.current_price || 0,
max: compareData.price_range?.max || historyData.current_price || 0,
},
registrars: sortedRegistrars,
cheapest_registrar: compareData.cheapest_registrar || sortedRegistrars[0]?.name || 'N/A',
// New fields from overview
min_renewal_price: tldFromOverview?.min_renewal_price || sortedRegistrars[0]?.renewal_price || 0,
price_change_1y: tldFromOverview?.price_change_1y || 0,
price_change_3y: tldFromOverview?.price_change_3y || 0,
risk_level: tldFromOverview?.risk_level || 'low',
risk_reason: tldFromOverview?.risk_reason || 'Stable',
})
setHistory(historyData)
} else {
setError('Failed to load TLD data')
}
} catch (err) {
console.error('Error loading TLD data:', err)
setError('Failed to load TLD data')
} finally {
setLoading(false)
}
}
const filteredHistory = useMemo(() => {
if (!history?.history) return []
const now = new Date()
let cutoffDays = 365
switch (chartPeriod) {
case '1M': cutoffDays = 30; break
case '3M': cutoffDays = 90; break
case '1Y': cutoffDays = 365; break
case 'ALL': cutoffDays = 9999; break
}
const cutoff = new Date(now.getTime() - cutoffDays * 24 * 60 * 60 * 1000)
return history.history.filter(h => new Date(h.date) >= cutoff)
}, [history, chartPeriod])
const chartStats = useMemo(() => {
if (filteredHistory.length === 0) return { high: 0, low: 0, avg: 0 }
const prices = filteredHistory.map(h => h.price)
return {
high: Math.max(...prices),
low: Math.min(...prices),
avg: prices.reduce((a, b) => a + b, 0) / prices.length,
}
}, [filteredHistory])
const handleDomainCheck = async () => {
if (!domainSearch.trim()) return
setCheckingDomain(true)
setDomainResult(null)
try {
const domain = domainSearch.includes('.') ? domainSearch : `${domainSearch}.${tld}`
const result = await api.checkDomain(domain, false)
setDomainResult({
domain,
is_available: result.is_available,
status: result.status,
registrar: result.registrar,
creation_date: result.creation_date,
expiration_date: result.expiration_date,
})
} catch (err) {
console.error('Domain check failed:', err)
} finally {
setCheckingDomain(false)
}
}
const getRegistrarUrl = (registrarName: string, domain?: string) => {
const baseUrl = REGISTRAR_URLS[registrarName]
if (!baseUrl) return '#'
if (domain) return `${baseUrl}${domain}`
return baseUrl
}
// Calculate renewal trap info
const getRenewalInfo = () => {
if (!details?.registrars?.length) return null
const cheapest = details.registrars[0]
const ratio = cheapest.renewal_price / cheapest.registration_price
return {
registration: cheapest.registration_price,
renewal: cheapest.renewal_price,
ratio,
isTrap: ratio > 2,
}
}
const renewalInfo = getRenewalInfo()
// Risk badge component
const getRiskBadge = () => {
if (!details) return null
const level = details.risk_level
const reason = details.risk_reason
return (
<span className={clsx(
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium",
level === 'high' && "bg-red-500/10 text-red-400",
level === 'medium' && "bg-amber-500/10 text-amber-400",
level === 'low' && "bg-accent/10 text-accent"
)}>
<span className={clsx(
"w-2.5 h-2.5 rounded-full",
level === 'high' && "bg-red-400",
level === 'medium' && "bg-amber-400",
level === 'low' && "bg-accent"
)} />
{reason}
</span>
)
}
if (loading) {
return (
<CommandCenterLayout title={`.${tld}`} subtitle="Loading...">
<PageContainer>
<div className="flex items-center justify-center py-20">
<RefreshCw className="w-6 h-6 text-accent animate-spin" />
</div>
</PageContainer>
</CommandCenterLayout>
)
}
if (error || !details) {
return (
<CommandCenterLayout title="TLD Not Found" subtitle="Error loading data">
<PageContainer>
<div className="text-center py-20">
<div className="w-16 h-16 bg-background-secondary rounded-full flex items-center justify-center mx-auto mb-6">
<X className="w-8 h-8 text-foreground-subtle" />
</div>
<h1 className="text-xl font-medium text-foreground mb-2">TLD Not Found</h1>
<p className="text-foreground-muted mb-8">{error || `The TLD .${tld} could not be found.`}</p>
<Link
href="/command/pricing"
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background rounded-xl font-medium hover:bg-accent-hover transition-all"
>
<ArrowLeft className="w-4 h-4" />
Back to TLD Pricing
</Link>
</div>
</PageContainer>
</CommandCenterLayout>
)
}
return (
<CommandCenterLayout
title={`.${details.tld}`}
subtitle={details.description}
>
<PageContainer>
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm mb-6">
<Link href="/command/pricing" className="text-foreground-subtle hover:text-foreground transition-colors">
TLD Pricing
</Link>
<ChevronRight className="w-3.5 h-3.5 text-foreground-subtle" />
<span className="text-foreground font-medium">.{details.tld}</span>
</nav>
{/* Stats Grid - All info from table */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div title="Lowest first-year registration price across all tracked registrars">
<StatCard
title="Buy Price (1y)"
value={`$${details.pricing.min.toFixed(2)}`}
subtitle={`at ${details.cheapest_registrar}`}
icon={DollarSign}
/>
</div>
<div title={renewalInfo?.isTrap
? `Warning: Renewal is ${renewalInfo.ratio.toFixed(1)}x the registration price`
: 'Annual renewal price after first year'}>
<StatCard
title="Renewal (1y)"
value={details.min_renewal_price ? `$${details.min_renewal_price.toFixed(2)}` : '—'}
subtitle={renewalInfo?.isTrap ? `${renewalInfo.ratio.toFixed(1)}x registration` : 'per year'}
icon={RefreshCw}
/>
</div>
<div title="Price change over the last 12 months">
<StatCard
title="1y Change"
value={`${details.price_change_1y > 0 ? '+' : ''}${details.price_change_1y.toFixed(0)}%`}
icon={details.price_change_1y > 0 ? TrendingUp : details.price_change_1y < 0 ? TrendingDown : Minus}
/>
</div>
<div title="Price change over the last 3 years">
<StatCard
title="3y Change"
value={`${details.price_change_3y > 0 ? '+' : ''}${details.price_change_3y.toFixed(0)}%`}
icon={BarChart3}
/>
</div>
</div>
{/* Risk Level */}
<div className="flex items-center gap-4 p-4 bg-background-secondary/30 border border-border/50 rounded-xl">
<Shield className="w-5 h-5 text-foreground-muted" />
<div className="flex-1">
<p className="text-sm font-medium text-foreground">Risk Assessment</p>
<p className="text-xs text-foreground-muted">Based on renewal ratio, price volatility, and market trends</p>
</div>
{getRiskBadge()}
</div>
{/* Renewal Trap Warning */}
{renewalInfo?.isTrap && (
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-400">Renewal Trap Detected</p>
<p className="text-sm text-foreground-muted mt-1">
The renewal price (${renewalInfo.renewal.toFixed(2)}) is {renewalInfo.ratio.toFixed(1)}x higher than the registration price (${renewalInfo.registration.toFixed(2)}).
Consider the total cost of ownership before registering.
</p>
</div>
</div>
)}
{/* Price Chart */}
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-medium text-foreground">Price History</h2>
<div className="flex items-center gap-1 bg-foreground/5 rounded-lg p-1">
{(['1M', '3M', '1Y', 'ALL'] as ChartPeriod[]).map((period) => (
<button
key={period}
onClick={() => setChartPeriod(period)}
className={clsx(
"px-3 py-1.5 text-xs font-medium rounded-md transition-all",
chartPeriod === period
? "bg-accent text-background"
: "text-foreground-muted hover:text-foreground"
)}
>
{period}
</button>
))}
</div>
</div>
<div className="pl-14">
<PriceChart data={filteredHistory} chartStats={chartStats} />
</div>
{/* Chart Stats */}
<div className="grid grid-cols-3 gap-4 mt-6 pt-6 border-t border-border/30">
<div className="text-center">
<p className="text-xs text-foreground-subtle uppercase mb-1">Period High</p>
<p className="text-lg font-medium text-foreground tabular-nums">${chartStats.high.toFixed(2)}</p>
</div>
<div className="text-center">
<p className="text-xs text-foreground-subtle uppercase mb-1">Average</p>
<p className="text-lg font-medium text-foreground tabular-nums">${chartStats.avg.toFixed(2)}</p>
</div>
<div className="text-center">
<p className="text-xs text-foreground-subtle uppercase mb-1">Period Low</p>
<p className="text-lg font-medium text-foreground tabular-nums">${chartStats.low.toFixed(2)}</p>
</div>
</div>
</div>
{/* Registrar Comparison */}
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
<h2 className="text-lg font-medium text-foreground mb-6">Registrar Comparison</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/30">
<th className="text-left pb-3 text-sm font-medium text-foreground-muted">Registrar</th>
<th className="text-right pb-3 text-sm font-medium text-foreground-muted" title="First year registration price">Register</th>
<th className="text-right pb-3 text-sm font-medium text-foreground-muted" title="Annual renewal price">Renew</th>
<th className="text-right pb-3 text-sm font-medium text-foreground-muted" title="Transfer from another registrar">Transfer</th>
<th className="text-right pb-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-border/20">
{details.registrars.map((registrar, idx) => {
const hasRenewalTrap = registrar.renewal_price / registrar.registration_price > 1.5
const isBestValue = idx === 0 && !hasRenewalTrap
return (
<tr key={registrar.name} className={clsx(isBestValue && "bg-accent/5")}>
<td className="py-4">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{registrar.name}</span>
{isBestValue && (
<span
className="px-2 py-0.5 text-xs bg-accent/10 text-accent rounded-full cursor-help"
title="Best overall value: lowest registration price without renewal trap"
>
Best
</span>
)}
{idx === 0 && hasRenewalTrap && (
<span
className="px-2 py-0.5 text-xs bg-amber-500/10 text-amber-400 rounded-full cursor-help"
title="Cheapest registration but high renewal costs"
>
Cheap Start
</span>
)}
</div>
</td>
<td className="py-4 text-right">
<span
className={clsx(
"font-medium tabular-nums cursor-help",
isBestValue ? "text-accent" : "text-foreground"
)}
title={`First year: $${registrar.registration_price.toFixed(2)}`}
>
${registrar.registration_price.toFixed(2)}
</span>
</td>
<td className="py-4 text-right">
<div className="flex items-center gap-1 justify-end">
<span
className={clsx(
"tabular-nums cursor-help",
hasRenewalTrap ? "text-amber-400" : "text-foreground-muted"
)}
title={hasRenewalTrap
? `Renewal is ${(registrar.renewal_price / registrar.registration_price).toFixed(1)}x the registration price`
: `Annual renewal: $${registrar.renewal_price.toFixed(2)}`}
>
${registrar.renewal_price.toFixed(2)}
</span>
{hasRenewalTrap && (
<AlertTriangle
className="w-3.5 h-3.5 text-amber-400 cursor-help"
title={`Renewal trap: ${(registrar.renewal_price / registrar.registration_price).toFixed(1)}x registration price`}
/>
)}
</div>
</td>
<td className="py-4 text-right">
<span
className="text-foreground-muted tabular-nums cursor-help"
title={`Transfer from another registrar: $${registrar.transfer_price.toFixed(2)}`}
>
${registrar.transfer_price.toFixed(2)}
</span>
</td>
<td className="py-4 text-right">
<a
href={getRegistrarUrl(registrar.name)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-accent hover:text-accent/80 transition-colors"
title={`Register at ${registrar.name}`}
>
Visit
<ExternalLink className="w-3.5 h-3.5" />
</a>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
{/* Quick Domain Check */}
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
<h2 className="text-lg font-medium text-foreground mb-4">Quick Domain Check</h2>
<p className="text-sm text-foreground-muted mb-4">
Check if a domain is available with .{tld}
</p>
<div className="flex gap-3">
<div className="flex-1 relative">
<input
type="text"
value={domainSearch}
onChange={(e) => setDomainSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleDomainCheck()}
placeholder={`example or example.${tld}`}
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl
text-sm text-foreground placeholder:text-foreground-subtle
focus:outline-none focus:border-accent/50 transition-all"
/>
</div>
<button
onClick={handleDomainCheck}
disabled={checkingDomain || !domainSearch.trim()}
className="h-11 px-6 bg-accent text-background font-medium rounded-xl
hover:bg-accent-hover transition-all disabled:opacity-50"
>
{checkingDomain ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
'Check'
)}
</button>
</div>
{/* Result */}
{domainResult && (
<div className={clsx(
"mt-4 p-4 rounded-xl border",
domainResult.is_available
? "bg-accent/10 border-accent/30"
: "bg-foreground/5 border-border/50"
)}>
<div className="flex items-center gap-3">
{domainResult.is_available ? (
<Check className="w-5 h-5 text-accent" />
) : (
<X className="w-5 h-5 text-foreground-subtle" />
)}
<div>
<p className="font-medium text-foreground">{domainResult.domain}</p>
<p className="text-sm text-foreground-muted">
{domainResult.is_available ? 'Available for registration!' : 'Already registered'}
</p>
</div>
</div>
{domainResult.is_available && (
<a
href={getRegistrarUrl(details.cheapest_registrar, domainResult.domain)}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-accent text-background text-sm font-medium rounded-lg hover:bg-accent-hover transition-all"
>
Register at {details.cheapest_registrar}
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
)}
</div>
{/* TLD Info */}
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
<h2 className="text-lg font-medium text-foreground mb-4">TLD Information</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-4 bg-background/50 rounded-xl">
<div className="flex items-center gap-2 text-foreground-muted mb-2">
<Globe className="w-4 h-4" />
<span className="text-xs uppercase">Type</span>
</div>
<p className="font-medium text-foreground capitalize">{details.type}</p>
</div>
<div className="p-4 bg-background/50 rounded-xl">
<div className="flex items-center gap-2 text-foreground-muted mb-2">
<Building className="w-4 h-4" />
<span className="text-xs uppercase">Registry</span>
</div>
<p className="font-medium text-foreground">{details.registry}</p>
</div>
<div className="p-4 bg-background/50 rounded-xl">
<div className="flex items-center gap-2 text-foreground-muted mb-2">
<Calendar className="w-4 h-4" />
<span className="text-xs uppercase">Introduced</span>
</div>
<p className="font-medium text-foreground">{details.introduced || 'Unknown'}</p>
</div>
<div className="p-4 bg-background/50 rounded-xl">
<div className="flex items-center gap-2 text-foreground-muted mb-2">
<BarChart3 className="w-4 h-4" />
<span className="text-xs uppercase">Registrars</span>
</div>
<p className="font-medium text-foreground">{details.registrars.length} tracked</p>
</div>
</div>
</div>
</PageContainer>
</CommandCenterLayout>
)
}

View File

@ -0,0 +1,387 @@
'use client'
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
import {
PremiumTable,
StatCard,
PageContainer,
SearchInput,
TabBar,
FilterBar,
SelectDropdown,
ActionButton,
} from '@/components/PremiumTable'
import {
TrendingUp,
ChevronRight,
Globe,
DollarSign,
RefreshCw,
AlertTriangle,
Cpu,
MapPin,
Coins,
Crown,
Info,
Loader2,
} from 'lucide-react'
import clsx from 'clsx'
interface TLDData {
tld: string
min_price: number
avg_price: number
max_price: number
min_renewal_price: number
avg_renewal_price: number
cheapest_registrar?: string
cheapest_registrar_url?: string
price_change_7d: number
price_change_1y: number
price_change_3y: number
risk_level: 'low' | 'medium' | 'high'
risk_reason: string
popularity_rank?: number
type?: string
}
// Category definitions
const CATEGORIES = [
{ id: 'all', label: 'All', icon: Globe },
{ id: 'tech', label: 'Tech', icon: Cpu },
{ id: 'geo', label: 'Geo', icon: MapPin },
{ id: 'budget', label: 'Budget', icon: Coins },
{ id: 'premium', label: 'Premium', icon: Crown },
]
const CATEGORY_FILTERS: Record<string, (tld: TLDData) => boolean> = {
all: () => true,
tech: (tld) => ['ai', 'io', 'app', 'dev', 'tech', 'code', 'cloud', 'data', 'api', 'software'].includes(tld.tld),
geo: (tld) => ['ch', 'de', 'uk', 'us', 'fr', 'it', 'es', 'nl', 'at', 'eu', 'co', 'ca', 'au', 'nz', 'jp', 'cn', 'in', 'br', 'mx', 'nyc', 'london', 'paris', 'berlin', 'tokyo', 'swiss'].includes(tld.tld),
budget: (tld) => tld.min_price < 5,
premium: (tld) => tld.min_price >= 50,
}
const SORT_OPTIONS = [
{ value: 'popularity', label: 'By Popularity' },
{ value: 'price_asc', label: 'Price: Low → High' },
{ value: 'price_desc', label: 'Price: High → Low' },
{ value: 'change', label: 'By Price Change' },
{ value: 'risk', label: 'By Risk Level' },
]
// Memoized Sparkline
const Sparkline = memo(function Sparkline({ trend }: { trend: number }) {
const isPositive = trend > 0
const isNeutral = trend === 0
return (
<svg width="40" height="16" viewBox="0 0 40 16" className="overflow-visible">
{isNeutral ? (
<line x1="0" y1="8" x2="40" y2="8" stroke="currentColor" className="text-foreground-muted" strokeWidth="1.5" />
) : isPositive ? (
<polyline
points="0,14 10,12 20,10 30,6 40,2"
fill="none"
stroke="currentColor"
className="text-orange-400"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
) : (
<polyline
points="0,2 10,4 20,8 30,12 40,14"
fill="none"
stroke="currentColor"
className="text-accent"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</svg>
)
})
export default function TLDPricingPage() {
const { subscription } = useStore()
const [tldData, setTldData] = useState<TLDData[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState('popularity')
const [category, setCategory] = useState('all')
const [page, setPage] = useState(0)
const [total, setTotal] = useState(0)
const loadTLDData = useCallback(async () => {
setLoading(true)
try {
const response = await api.getTldOverview(
50,
page * 50,
sortBy === 'risk' || sortBy === 'change' ? 'popularity' : sortBy as any,
)
const mapped: TLDData[] = (response.tlds || []).map((tld) => ({
tld: tld.tld,
min_price: tld.min_registration_price,
avg_price: tld.avg_registration_price,
max_price: tld.max_registration_price,
min_renewal_price: tld.min_renewal_price,
avg_renewal_price: tld.avg_renewal_price,
price_change_7d: tld.price_change_7d,
price_change_1y: tld.price_change_1y,
price_change_3y: tld.price_change_3y,
risk_level: tld.risk_level,
risk_reason: tld.risk_reason,
popularity_rank: tld.popularity_rank,
type: tld.type,
}))
setTldData(mapped)
setTotal(response.total || 0)
} catch (error) {
console.error('Failed to load TLD data:', error)
} finally {
setLoading(false)
}
}, [page, sortBy])
useEffect(() => {
loadTLDData()
}, [loadTLDData])
const handleRefresh = useCallback(async () => {
setRefreshing(true)
await loadTLDData()
setRefreshing(false)
}, [loadTLDData])
// Memoized filtered and sorted data
const sortedData = useMemo(() => {
let data = tldData.filter(CATEGORY_FILTERS[category] || (() => true))
if (searchQuery) {
const q = searchQuery.toLowerCase()
data = data.filter(tld => tld.tld.toLowerCase().includes(q))
}
if (sortBy === 'risk') {
const riskOrder = { high: 0, medium: 1, low: 2 }
data = [...data].sort((a, b) => riskOrder[a.risk_level] - riskOrder[b.risk_level])
}
return data
}, [tldData, category, searchQuery, sortBy])
// Memoized stats
const stats = useMemo(() => {
const lowestPrice = tldData.length > 0
? tldData.reduce((min, tld) => Math.min(min, tld.min_price), Infinity)
: 0.99
const hottestTld = tldData.find(tld => (tld.price_change_7d || 0) > 5)?.tld || 'ai'
const trapCount = tldData.filter(tld => tld.risk_level === 'high' || tld.risk_level === 'medium').length
return { lowestPrice, hottestTld, trapCount }
}, [tldData])
const subtitle = useMemo(() => {
if (loading && total === 0) return 'Loading TLD pricing data...'
if (total === 0) return 'No TLD data available'
return `Tracking ${total.toLocaleString()} TLDs • Updated daily`
}, [loading, total])
// Memoized columns
const columns = useMemo(() => [
{
key: 'tld',
header: 'TLD',
width: '100px',
render: (tld: TLDData) => (
<span className="font-mono text-lg font-semibold text-foreground group-hover:text-accent transition-colors">
.{tld.tld}
</span>
),
},
{
key: 'trend',
header: 'Trend',
width: '80px',
hideOnMobile: true,
render: (tld: TLDData) => <Sparkline trend={tld.price_change_1y || 0} />,
},
{
key: 'buy_price',
header: 'Buy (1y)',
align: 'right' as const,
width: '100px',
render: (tld: TLDData) => (
<span className="font-semibold text-foreground tabular-nums">${tld.min_price.toFixed(2)}</span>
),
},
{
key: 'renew_price',
header: 'Renew (1y)',
align: 'right' as const,
width: '120px',
render: (tld: TLDData) => {
const ratio = tld.min_renewal_price / tld.min_price
return (
<div className="flex items-center gap-1 justify-end">
<span className="text-foreground-muted tabular-nums">${tld.min_renewal_price.toFixed(2)}</span>
{ratio > 2 && (
<span className="text-amber-400" title={`Renewal is ${ratio.toFixed(1)}x registration`}>
<AlertTriangle className="w-3.5 h-3.5" />
</span>
)}
</div>
)
},
},
{
key: 'change_1y',
header: '1y',
align: 'right' as const,
width: '80px',
hideOnMobile: true,
render: (tld: TLDData) => {
const change = tld.price_change_1y || 0
return (
<span className={clsx("font-medium tabular-nums", change > 0 ? "text-orange-400" : change < 0 ? "text-accent" : "text-foreground-muted")}>
{change > 0 ? '+' : ''}{change.toFixed(0)}%
</span>
)
},
},
{
key: 'change_3y',
header: '3y',
align: 'right' as const,
width: '80px',
hideOnMobile: true,
render: (tld: TLDData) => {
const change = tld.price_change_3y || 0
return (
<span className={clsx("font-medium tabular-nums", change > 0 ? "text-orange-400" : change < 0 ? "text-accent" : "text-foreground-muted")}>
{change > 0 ? '+' : ''}{change.toFixed(0)}%
</span>
)
},
},
{
key: 'risk',
header: 'Risk',
align: 'center' as const,
width: '120px',
render: (tld: TLDData) => (
<span className={clsx(
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium",
tld.risk_level === 'high' && "bg-red-500/10 text-red-400",
tld.risk_level === 'medium' && "bg-amber-500/10 text-amber-400",
tld.risk_level === 'low' && "bg-accent/10 text-accent"
)}>
<span className={clsx(
"w-2 h-2 rounded-full",
tld.risk_level === 'high' && "bg-red-400",
tld.risk_level === 'medium' && "bg-amber-400",
tld.risk_level === 'low' && "bg-accent"
)} />
<span className="hidden sm:inline">{tld.risk_reason}</span>
</span>
),
},
{
key: 'actions',
header: '',
align: 'right' as const,
width: '50px',
render: () => <ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />,
},
], [])
return (
<CommandCenterLayout
title="TLD Pricing"
subtitle={subtitle}
actions={
<ActionButton onClick={handleRefresh} disabled={refreshing} variant="ghost" icon={refreshing ? Loader2 : RefreshCw}>
{refreshing ? '' : 'Refresh'}
</ActionButton>
}
>
<PageContainer>
{/* Stats Overview */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="TLDs Tracked" value={total > 0 ? total.toLocaleString() : '—'} subtitle="updated daily" icon={Globe} />
<StatCard title="Lowest Price" value={total > 0 ? `$${stats.lowestPrice.toFixed(2)}` : '—'} icon={DollarSign} />
<StatCard title="Hottest TLD" value={total > 0 ? `.${stats.hottestTld}` : '—'} subtitle="rising prices" icon={TrendingUp} />
<StatCard title="Renewal Traps" value={stats.trapCount.toString()} subtitle="high renewal ratio" icon={AlertTriangle} />
</div>
{/* Category Tabs */}
<TabBar
tabs={CATEGORIES.map(c => ({ id: c.id, label: c.label, icon: c.icon }))}
activeTab={category}
onChange={setCategory}
/>
{/* Filters */}
<FilterBar>
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search TLDs (e.g. com, io, dev)..."
className="flex-1 max-w-md"
/>
<SelectDropdown value={sortBy} onChange={setSortBy} options={SORT_OPTIONS} />
</FilterBar>
{/* Legend */}
<div className="flex flex-wrap items-center gap-4 text-xs text-foreground-muted">
<div className="flex items-center gap-2">
<Info className="w-3.5 h-3.5" />
<span>Tip: Renewal traps show when renewal price is &gt;2x registration</span>
</div>
</div>
{/* TLD Table */}
<PremiumTable
data={sortedData}
keyExtractor={(tld) => tld.tld}
loading={loading}
onRowClick={(tld) => window.location.href = `/command/pricing/${tld.tld}`}
emptyIcon={<Globe className="w-12 h-12 text-foreground-subtle" />}
emptyTitle="No TLDs found"
emptyDescription={searchQuery ? `No TLDs matching "${searchQuery}"` : "Check back later for TLD data"}
columns={columns}
/>
{/* Pagination */}
{total > 50 && (
<div className="flex items-center justify-center gap-4 pt-2">
<button
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground bg-foreground/5 hover:bg-foreground/10 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<span className="text-sm text-foreground-muted tabular-nums">
Page {page + 1} of {Math.ceil(total / 50)}
</span>
<button
onClick={() => setPage(page + 1)}
disabled={(page + 1) * 50 >= total}
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground bg-foreground/5 hover:bg-foreground/10 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
)}
</PageContainer>
</CommandCenterLayout>
)
}

View File

@ -0,0 +1,508 @@
'use client'
import { useEffect, useState } from 'react'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
import { PageContainer, StatCard, Badge } from '@/components/PremiumTable'
import {
Search,
Link2,
Globe,
Shield,
TrendingUp,
Loader2,
AlertCircle,
X,
ExternalLink,
Crown,
CheckCircle,
Sparkles,
BookOpen,
Building,
GraduationCap,
Newspaper,
Lock,
Star,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
interface SEOData {
domain: string
seo_score: number
value_category: string
metrics: {
domain_authority: number | null
page_authority: number | null
spam_score: number | null
total_backlinks: number | null
referring_domains: number | null
}
notable_links: {
has_wikipedia: boolean
has_gov: boolean
has_edu: boolean
has_news: boolean
notable_domains: string[]
}
top_backlinks: Array<{
domain: string
authority: number
page: string
}>
estimated_value: number | null
data_source: string
last_updated: string | null
is_estimated: boolean
}
export default function SEOPage() {
const { subscription } = useStore()
const [domain, setDomain] = useState('')
const [loading, setLoading] = useState(false)
const [seoData, setSeoData] = useState<SEOData | null>(null)
const [error, setError] = useState<string | null>(null)
const [recentSearches, setRecentSearches] = useState<string[]>([])
const tier = subscription?.tier?.toLowerCase() || 'scout'
const isTycoon = tier === 'tycoon'
useEffect(() => {
// Load recent searches from localStorage
const saved = localStorage.getItem('seo-recent-searches')
if (saved) {
setRecentSearches(JSON.parse(saved))
}
}, [])
const saveRecentSearch = (domain: string) => {
const updated = [domain, ...recentSearches.filter(d => d !== domain)].slice(0, 5)
setRecentSearches(updated)
localStorage.setItem('seo-recent-searches', JSON.stringify(updated))
}
const cleanDomain = (d: string): string => {
// Remove whitespace, protocol, www, and trailing slashes
return d.trim()
.toLowerCase()
.replace(/\s+/g, '')
.replace(/^https?:\/\//, '')
.replace(/^www\./, '')
.replace(/\/.*$/, '')
}
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault()
const cleanedDomain = cleanDomain(domain)
if (!cleanedDomain) return
setLoading(true)
setError(null)
setSeoData(null)
try {
const data = await api.request<SEOData>(`/seo/${encodeURIComponent(cleanedDomain)}`)
setSeoData(data)
saveRecentSearch(cleanedDomain)
} catch (err: any) {
setError(err.message || 'Failed to analyze domain')
} finally {
setLoading(false)
}
}
const handleQuickSearch = async (searchDomain: string) => {
const cleanedDomain = cleanDomain(searchDomain)
setDomain(cleanedDomain)
setLoading(true)
setError(null)
setSeoData(null)
try {
const data = await api.request<SEOData>(`/seo/${encodeURIComponent(cleanedDomain)}`)
setSeoData(data)
} catch (err: any) {
setError(err.message || 'Failed to analyze domain')
} finally {
setLoading(false)
}
}
const getScoreColor = (score: number) => {
if (score >= 60) return 'text-accent'
if (score >= 40) return 'text-amber-400'
if (score >= 20) return 'text-orange-400'
return 'text-foreground-muted'
}
const getScoreBg = (score: number) => {
if (score >= 60) return 'bg-accent/10 border-accent/30'
if (score >= 40) return 'bg-amber-500/10 border-amber-500/30'
if (score >= 20) return 'bg-orange-500/10 border-orange-500/30'
return 'bg-foreground/5 border-border'
}
const formatNumber = (num: number | null) => {
if (num === null) return '-'
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
return num.toString()
}
// Show upgrade prompt for non-Tycoon users
if (!isTycoon) {
return (
<CommandCenterLayout
title="SEO Juice Detector"
subtitle="Backlink analysis & domain authority"
>
<PageContainer>
<div className="text-center py-16 bg-gradient-to-br from-accent/10 to-accent/5 border border-accent/20 rounded-2xl">
<div className="w-20 h-20 bg-accent/20 rounded-full flex items-center justify-center mx-auto mb-6">
<Crown className="w-10 h-10 text-accent" />
</div>
<h2 className="text-2xl font-semibold text-foreground mb-3">Tycoon Feature</h2>
<p className="text-foreground-muted max-w-lg mx-auto mb-8">
SEO Juice Detector is a premium feature for serious domain investors.
Analyze backlinks, domain authority, and find hidden gems that SEO agencies pay
$100-$500 for even if the name is "ugly".
</p>
<div className="grid sm:grid-cols-3 gap-4 max-w-2xl mx-auto mb-8">
<div className="p-4 bg-background/50 rounded-xl">
<Link2 className="w-6 h-6 text-accent mx-auto mb-2" />
<p className="text-sm text-foreground font-medium">Backlink Analysis</p>
<p className="text-xs text-foreground-muted">Top referring domains</p>
</div>
<div className="p-4 bg-background/50 rounded-xl">
<TrendingUp className="w-6 h-6 text-accent mx-auto mb-2" />
<p className="text-sm text-foreground font-medium">Domain Authority</p>
<p className="text-xs text-foreground-muted">Moz DA/PA scores</p>
</div>
<div className="p-4 bg-background/50 rounded-xl">
<Star className="w-6 h-6 text-accent mx-auto mb-2" />
<p className="text-sm text-foreground font-medium">Notable Links</p>
<p className="text-xs text-foreground-muted">Wikipedia, .gov, .edu</p>
</div>
</div>
<Link
href="/pricing"
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
>
<Crown className="w-5 h-5" />
Upgrade to Tycoon
</Link>
</div>
</PageContainer>
</CommandCenterLayout>
)
}
return (
<CommandCenterLayout
title="SEO Juice Detector"
subtitle="Analyze backlinks, domain authority & find hidden SEO gems"
>
<PageContainer>
{/* Error Message */}
{error && (
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-400" />
<p className="text-sm text-red-400 flex-1">{error}</p>
<button onClick={() => setError(null)}><X className="w-4 h-4 text-red-400" /></button>
</div>
)}
{/* Search Form */}
<div className="p-6 bg-background-secondary/30 border border-border rounded-2xl">
<form onSubmit={handleSearch} className="flex gap-3">
<div className="relative flex-1">
<Globe className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
<input
type="text"
value={domain}
onChange={(e) => setDomain(e.target.value)}
placeholder="Enter domain to analyze (e.g., example.com)"
className="w-full pl-12 pr-4 py-3 bg-background border border-border rounded-xl
text-foreground placeholder:text-foreground-subtle
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
/>
</div>
<button
type="submit"
disabled={loading || !domain.trim()}
className="flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl
hover:bg-accent-hover transition-all disabled:opacity-50"
>
{loading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Search className="w-5 h-5" />
)}
Analyze
</button>
</form>
{/* Recent Searches */}
{recentSearches.length > 0 && !seoData && (
<div className="mt-4 flex items-center gap-2 flex-wrap">
<span className="text-xs text-foreground-muted">Recent:</span>
{recentSearches.map((d) => (
<button
key={d}
onClick={() => handleQuickSearch(d)}
className="px-3 py-1 text-xs bg-foreground/5 text-foreground-muted rounded-full hover:bg-foreground/10 transition-colors"
>
{d}
</button>
))}
</div>
)}
</div>
{/* Loading State */}
{loading && (
<div className="flex flex-col items-center justify-center py-16">
<Loader2 className="w-8 h-8 text-accent animate-spin mb-4" />
<p className="text-foreground-muted">Analyzing backlinks & authority...</p>
</div>
)}
{/* Results */}
{seoData && !loading && (
<div className="space-y-6 animate-slide-up">
{/* Header with Score */}
<div className="p-6 bg-background-secondary/30 border border-border rounded-2xl">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="font-mono text-2xl font-medium text-foreground mb-1">
{seoData.domain}
</h2>
<div className="flex items-center gap-2">
<Badge variant={seoData.is_estimated ? 'warning' : 'success'}>
{seoData.data_source === 'moz' ? 'Moz Data' : 'Estimated'}
</Badge>
<span className="text-sm text-foreground-muted">{seoData.value_category}</span>
</div>
</div>
<div className={clsx(
"w-24 h-24 rounded-2xl border flex flex-col items-center justify-center",
getScoreBg(seoData.seo_score)
)}>
<span className={clsx("text-3xl font-semibold", getScoreColor(seoData.seo_score))}>
{seoData.seo_score}
</span>
<span className="text-xs text-foreground-muted">SEO Score</span>
</div>
</div>
{/* Estimated Value */}
{seoData.estimated_value && (
<div className="mt-4 p-4 bg-accent/10 border border-accent/20 rounded-xl">
<p className="text-sm text-foreground-muted mb-1">Estimated SEO Value</p>
<p className="text-2xl font-semibold text-accent">
${seoData.estimated_value.toLocaleString()}
</p>
<p className="text-xs text-foreground-subtle mt-1">
Based on domain authority & backlink profile
</p>
</div>
)}
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<StatCard
title="Domain Authority"
value={seoData.metrics.domain_authority || 0}
icon={TrendingUp}
subtitle="/100"
/>
<StatCard
title="Page Authority"
value={seoData.metrics.page_authority || 0}
icon={Globe}
subtitle="/100"
/>
<StatCard
title="Backlinks"
value={formatNumber(seoData.metrics.total_backlinks)}
icon={Link2}
/>
<StatCard
title="Referring Domains"
value={formatNumber(seoData.metrics.referring_domains)}
icon={ExternalLink}
/>
<StatCard
title="Spam Score"
value={seoData.metrics.spam_score || 0}
icon={Shield}
subtitle={seoData.metrics.spam_score && seoData.metrics.spam_score > 30 ? '⚠️ High' : '✓ Low'}
/>
</div>
{/* Notable Links */}
<div className="p-6 bg-background-secondary/30 border border-border rounded-2xl">
<h3 className="text-lg font-medium text-foreground mb-4">Notable Backlinks</h3>
<div className="grid sm:grid-cols-4 gap-4">
<div className={clsx(
"p-4 rounded-xl border flex items-center gap-3",
seoData.notable_links.has_wikipedia
? "bg-accent/10 border-accent/30"
: "bg-foreground/5 border-border"
)}>
<BookOpen className={clsx(
"w-6 h-6",
seoData.notable_links.has_wikipedia ? "text-accent" : "text-foreground-subtle"
)} />
<div>
<p className="text-sm font-medium text-foreground">Wikipedia</p>
<p className="text-xs text-foreground-muted">
{seoData.notable_links.has_wikipedia ? '✓ Found' : 'Not found'}
</p>
</div>
</div>
<div className={clsx(
"p-4 rounded-xl border flex items-center gap-3",
seoData.notable_links.has_gov
? "bg-accent/10 border-accent/30"
: "bg-foreground/5 border-border"
)}>
<Building className={clsx(
"w-6 h-6",
seoData.notable_links.has_gov ? "text-accent" : "text-foreground-subtle"
)} />
<div>
<p className="text-sm font-medium text-foreground">.gov Links</p>
<p className="text-xs text-foreground-muted">
{seoData.notable_links.has_gov ? '✓ Found' : 'Not found'}
</p>
</div>
</div>
<div className={clsx(
"p-4 rounded-xl border flex items-center gap-3",
seoData.notable_links.has_edu
? "bg-accent/10 border-accent/30"
: "bg-foreground/5 border-border"
)}>
<GraduationCap className={clsx(
"w-6 h-6",
seoData.notable_links.has_edu ? "text-accent" : "text-foreground-subtle"
)} />
<div>
<p className="text-sm font-medium text-foreground">.edu Links</p>
<p className="text-xs text-foreground-muted">
{seoData.notable_links.has_edu ? '✓ Found' : 'Not found'}
</p>
</div>
</div>
<div className={clsx(
"p-4 rounded-xl border flex items-center gap-3",
seoData.notable_links.has_news
? "bg-accent/10 border-accent/30"
: "bg-foreground/5 border-border"
)}>
<Newspaper className={clsx(
"w-6 h-6",
seoData.notable_links.has_news ? "text-accent" : "text-foreground-subtle"
)} />
<div>
<p className="text-sm font-medium text-foreground">News Sites</p>
<p className="text-xs text-foreground-muted">
{seoData.notable_links.has_news ? '✓ Found' : 'Not found'}
</p>
</div>
</div>
</div>
{/* Notable Domains List */}
{seoData.notable_links.notable_domains.length > 0 && (
<div className="mt-4">
<p className="text-sm text-foreground-muted mb-2">High-authority referring domains:</p>
<div className="flex flex-wrap gap-2">
{seoData.notable_links.notable_domains.map((d) => (
<span key={d} className="px-3 py-1 bg-accent/10 text-accent text-sm rounded-full">
{d}
</span>
))}
</div>
</div>
)}
</div>
{/* Top Backlinks */}
{seoData.top_backlinks.length > 0 && (
<div className="p-6 bg-background-secondary/30 border border-border rounded-2xl">
<h3 className="text-lg font-medium text-foreground mb-4">Top Backlinks</h3>
<div className="space-y-2">
{seoData.top_backlinks.map((link, idx) => (
<div
key={idx}
className="flex items-center justify-between p-3 bg-background rounded-xl border border-border/50"
>
<div className="flex items-center gap-3">
<div className={clsx(
"w-8 h-8 rounded-lg flex items-center justify-center text-sm font-medium",
link.authority >= 60 ? "bg-accent/10 text-accent" :
link.authority >= 40 ? "bg-amber-500/10 text-amber-400" :
"bg-foreground/5 text-foreground-muted"
)}>
{link.authority}
</div>
<div>
<p className="font-mono text-sm text-foreground">{link.domain}</p>
{link.page && (
<p className="text-xs text-foreground-muted truncate max-w-xs">{link.page}</p>
)}
</div>
</div>
<a
href={`https://${link.domain}`}
target="_blank"
rel="noopener noreferrer"
className="p-2 text-foreground-subtle hover:text-accent transition-colors"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
))}
</div>
</div>
)}
{/* Data Source Note */}
{seoData.is_estimated && (
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl">
<p className="text-sm text-amber-400">
<AlertCircle className="w-4 h-4 inline mr-2" />
This data is estimated based on domain characteristics.
For live Moz data, configure MOZ_ACCESS_ID and MOZ_SECRET_KEY in the backend.
</p>
</div>
)}
</div>
)}
{/* Empty State */}
{!seoData && !loading && !error && (
<div className="text-center py-16">
<Sparkles className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
<h2 className="text-xl font-medium text-foreground mb-2">SEO Juice Detector</h2>
<p className="text-foreground-muted max-w-md mx-auto">
Enter a domain above to analyze its backlink profile, domain authority,
and find hidden SEO value that others miss.
</p>
</div>
)}
</PageContainer>
</CommandCenterLayout>
)
}

View File

@ -0,0 +1,563 @@
'use client'
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
import { PageContainer, TabBar } from '@/components/PremiumTable'
import { useStore } from '@/lib/store'
import { api, PriceAlert } from '@/lib/api'
import {
User,
Bell,
CreditCard,
Shield,
ChevronRight,
Loader2,
Check,
AlertCircle,
Trash2,
ExternalLink,
Crown,
Zap,
Key,
TrendingUp,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
type SettingsTab = 'profile' | 'notifications' | 'billing' | 'security'
export default function SettingsPage() {
const router = useRouter()
const { user, isAuthenticated, isLoading, checkAuth, subscription } = useStore()
const [activeTab, setActiveTab] = useState<SettingsTab>('profile')
const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
// Profile form
const [profileForm, setProfileForm] = useState({
name: '',
email: '',
})
// Notification preferences
const [notificationPrefs, setNotificationPrefs] = useState({
domain_availability: true,
price_alerts: true,
weekly_digest: false,
})
const [savingNotifications, setSavingNotifications] = useState(false)
// Price alerts
const [priceAlerts, setPriceAlerts] = useState<PriceAlert[]>([])
const [loadingAlerts, setLoadingAlerts] = useState(false)
const [deletingAlertId, setDeletingAlertId] = useState<number | null>(null)
useEffect(() => {
checkAuth()
}, [checkAuth])
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login')
}
}, [isLoading, isAuthenticated, router])
useEffect(() => {
if (user) {
setProfileForm({
name: user.name || '',
email: user.email || '',
})
}
}, [user])
useEffect(() => {
if (isAuthenticated && activeTab === 'notifications') {
loadPriceAlerts()
}
}, [isAuthenticated, activeTab])
const loadPriceAlerts = async () => {
setLoadingAlerts(true)
try {
const alerts = await api.getPriceAlerts()
setPriceAlerts(alerts)
} catch (err) {
console.error('Failed to load alerts:', err)
} finally {
setLoadingAlerts(false)
}
}
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setError(null)
setSuccess(null)
try {
await api.updateMe({ name: profileForm.name || undefined })
const { checkAuth } = useStore.getState()
await checkAuth()
setSuccess('Profile updated successfully')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update profile')
} finally {
setSaving(false)
}
}
const handleSaveNotifications = async () => {
setSavingNotifications(true)
setError(null)
setSuccess(null)
try {
localStorage.setItem('notification_prefs', JSON.stringify(notificationPrefs))
setSuccess('Notification preferences saved')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save preferences')
} finally {
setSavingNotifications(false)
}
}
useEffect(() => {
const saved = localStorage.getItem('notification_prefs')
if (saved) {
try {
setNotificationPrefs(JSON.parse(saved))
} catch {}
}
}, [])
const handleDeletePriceAlert = async (tld: string, alertId: number) => {
setDeletingAlertId(alertId)
try {
await api.deletePriceAlert(tld)
setPriceAlerts(prev => prev.filter(a => a.id !== alertId))
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete alert')
} finally {
setDeletingAlertId(null)
}
}
const handleOpenBillingPortal = async () => {
try {
const { portal_url } = await api.createPortalSession()
window.location.href = portal_url
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to open billing portal')
}
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (!isAuthenticated || !user) {
return null
}
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
const isProOrHigher = ['Trader', 'Tycoon', 'Professional', 'Enterprise'].includes(tierName)
const tabs = [
{ id: 'profile' as const, label: 'Profile', icon: User },
{ id: 'notifications' as const, label: 'Notifications', icon: Bell },
{ id: 'billing' as const, label: 'Billing', icon: CreditCard },
{ id: 'security' as const, label: 'Security', icon: Shield },
]
return (
<CommandCenterLayout
title="Settings"
subtitle="Manage your account"
>
<PageContainer>
{/* Messages */}
{error && (
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
<p className="text-sm text-red-400 flex-1">{error}</p>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
{success && (
<div className="p-4 bg-accent/10 border border-accent/20 rounded-xl flex items-center gap-3">
<Check className="w-5 h-5 text-accent shrink-0" />
<p className="text-sm text-accent flex-1">{success}</p>
<button onClick={() => setSuccess(null)} className="text-accent hover:text-accent/80">
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
<div className="flex flex-col lg:flex-row gap-6">
{/* Sidebar */}
<div className="lg:w-72 shrink-0 space-y-5">
{/* Mobile: Horizontal scroll tabs */}
<nav className="lg:hidden flex gap-2 overflow-x-auto pb-2 -mx-4 px-4 scrollbar-hide">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsx(
"flex items-center gap-2.5 px-5 py-3 text-sm font-medium rounded-xl whitespace-nowrap transition-all",
activeTab === tab.id
? "bg-gradient-to-r from-accent to-accent/80 text-background shadow-lg shadow-accent/20"
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground border border-border/50"
)}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</nav>
{/* Desktop: Vertical tabs */}
<nav className="hidden lg:block p-2 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 border border-border/40 rounded-2xl backdrop-blur-sm">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsx(
"w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium rounded-xl transition-all",
activeTab === tab.id
? "bg-gradient-to-r from-accent to-accent/80 text-background shadow-lg shadow-accent/20"
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
)}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</nav>
{/* Plan info */}
<div className="hidden lg:block p-5 bg-accent/5 border border-accent/20 rounded-2xl">
<div className="flex items-center gap-2 mb-3">
{isProOrHigher ? <Crown className="w-5 h-5 text-accent" /> : <Zap className="w-5 h-5 text-accent" />}
<span className="text-sm font-semibold text-foreground">{tierName} Plan</span>
</div>
<p className="text-xs text-foreground-muted mb-4">
{subscription?.domains_used || 0} / {subscription?.domain_limit || 5} domains tracked
</p>
{!isProOrHigher && (
<Link
href="/pricing"
className="flex items-center justify-center gap-2 w-full py-2.5 bg-gradient-to-r from-accent to-accent/80 text-background text-sm font-medium rounded-xl hover:shadow-[0_0_20px_-5px_rgba(16,185,129,0.4)] transition-all"
>
Upgrade
<ChevronRight className="w-3.5 h-3.5" />
</Link>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Profile Tab */}
{activeTab === 'profile' && (
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm p-6">
<h2 className="text-lg font-medium text-foreground mb-6">Profile Information</h2>
<form onSubmit={handleSaveProfile} className="space-y-5">
<div>
<label className="block text-sm text-foreground-muted mb-2">Name</label>
<input
type="text"
value={profileForm.name}
onChange={(e) => setProfileForm({ ...profileForm, name: e.target.value })}
placeholder="Your name"
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 transition-all"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-2">Email</label>
<input
type="email"
value={profileForm.email}
disabled
className="w-full h-11 px-4 bg-foreground/5 border border-border/50 rounded-xl text-foreground-muted cursor-not-allowed"
/>
<p className="text-xs text-foreground-subtle mt-1.5">Email cannot be changed</p>
</div>
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-accent to-accent/80 text-background font-medium rounded-xl
hover:shadow-[0_0_20px_-5px_rgba(16,185,129,0.4)] disabled:opacity-50 transition-all"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Save Changes
</button>
</form>
</div>
)}
{/* Notifications Tab */}
{activeTab === 'notifications' && (
<div className="space-y-6">
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm p-6">
<h2 className="text-lg font-medium text-foreground mb-5">Email Preferences</h2>
<div className="space-y-3">
{[
{ key: 'domain_availability', label: 'Domain Availability', desc: 'Get notified when watched domains become available' },
{ key: 'price_alerts', label: 'Price Alerts', desc: 'Get notified when TLD prices change' },
{ key: 'weekly_digest', label: 'Weekly Digest', desc: 'Receive a weekly summary of your portfolio' },
].map((item) => (
<label key={item.key} className="flex items-center justify-between p-4 bg-foreground/5 border border-border/30 rounded-xl cursor-pointer hover:border-foreground/20 transition-colors">
<div>
<p className="text-sm font-medium text-foreground">{item.label}</p>
<p className="text-xs text-foreground-muted">{item.desc}</p>
</div>
<input
type="checkbox"
checked={notificationPrefs[item.key as keyof typeof notificationPrefs]}
onChange={(e) => setNotificationPrefs({ ...notificationPrefs, [item.key]: e.target.checked })}
className="w-5 h-5 accent-accent cursor-pointer"
/>
</label>
))}
</div>
<button
onClick={handleSaveNotifications}
disabled={savingNotifications}
className="mt-5 flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-accent to-accent/80 text-background font-medium rounded-xl
hover:shadow-[0_0_20px_-5px_rgba(16,185,129,0.4)] disabled:opacity-50 transition-all"
>
{savingNotifications ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Save Preferences
</button>
</div>
{/* Active Price Alerts */}
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm p-6">
<h2 className="text-lg font-medium text-foreground mb-5">Active Price Alerts</h2>
{loadingAlerts ? (
<div className="py-10 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-accent" />
</div>
) : priceAlerts.length === 0 ? (
<div className="py-12 text-center border border-dashed border-border/50 rounded-xl bg-foreground/5">
<Bell className="w-10 h-10 text-foreground-subtle mx-auto mb-4" />
<p className="text-foreground-muted mb-3">No price alerts set</p>
<Link href="/command/pricing" className="text-accent hover:text-accent/80 text-sm font-medium">
Browse TLD prices
</Link>
</div>
) : (
<div className="space-y-2">
{priceAlerts.map((alert) => (
<div
key={alert.id}
className="flex items-center justify-between p-4 bg-foreground/5 border border-border/30 rounded-xl hover:border-foreground/20 transition-colors"
>
<div className="flex items-center gap-3">
<div className="relative">
<div className={clsx(
"w-2.5 h-2.5 rounded-full",
alert.is_active ? "bg-accent" : "bg-foreground-subtle"
)} />
{alert.is_active && (
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-40" />
)}
</div>
<div>
<Link
href={`/tld-pricing/${alert.tld}`}
className="text-sm font-mono font-medium text-foreground hover:text-accent transition-colors"
>
.{alert.tld}
</Link>
<p className="text-xs text-foreground-muted">
Alert on {alert.threshold_percent}% change
{alert.target_price && ` or below $${alert.target_price}`}
</p>
</div>
</div>
<button
onClick={() => handleDeletePriceAlert(alert.tld, alert.id)}
disabled={deletingAlertId === alert.id}
className="p-2 text-foreground-subtle hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-all"
>
{deletingAlertId === alert.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Billing Tab */}
{activeTab === 'billing' && (
<div className="space-y-6">
{/* Current Plan */}
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm p-6">
<h2 className="text-lg font-medium text-foreground mb-6">Your Current Plan</h2>
<div className="p-5 bg-accent/5 border border-accent/20 rounded-xl mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
{tierName === 'Tycoon' ? <Crown className="w-6 h-6 text-accent" /> : tierName === 'Trader' ? <TrendingUp className="w-6 h-6 text-accent" /> : <Zap className="w-6 h-6 text-accent" />}
<div>
<p className="text-xl font-semibold text-foreground">{tierName}</p>
<p className="text-sm text-foreground-muted">
{tierName === 'Scout' ? 'Free forever' : tierName === 'Trader' ? '$9/month' : '$29/month'}
</p>
</div>
</div>
<span className={clsx(
"px-3 py-1.5 text-xs font-medium rounded-full",
isProOrHigher ? "bg-accent/10 text-accent" : "bg-foreground/5 text-foreground-muted"
)}>
{isProOrHigher ? 'Active' : 'Free'}
</span>
</div>
{/* Plan Stats */}
<div className="grid grid-cols-3 gap-4 p-4 bg-background/50 rounded-xl mb-4">
<div className="text-center">
<p className="text-2xl font-semibold text-foreground">{subscription?.domain_limit || 5}</p>
<p className="text-xs text-foreground-muted">Domains</p>
</div>
<div className="text-center border-x border-border/50">
<p className="text-2xl font-semibold text-foreground">
{subscription?.check_frequency === 'realtime' ? '10m' :
subscription?.check_frequency === 'hourly' ? '1h' : '24h'}
</p>
<p className="text-xs text-foreground-muted">Check Interval</p>
</div>
<div className="text-center">
<p className="text-2xl font-semibold text-foreground">
{subscription?.portfolio_limit === -1 ? '∞' : subscription?.portfolio_limit || 0}
</p>
<p className="text-xs text-foreground-muted">Portfolio</p>
</div>
</div>
{isProOrHigher ? (
<button
onClick={handleOpenBillingPortal}
className="w-full flex items-center justify-center gap-2 py-3 bg-background text-foreground font-medium rounded-xl border border-border/50
hover:border-foreground/20 transition-all"
>
<ExternalLink className="w-4 h-4" />
Manage Subscription
</button>
) : (
<Link
href="/pricing"
className="w-full flex items-center justify-center gap-2 py-3 bg-gradient-to-r from-accent to-accent/80 text-background font-medium rounded-xl
hover:shadow-[0_0_20px_-5px_rgba(16,185,129,0.4)] transition-all"
>
<Zap className="w-4 h-4" />
Upgrade Plan
</Link>
)}
</div>
{/* Plan Features */}
<h3 className="text-sm font-medium text-foreground mb-3">Your Plan Includes</h3>
<ul className="grid grid-cols-2 gap-2">
{[
`${subscription?.domain_limit || 5} Watchlist Domains`,
`${subscription?.check_frequency === 'realtime' ? '10-minute' : subscription?.check_frequency === 'hourly' ? 'Hourly' : 'Daily'} Scans`,
'Email Alerts',
'TLD Price Data',
].map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm">
<Check className="w-4 h-4 text-accent" />
<span className="text-foreground">{feature}</span>
</li>
))}
</ul>
</div>
</div>
)}
{/* Security Tab */}
{activeTab === 'security' && (
<div className="space-y-6">
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm p-6">
<h2 className="text-lg font-medium text-foreground mb-4">Password</h2>
<p className="text-sm text-foreground-muted mb-5">
Change your password or reset it if you've forgotten it.
</p>
<Link
href="/forgot-password"
className="inline-flex items-center gap-2 px-5 py-3 bg-foreground/5 border border-border/50 text-foreground font-medium rounded-xl
hover:border-foreground/20 transition-all"
>
<Key className="w-4 h-4" />
Change Password
</Link>
</div>
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm p-6">
<h2 className="text-lg font-medium text-foreground mb-5">Account Security</h2>
<div className="space-y-3">
<div className="flex items-center justify-between p-4 bg-foreground/5 border border-border/30 rounded-xl">
<div>
<p className="text-sm font-medium text-foreground">Email Verified</p>
<p className="text-xs text-foreground-muted">Your email address has been verified</p>
</div>
<div className="w-8 h-8 bg-accent/10 rounded-lg flex items-center justify-center">
<Check className="w-4 h-4 text-accent" />
</div>
</div>
<div className="flex items-center justify-between p-4 bg-foreground/5 border border-border/30 rounded-xl">
<div>
<p className="text-sm font-medium text-foreground">Two-Factor Authentication</p>
<p className="text-xs text-foreground-muted">Coming soon</p>
</div>
<span className="text-xs px-2.5 py-1 bg-foreground/5 text-foreground-muted rounded-full border border-border/30">Soon</span>
</div>
</div>
</div>
<div className="relative overflow-hidden rounded-2xl border border-red-500/20 bg-red-500/5 p-6">
<h2 className="text-lg font-medium text-red-400 mb-2">Danger Zone</h2>
<p className="text-sm text-foreground-muted mb-5">
Permanently delete your account and all associated data.
</p>
<button
className="px-5 py-3 bg-red-500 text-white font-medium rounded-xl hover:bg-red-500/90 transition-all"
>
Delete Account
</button>
</div>
</div>
)}
</div>
</div>
</PageContainer>
</CommandCenterLayout>
)
}

View File

@ -0,0 +1,620 @@
'use client'
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
import { useStore } from '@/lib/store'
import { api, DomainHealthReport, HealthStatus } from '@/lib/api'
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
import {
PremiumTable,
Badge,
StatCard,
PageContainer,
TableActionButton,
SearchInput,
TabBar,
FilterBar,
ActionButton,
} from '@/components/PremiumTable'
import { Toast, useToast } from '@/components/Toast'
import {
Plus,
Trash2,
RefreshCw,
Loader2,
Bell,
BellOff,
ExternalLink,
Eye,
Sparkles,
ArrowUpRight,
X,
Activity,
Shield,
AlertTriangle,
ShoppingCart,
HelpCircle,
} from 'lucide-react'
import clsx from 'clsx'
import Link from 'next/link'
// Health status badge colors and icons
const healthStatusConfig: Record<HealthStatus, {
label: string
color: string
bgColor: string
icon: typeof Activity
description: string
}> = {
healthy: {
label: 'Healthy',
color: 'text-accent',
bgColor: 'bg-accent/10 border-accent/20',
icon: Activity,
description: 'Domain is active and well-maintained'
},
weakening: {
label: 'Weakening',
color: 'text-amber-400',
bgColor: 'bg-amber-400/10 border-amber-400/20',
icon: AlertTriangle,
description: 'Warning signs detected - owner may be losing interest'
},
parked: {
label: 'For Sale',
color: 'text-orange-400',
bgColor: 'bg-orange-400/10 border-orange-400/20',
icon: ShoppingCart,
description: 'Domain is parked and likely for sale'
},
critical: {
label: 'Critical',
color: 'text-red-400',
bgColor: 'bg-red-400/10 border-red-400/20',
icon: AlertTriangle,
description: 'Domain drop is imminent!'
},
unknown: {
label: 'Unknown',
color: 'text-foreground-muted',
bgColor: 'bg-foreground/5 border-border/30',
icon: HelpCircle,
description: 'Could not determine status'
},
}
type FilterStatus = 'all' | 'available' | 'watching'
export default function WatchlistPage() {
const { domains, addDomain, deleteDomain, refreshDomain, subscription } = useStore()
const { toast, showToast, hideToast } = useToast()
const [newDomain, setNewDomain] = useState('')
const [adding, setAdding] = useState(false)
const [refreshingId, setRefreshingId] = useState<number | null>(null)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [togglingNotifyId, setTogglingNotifyId] = useState<number | null>(null)
const [filterStatus, setFilterStatus] = useState<FilterStatus>('all')
const [searchQuery, setSearchQuery] = useState('')
// Health check state
const [healthReports, setHealthReports] = useState<Record<number, DomainHealthReport>>({})
const [loadingHealth, setLoadingHealth] = useState<Record<number, boolean>>({})
const [selectedHealthDomainId, setSelectedHealthDomainId] = useState<number | null>(null)
// Memoized stats - avoids recalculation on every render
const stats = useMemo(() => ({
availableCount: domains?.filter(d => d.is_available).length || 0,
watchingCount: domains?.filter(d => !d.is_available).length || 0,
domainsUsed: domains?.length || 0,
domainLimit: subscription?.domain_limit || 5,
}), [domains, subscription?.domain_limit])
const canAddMore = stats.domainsUsed < stats.domainLimit
// Memoized filtered domains
const filteredDomains = useMemo(() => {
if (!domains) return []
return domains.filter(domain => {
if (searchQuery && !domain.name.toLowerCase().includes(searchQuery.toLowerCase())) {
return false
}
if (filterStatus === 'available' && !domain.is_available) return false
if (filterStatus === 'watching' && domain.is_available) return false
return true
})
}, [domains, searchQuery, filterStatus])
// Memoized tabs config
const tabs = useMemo(() => [
{ id: 'all', label: 'All', count: stats.domainsUsed },
{ id: 'available', label: 'Available', count: stats.availableCount, color: 'accent' as const },
{ id: 'watching', label: 'Monitoring', count: stats.watchingCount },
], [stats])
// Callbacks - prevent recreation on every render
const handleAddDomain = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
if (!newDomain.trim()) return
setAdding(true)
try {
await addDomain(newDomain.trim())
setNewDomain('')
showToast(`Added ${newDomain.trim()} to watchlist`, 'success')
} catch (err: any) {
showToast(err.message || 'Failed to add domain', 'error')
} finally {
setAdding(false)
}
}, [newDomain, addDomain, showToast])
const handleRefresh = useCallback(async (id: number) => {
setRefreshingId(id)
try {
await refreshDomain(id)
showToast('Domain status refreshed', 'success')
} catch (err: any) {
showToast(err.message || 'Failed to refresh', 'error')
} finally {
setRefreshingId(null)
}
}, [refreshDomain, showToast])
const handleDelete = useCallback(async (id: number, name: string) => {
if (!confirm(`Remove ${name} from your watchlist?`)) return
setDeletingId(id)
try {
await deleteDomain(id)
showToast(`Removed ${name} from watchlist`, 'success')
} catch (err: any) {
showToast(err.message || 'Failed to remove', 'error')
} finally {
setDeletingId(null)
}
}, [deleteDomain, showToast])
const handleToggleNotify = useCallback(async (id: number, currentState: boolean) => {
setTogglingNotifyId(id)
try {
await api.updateDomainNotify(id, !currentState)
showToast(!currentState ? 'Notifications enabled' : 'Notifications disabled', 'success')
} catch (err: any) {
showToast(err.message || 'Failed to update', 'error')
} finally {
setTogglingNotifyId(null)
}
}, [showToast])
const handleHealthCheck = useCallback(async (domainId: number) => {
if (loadingHealth[domainId]) return
setLoadingHealth(prev => ({ ...prev, [domainId]: true }))
try {
const report = await api.getDomainHealth(domainId)
setHealthReports(prev => ({ ...prev, [domainId]: report }))
setSelectedHealthDomainId(domainId)
} catch (err: any) {
showToast(err.message || 'Health check failed', 'error')
} finally {
setLoadingHealth(prev => ({ ...prev, [domainId]: false }))
}
}, [loadingHealth, showToast])
// Dynamic subtitle
const subtitle = useMemo(() => {
if (stats.domainsUsed === 0) return 'Start tracking domains to monitor their availability'
return `Monitoring ${stats.domainsUsed} domain${stats.domainsUsed !== 1 ? 's' : ''}${stats.domainLimit === -1 ? 'Unlimited' : `${stats.domainLimit - stats.domainsUsed} slots left`}`
}, [stats])
// Memoized columns config
const columns = useMemo(() => [
{
key: 'domain',
header: 'Domain',
render: (domain: any) => (
<div className="flex items-center gap-3">
<div className="relative">
<span className={clsx(
"block w-3 h-3 rounded-full",
domain.is_available ? "bg-accent" : "bg-foreground-muted/50"
)} />
{domain.is_available && (
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-50" />
)}
</div>
<div>
<span className="font-mono font-medium text-foreground">{domain.name}</span>
{domain.is_available && (
<Badge variant="success" size="xs" className="ml-2">AVAILABLE</Badge>
)}
</div>
</div>
),
},
{
key: 'status',
header: 'Status',
align: 'left' as const,
hideOnMobile: true,
render: (domain: any) => {
const health = healthReports[domain.id]
if (health) {
const config = healthStatusConfig[health.status]
const Icon = config.icon
return (
<div className={clsx("inline-flex items-center gap-2 px-2.5 py-1 rounded-lg border", config.bgColor)}>
<Icon className={clsx("w-3.5 h-3.5", config.color)} />
<span className={clsx("text-xs font-medium", config.color)}>{config.label}</span>
</div>
)
}
return (
<span className={clsx(
"text-sm",
domain.is_available ? "text-accent font-medium" : "text-foreground-muted"
)}>
{domain.is_available ? 'Ready to pounce!' : 'Monitoring...'}
</span>
)
},
},
{
key: 'notifications',
header: 'Alerts',
align: 'center' as const,
width: '80px',
hideOnMobile: true,
render: (domain: any) => (
<button
onClick={(e) => {
e.stopPropagation()
handleToggleNotify(domain.id, domain.notify_on_available)
}}
disabled={togglingNotifyId === domain.id}
className={clsx(
"p-2 rounded-lg transition-colors",
domain.notify_on_available
? "bg-accent/10 text-accent hover:bg-accent/20"
: "text-foreground-muted hover:bg-foreground/5"
)}
title={domain.notify_on_available ? "Disable alerts" : "Enable alerts"}
>
{togglingNotifyId === domain.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : domain.notify_on_available ? (
<Bell className="w-4 h-4" />
) : (
<BellOff className="w-4 h-4" />
)}
</button>
),
},
{
key: 'actions',
header: '',
align: 'right' as const,
render: (domain: any) => (
<div className="flex items-center gap-1 justify-end">
<TableActionButton
icon={Activity}
onClick={() => handleHealthCheck(domain.id)}
loading={loadingHealth[domain.id]}
title="Health check (DNS, HTTP, SSL)"
variant={healthReports[domain.id] ? 'accent' : 'default'}
/>
<TableActionButton
icon={RefreshCw}
onClick={() => handleRefresh(domain.id)}
loading={refreshingId === domain.id}
title="Refresh availability"
/>
<TableActionButton
icon={Trash2}
onClick={() => handleDelete(domain.id, domain.name)}
variant="danger"
loading={deletingId === domain.id}
title="Remove"
/>
{domain.is_available && (
<a
href={`https://www.namecheap.com/domains/registration/results/?domain=${domain.name}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-1.5 px-3 py-2 bg-accent text-background text-xs font-medium
rounded-lg hover:bg-accent-hover transition-colors ml-1"
>
Register <ExternalLink className="w-3 h-3" />
</a>
)}
</div>
),
},
], [healthReports, togglingNotifyId, loadingHealth, refreshingId, deletingId, handleToggleNotify, handleHealthCheck, handleRefresh, handleDelete])
return (
<CommandCenterLayout title="Watchlist" subtitle={subtitle}>
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
<PageContainer>
{/* Stats Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="Total Watched" value={stats.domainsUsed} icon={Eye} />
<StatCard title="Available" value={stats.availableCount} icon={Sparkles} />
<StatCard title="Monitoring" value={stats.watchingCount} subtitle="active checks" icon={Activity} />
<StatCard title="Plan Limit" value={stats.domainLimit === -1 ? '∞' : stats.domainLimit} subtitle={`${stats.domainsUsed} used`} icon={Shield} />
</div>
{/* Add Domain Form */}
<FilterBar>
<SearchInput
value={newDomain}
onChange={setNewDomain}
placeholder="Enter domain to track (e.g., dream.com)"
className="flex-1"
/>
<ActionButton
onClick={handleAddDomain}
disabled={adding || !newDomain.trim() || !canAddMore}
icon={adding ? Loader2 : Plus}
>
Add Domain
</ActionButton>
</FilterBar>
{!canAddMore && (
<div className="flex items-center justify-between p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl">
<p className="text-sm text-amber-400">
You've reached your domain limit. Upgrade to track more.
</p>
<Link
href="/pricing"
className="text-sm font-medium text-amber-400 hover:text-amber-300 flex items-center gap-1"
>
Upgrade <ArrowUpRight className="w-3 h-3" />
</Link>
</div>
)}
{/* Filters */}
<FilterBar className="justify-between">
<TabBar
tabs={tabs}
activeTab={filterStatus}
onChange={(id) => setFilterStatus(id as FilterStatus)}
/>
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Filter domains..."
className="w-full sm:w-64"
/>
</FilterBar>
{/* Domain Table */}
<PremiumTable
data={filteredDomains}
keyExtractor={(d) => d.id}
emptyIcon={<Eye className="w-12 h-12 text-foreground-subtle" />}
emptyTitle={stats.domainsUsed === 0 ? "Your watchlist is empty" : "No domains match your filters"}
emptyDescription={stats.domainsUsed === 0 ? "Add a domain above to start tracking" : "Try adjusting your filter criteria"}
columns={columns}
/>
{/* Health Report Modal */}
{selectedHealthDomainId && healthReports[selectedHealthDomainId] && (
<HealthReportModal
report={healthReports[selectedHealthDomainId]}
onClose={() => setSelectedHealthDomainId(null)}
/>
)}
</PageContainer>
</CommandCenterLayout>
)
}
// Health Report Modal Component - memoized
const HealthReportModal = memo(function HealthReportModal({
report,
onClose
}: {
report: DomainHealthReport
onClose: () => void
}) {
const config = healthStatusConfig[report.status]
const Icon = config.icon
return (
<div
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="w-full max-w-lg bg-background-secondary border border-border/50 rounded-2xl shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-border/50">
<div className="flex items-center gap-3">
<div className={clsx("p-2 rounded-lg border", config.bgColor)}>
<Icon className={clsx("w-5 h-5", config.color)} />
</div>
<div>
<h3 className="font-mono font-semibold text-foreground">{report.domain}</h3>
<p className="text-xs text-foreground-muted">{config.description}</p>
</div>
</div>
<button onClick={onClose} className="p-1 text-foreground-muted hover:text-foreground transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Score */}
<div className="p-5 border-b border-border/30">
<div className="flex items-center justify-between">
<span className="text-sm text-foreground-muted">Health Score</span>
<div className="flex items-center gap-3">
<div className="w-32 h-2 bg-foreground/10 rounded-full overflow-hidden">
<div
className={clsx(
"h-full rounded-full transition-all",
report.score >= 70 ? "bg-accent" :
report.score >= 40 ? "bg-amber-400" : "bg-red-400"
)}
style={{ width: `${report.score}%` }}
/>
</div>
<span className={clsx(
"text-lg font-bold tabular-nums",
report.score >= 70 ? "text-accent" :
report.score >= 40 ? "text-amber-400" : "text-red-400"
)}>
{report.score}/100
</span>
</div>
</div>
</div>
{/* Check Results */}
<div className="p-5 space-y-4 max-h-80 overflow-y-auto">
{/* DNS */}
{report.dns && (
<div className="p-4 bg-foreground/5 rounded-xl">
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
<span className={clsx(
"w-2 h-2 rounded-full",
report.dns.has_ns && report.dns.has_a ? "bg-accent" : "bg-red-400"
)} />
DNS Infrastructure
</h4>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="flex items-center gap-1.5">
<span className={report.dns.has_ns ? "text-accent" : "text-red-400"}>
{report.dns.has_ns ? '' : ''}
</span>
<span className="text-foreground-muted">Nameservers</span>
</div>
<div className="flex items-center gap-1.5">
<span className={report.dns.has_a ? "text-accent" : "text-red-400"}>
{report.dns.has_a ? '' : ''}
</span>
<span className="text-foreground-muted">A Record</span>
</div>
<div className="flex items-center gap-1.5">
<span className={report.dns.has_mx ? "text-accent" : "text-foreground-muted"}>
{report.dns.has_mx ? '' : ''}
</span>
<span className="text-foreground-muted">MX Record</span>
</div>
</div>
{report.dns.is_parked && (
<p className="mt-2 text-xs text-orange-400">⚠ Parked at {report.dns.parking_provider || 'unknown provider'}</p>
)}
</div>
)}
{/* HTTP */}
{report.http && (
<div className="p-4 bg-foreground/5 rounded-xl">
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
<span className={clsx(
"w-2 h-2 rounded-full",
report.http.is_reachable && report.http.status_code === 200 ? "bg-accent" :
report.http.is_reachable ? "bg-amber-400" : "bg-red-400"
)} />
Website Status
</h4>
<div className="flex items-center gap-4 text-xs">
<span className={clsx(
report.http.is_reachable ? "text-accent" : "text-red-400"
)}>
{report.http.is_reachable ? 'Reachable' : 'Unreachable'}
</span>
{report.http.status_code && (
<span className="text-foreground-muted">HTTP {report.http.status_code}</span>
)}
</div>
{report.http.is_parked && (
<p className="mt-2 text-xs text-orange-400">⚠ Parking page detected</p>
)}
</div>
)}
{/* SSL */}
{report.ssl && (
<div className="p-4 bg-foreground/5 rounded-xl">
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
<span className={clsx(
"w-2 h-2 rounded-full",
report.ssl.has_certificate && report.ssl.is_valid ? "bg-accent" :
report.ssl.has_certificate ? "bg-amber-400" : "bg-foreground-muted"
)} />
SSL Certificate
</h4>
<div className="text-xs">
{report.ssl.has_certificate ? (
<div className="space-y-1">
<p className={report.ssl.is_valid ? "text-accent" : "text-red-400"}>
{report.ssl.is_valid ? ' Valid certificate' : ' Certificate invalid/expired'}
</p>
{report.ssl.days_until_expiry !== undefined && (
<p className={clsx(
report.ssl.days_until_expiry > 30 ? "text-foreground-muted" :
report.ssl.days_until_expiry > 7 ? "text-amber-400" : "text-red-400"
)}>
Expires in {report.ssl.days_until_expiry} days
</p>
)}
</div>
) : (
<p className="text-foreground-muted">No SSL certificate</p>
)}
</div>
</div>
)}
{/* Signals & Recommendations */}
{((report.signals?.length || 0) > 0 || (report.recommendations?.length || 0) > 0) && (
<div className="space-y-3">
{(report.signals?.length || 0) > 0 && (
<div>
<h4 className="text-xs font-medium text-foreground-muted uppercase tracking-wider mb-2">Signals</h4>
<ul className="space-y-1">
{report.signals?.map((signal, i) => (
<li key={i} className="text-xs text-foreground flex items-start gap-2">
<span className="text-accent mt-0.5"></span>
{signal}
</li>
))}
</ul>
</div>
)}
{(report.recommendations?.length || 0) > 0 && (
<div>
<h4 className="text-xs font-medium text-foreground-muted uppercase tracking-wider mb-2">Recommendations</h4>
<ul className="space-y-1">
{report.recommendations?.map((rec, i) => (
<li key={i} className="text-xs text-foreground flex items-start gap-2">
<span className="text-amber-400 mt-0.5"></span>
{rec}
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 bg-foreground/5 border-t border-border/30">
<p className="text-xs text-foreground-subtle text-center">
Checked at {new Date(report.checked_at).toLocaleString()}
</p>
</div>
</div>
</div>
)
})

View File

@ -0,0 +1,221 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
import { PageContainer } from '@/components/PremiumTable'
import { useStore } from '@/lib/store'
import {
CheckCircle,
Zap,
Crown,
ArrowRight,
Eye,
Store,
Bell,
BarChart3,
Sparkles,
TrendingUp,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
const planDetails = {
trader: {
name: 'Trader',
icon: TrendingUp,
color: 'text-accent',
bgColor: 'bg-accent/10',
features: [
{ icon: Eye, text: '50 domains in watchlist', description: 'Track up to 50 domains at once' },
{ icon: Zap, text: 'Hourly availability checks', description: '24x faster than Scout' },
{ icon: Store, text: '10 For Sale listings', description: 'List your domains on the marketplace' },
{ icon: Bell, text: '5 Sniper Alerts', description: 'Get notified when specific domains drop' },
{ icon: BarChart3, text: 'Deal scores & valuations', description: 'Know what domains are worth' },
],
nextSteps: [
{ href: '/command/watchlist', label: 'Add domains to watchlist', icon: Eye },
{ href: '/command/alerts', label: 'Set up Sniper Alerts', icon: Bell },
{ href: '/command/portfolio', label: 'Track your portfolio', icon: BarChart3 },
],
},
tycoon: {
name: 'Tycoon',
icon: Crown,
color: 'text-amber-400',
bgColor: 'bg-amber-400/10',
features: [
{ icon: Eye, text: '500 domains in watchlist', description: 'Massive tracking capacity' },
{ icon: Zap, text: 'Real-time checks (10 min)', description: 'Never miss a drop' },
{ icon: Store, text: '50 For Sale listings', description: 'Full marketplace access' },
{ icon: Bell, text: 'Unlimited Sniper Alerts', description: 'Set as many as you need' },
{ icon: Sparkles, text: 'SEO Juice Detector', description: 'Find domains with backlinks' },
],
nextSteps: [
{ href: '/command/watchlist', label: 'Add domains to watchlist', icon: Eye },
{ href: '/command/seo', label: 'Analyze SEO metrics', icon: Sparkles },
{ href: '/command/alerts', label: 'Create Sniper Alerts', icon: Bell },
],
},
}
export default function WelcomePage() {
const router = useRouter()
const searchParams = useSearchParams()
const { fetchSubscription, checkAuth } = useStore()
const [loading, setLoading] = useState(true)
const [showConfetti, setShowConfetti] = useState(true)
const planId = searchParams.get('plan') as 'trader' | 'tycoon' | null
const plan = planId && planDetails[planId] ? planDetails[planId] : planDetails.trader
useEffect(() => {
const init = async () => {
await checkAuth()
await fetchSubscription()
setLoading(false)
}
init()
// Hide confetti after animation
const timer = setTimeout(() => setShowConfetti(false), 3000)
return () => clearTimeout(timer)
}, [checkAuth, fetchSubscription])
if (loading) {
return (
<CommandCenterLayout title="Welcome" subtitle="Loading your new plan...">
<PageContainer>
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
</PageContainer>
</CommandCenterLayout>
)
}
return (
<CommandCenterLayout title="Welcome" subtitle="Your upgrade is complete">
<PageContainer>
{/* Confetti Effect */}
{showConfetti && (
<div className="fixed inset-0 pointer-events-none z-50 overflow-hidden">
{Array.from({ length: 50 }).map((_, i) => (
<div
key={i}
className="absolute w-2 h-2 rounded-full animate-[confetti_3s_ease-out_forwards]"
style={{
left: `${Math.random() * 100}%`,
top: '-10px',
backgroundColor: ['#10b981', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6'][Math.floor(Math.random() * 5)],
animationDelay: `${Math.random() * 0.5}s`,
}}
/>
))}
</div>
)}
{/* Success Header */}
<div className="text-center mb-12">
<div className={clsx(
"inline-flex items-center justify-center w-20 h-20 rounded-full mb-6",
plan.bgColor
)}>
<CheckCircle className={clsx("w-10 h-10", plan.color)} />
</div>
<h1 className="text-3xl sm:text-4xl font-semibold text-foreground mb-3">
Welcome to {plan.name}!
</h1>
<p className="text-lg text-foreground-muted max-w-lg mx-auto">
Your payment was successful. You now have access to all {plan.name} features.
</p>
</div>
{/* Features Unlocked */}
<div className="mb-12">
<h2 className="text-lg font-semibold text-foreground mb-6 text-center">
Features Unlocked
</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{plan.features.map((feature, i) => (
<div
key={i}
className="p-5 bg-background-secondary/50 border border-border/50 rounded-xl
animate-slide-up"
style={{ animationDelay: `${i * 100}ms` }}
>
<div className="flex items-start gap-4">
<div className={clsx("w-10 h-10 rounded-xl flex items-center justify-center shrink-0", plan.bgColor)}>
<feature.icon className={clsx("w-5 h-5", plan.color)} />
</div>
<div>
<p className="font-medium text-foreground">{feature.text}</p>
<p className="text-sm text-foreground-muted mt-1">{feature.description}</p>
</div>
</div>
</div>
))}
</div>
</div>
{/* Next Steps */}
<div className="mb-12">
<h2 className="text-lg font-semibold text-foreground mb-6 text-center">
Get Started
</h2>
<div className="max-w-2xl mx-auto space-y-3">
{plan.nextSteps.map((step, i) => (
<Link
key={i}
href={step.href}
className="flex items-center justify-between p-5 bg-background-secondary/50 border border-border/50 rounded-xl
hover:border-accent/30 hover:bg-background-secondary transition-all group"
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-foreground/5 flex items-center justify-center
group-hover:bg-accent/10 transition-colors">
<step.icon className="w-5 h-5 text-foreground-muted group-hover:text-accent transition-colors" />
</div>
<span className="font-medium text-foreground">{step.label}</span>
</div>
<ArrowRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />
</Link>
))}
</div>
</div>
{/* Go to Dashboard */}
<div className="text-center">
<Link
href="/command/dashboard"
className="inline-flex items-center gap-2 px-8 py-4 bg-accent text-background font-medium rounded-xl
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
>
Go to Dashboard
<ArrowRight className="w-4 h-4" />
</Link>
<p className="text-sm text-foreground-muted mt-4">
Need help? Check out our <Link href="/docs" className="text-accent hover:underline">documentation</Link> or{' '}
<Link href="mailto:support@pounce.ch" className="text-accent hover:underline">contact support</Link>.
</p>
</div>
</PageContainer>
{/* Custom CSS for confetti animation */}
<style jsx>{`
@keyframes confetti {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
}
`}</style>
</CommandCenterLayout>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -27,7 +27,7 @@ export async function generateTLDMetadata(tld: string, price?: number, trend?: n
openGraph: { openGraph: {
title, title,
description, description,
url: `${siteUrl}/intel/${tld}`, url: `${siteUrl}/discover/${tld}`,
type: 'article', type: 'article',
images: [ images: [
{ {
@ -45,7 +45,7 @@ export async function generateTLDMetadata(tld: string, price?: number, trend?: n
images: [`${siteUrl}/api/og/tld?tld=${tld}&price=${price || 0}&trend=${trend || 0}`], images: [`${siteUrl}/api/og/tld?tld=${tld}&price=${price || 0}&trend=${trend || 0}`],
}, },
alternates: { alternates: {
canonical: `${siteUrl}/intel/${tld}`, canonical: `${siteUrl}/discover/${tld}`,
}, },
} }
} }
@ -80,7 +80,7 @@ export function generateTLDStructuredData(tld: string, price: number, trend: num
dateModified: new Date().toISOString(), dateModified: new Date().toISOString(),
mainEntityOfPage: { mainEntityOfPage: {
'@type': 'WebPage', '@type': 'WebPage',
'@id': `${siteUrl}/intel/${tld}`, '@id': `${siteUrl}/discover/${tld}`,
}, },
}, },
// Product (Domain TLD) // Product (Domain TLD)
@ -130,14 +130,14 @@ export function generateTLDStructuredData(tld: string, price: number, trend: num
{ {
'@type': 'ListItem', '@type': 'ListItem',
position: 2, position: 2,
name: 'Intel', name: 'Discover',
item: `${siteUrl}/intel`, item: `${siteUrl}/discover`,
}, },
{ {
'@type': 'ListItem', '@type': 'ListItem',
position: 3, position: 3,
name: `.${tldUpper}`, name: `.${tldUpper}`,
item: `${siteUrl}/intel/${tld}`, item: `${siteUrl}/discover/${tld}`,
}, },
], ],
}, },

View File

@ -25,6 +25,7 @@ import {
Shield, Shield,
Zap, Zap,
AlertTriangle, AlertTriangle,
Sparkles,
} from 'lucide-react' } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import clsx from 'clsx' import clsx from 'clsx'
@ -112,14 +113,14 @@ const RELATED_TLDS: Record<string, string[]> = {
type ChartPeriod = '1M' | '3M' | '1Y' | 'ALL' type ChartPeriod = '1M' | '3M' | '1Y' | 'ALL'
// Shimmer component for unauthenticated users // Shimmer component
function Shimmer({ className }: { className?: string }) { function Shimmer({ className }: { className?: string }) {
return ( return (
<div className={clsx( <div className={clsx(
"relative overflow-hidden rounded bg-foreground/5", "relative overflow-hidden rounded bg-white/5",
className className
)}> )}>
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-foreground/10 to-transparent" /> <div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/10 to-transparent" />
</div> </div>
) )
} }
@ -141,11 +142,11 @@ function PriceChart({
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
<div className="relative h-48 flex items-center justify-center"> <div className="relative h-48 flex items-center justify-center">
<div className="absolute inset-0 bg-gradient-to-t from-background-secondary/50 to-transparent rounded-xl" /> <div className="absolute inset-0 bg-gradient-to-t from-white/5 to-transparent rounded-xl" />
<Shimmer className="absolute inset-4 h-40" /> <Shimmer className="absolute inset-4 h-40" />
<div className="relative z-10 flex flex-col items-center gap-3"> <div className="relative z-10 flex flex-col items-center gap-3">
<Lock className="w-5 h-5 text-foreground-subtle" /> <Lock className="w-5 h-5 text-white/30" />
<span className="text-ui-sm text-foreground-muted">Sign in to view price history</span> <span className="text-xs text-white/50 uppercase tracking-wider">Sign in to view price history</span>
</div> </div>
</div> </div>
) )
@ -153,7 +154,7 @@ function PriceChart({
if (data.length === 0) { if (data.length === 0) {
return ( return (
<div className="h-48 flex items-center justify-center text-foreground-subtle"> <div className="h-48 flex items-center justify-center text-white/30">
No price history available No price history available
</div> </div>
) )
@ -226,6 +227,7 @@ function PriceChart({
strokeOpacity="0.05" strokeOpacity="0.05"
strokeWidth="0.2" strokeWidth="0.2"
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
className="text-white/20"
/> />
))} ))}
@ -268,7 +270,7 @@ function PriceChart({
{/* Hover dot */} {/* Hover dot */}
{hoveredIndex !== null && containerRef.current && ( {hoveredIndex !== null && containerRef.current && (
<div <div
className="absolute w-3 h-3 bg-accent rounded-full border-2 border-background shadow-lg pointer-events-none transform -translate-x-1/2 -translate-y-1/2 transition-all duration-75" className="absolute w-3 h-3 bg-accent rounded-full border-2 border-black shadow-lg pointer-events-none transform -translate-x-1/2 -translate-y-1/2 transition-all duration-75"
style={{ style={{
left: `${(points[hoveredIndex].x / 100) * containerRef.current.offsetWidth}px`, left: `${(points[hoveredIndex].x / 100) * containerRef.current.offsetWidth}px`,
top: `${(points[hoveredIndex].y / 100) * containerRef.current.offsetHeight}px` top: `${(points[hoveredIndex].y / 100) * containerRef.current.offsetHeight}px`
@ -279,13 +281,13 @@ function PriceChart({
{/* Tooltip */} {/* Tooltip */}
{hoveredIndex !== null && ( {hoveredIndex !== null && (
<div <div
className="absolute z-20 px-3 py-2 bg-background border border-border rounded-lg shadow-xl pointer-events-none transform -translate-x-1/2" className="absolute z-20 px-3 py-2 bg-black/90 border border-white/10 rounded-lg shadow-xl pointer-events-none transform -translate-x-1/2 backdrop-blur-md"
style={{ left: tooltipPos.x, top: Math.max(tooltipPos.y - 60, 10) }} style={{ left: tooltipPos.x, top: Math.max(tooltipPos.y - 60, 10) }}
> >
<p className="text-ui-sm font-medium text-foreground tabular-nums"> <p className="text-sm font-medium text-white tabular-nums">
${data[hoveredIndex].price.toFixed(2)} ${data[hoveredIndex].price.toFixed(2)}
</p> </p>
<p className="text-ui-xs text-foreground-subtle"> <p className="text-xs text-white/50">
{new Date(data[hoveredIndex].date).toLocaleDateString('en-US', { {new Date(data[hoveredIndex].date).toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
@ -316,7 +318,7 @@ function DomainResultCard({
return ( return (
<div className={clsx( <div className={clsx(
"mt-6 p-6 rounded-2xl border transition-all duration-500 animate-fade-in", "mt-6 p-6 rounded-2xl border transition-all duration-500 animate-fade-in backdrop-blur-md",
result.is_available result.is_available
? "bg-gradient-to-br from-accent/10 via-accent/5 to-transparent border-accent/30" ? "bg-gradient-to-br from-accent/10 via-accent/5 to-transparent border-accent/30"
: "bg-gradient-to-br from-orange-500/10 via-orange-500/5 to-transparent border-orange-500/30" : "bg-gradient-to-br from-orange-500/10 via-orange-500/5 to-transparent border-orange-500/30"
@ -325,8 +327,8 @@ function DomainResultCard({
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<div className={clsx( <div className={clsx(
"w-10 h-10 rounded-xl flex items-center justify-center", "w-10 h-10 rounded-xl flex items-center justify-center border",
result.is_available ? "bg-accent/20" : "bg-orange-500/20" result.is_available ? "bg-accent/20 border-accent/20" : "bg-orange-500/20 border-orange-500/20"
)}> )}>
{result.is_available ? ( {result.is_available ? (
<Check className="w-5 h-5 text-accent" /> <Check className="w-5 h-5 text-accent" />
@ -335,9 +337,9 @@ function DomainResultCard({
)} )}
</div> </div>
<div> <div>
<h3 className="font-mono text-lg text-foreground">{result.domain}</h3> <h3 className="font-mono text-lg text-white">{result.domain}</h3>
<p className={clsx( <p className={clsx(
"text-ui-sm", "text-sm font-medium",
result.is_available ? "text-accent" : "text-orange-400" result.is_available ? "text-accent" : "text-orange-400"
)}> )}>
{result.is_available ? 'Available for registration' : 'Already registered'} {result.is_available ? 'Available for registration' : 'Already registered'}
@ -349,7 +351,7 @@ function DomainResultCard({
<div className="flex flex-wrap items-center gap-4 mt-4"> <div className="flex flex-wrap items-center gap-4 mt-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-accent" /> <Zap className="w-4 h-4 text-accent" />
<span className="text-body-sm text-foreground"> <span className="text-sm text-white/80">
Register from <span className="font-medium text-accent">${cheapestPrice.toFixed(2)}</span>/yr Register from <span className="font-medium text-accent">${cheapestPrice.toFixed(2)}</span>/yr
</span> </span>
</div> </div>
@ -357,23 +359,23 @@ function DomainResultCard({
href={`${registrarUrl}${result.domain}`} href={`${registrarUrl}${result.domain}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-accent text-background text-ui-sm font-medium rounded-xl hover:bg-accent-hover transition-all" className="inline-flex items-center gap-2 px-5 py-2.5 bg-accent text-[#020202] text-sm font-bold rounded-lg hover:bg-accent-hover transition-all uppercase tracking-wide"
> >
Register at {cheapestRegistrar} Register at {cheapestRegistrar}
<ExternalLink className="w-4 h-4" /> <ExternalLink className="w-4 h-4" />
</a> </a>
</div> </div>
) : ( ) : (
<div className="flex flex-wrap items-center gap-4 mt-4 text-body-sm text-foreground-muted"> <div className="flex flex-wrap items-center gap-4 mt-4 text-sm text-white/50">
{result.registrar && ( {result.registrar && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-foreground-subtle" /> <Shield className="w-4 h-4 text-white/30" />
<span>Registrar: {result.registrar}</span> <span>Registrar: {result.registrar}</span>
</div> </div>
)} )}
{result.expiration_date && ( {result.expiration_date && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-foreground-subtle" /> <Clock className="w-4 h-4 text-white/30" />
<span>Expires: {new Date(result.expiration_date).toLocaleDateString()}</span> <span>Expires: {new Date(result.expiration_date).toLocaleDateString()}</span>
</div> </div>
)} )}
@ -383,7 +385,7 @@ function DomainResultCard({
<button <button
onClick={onClose} onClick={onClose}
className="p-2 text-foreground-subtle hover:text-foreground hover:bg-background-secondary rounded-lg transition-colors" className="p-2 text-white/30 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
@ -604,7 +606,7 @@ export default function TldDetailPage() {
if (loading || authLoading) { if (loading || authLoading) {
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-[#020202]">
<Header /> <Header />
<main className="pt-28 pb-16 px-4 sm:px-6"> <main className="pt-28 pb-16 px-4 sm:px-6">
<div className="max-w-5xl mx-auto space-y-8"> <div className="max-w-5xl mx-auto space-y-8">
@ -622,21 +624,21 @@ export default function TldDetailPage() {
if (error || !details) { if (error || !details) {
return ( return (
<div className="min-h-screen bg-background flex flex-col"> <div className="min-h-screen bg-[#020202] flex flex-col">
<Header /> <Header />
<main className="flex-1 flex items-center justify-center px-4"> <main className="flex-1 flex items-center justify-center px-4">
<div className="text-center"> <div className="text-center">
<div className="w-16 h-16 bg-background-secondary rounded-full flex items-center justify-center mx-auto mb-6"> <div className="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-6">
<X className="w-8 h-8 text-foreground-subtle" /> <X className="w-8 h-8 text-white/30" />
</div> </div>
<h1 className="text-heading-md text-foreground mb-2">TLD Not Found</h1> <h1 className="text-3xl font-bold text-white mb-2">TLD Not Found</h1>
<p className="text-body text-foreground-muted mb-8">{error || `The TLD .${tld} could not be found.`}</p> <p className="text-white/50 mb-8">{error || `The TLD .${tld} could not be found.`}</p>
<Link <Link
href="/intel" href="/discover"
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background rounded-xl font-medium hover:bg-accent-hover transition-all" className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-[#020202] rounded-xl font-bold hover:bg-accent-hover transition-all uppercase tracking-wide text-sm"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Back to Intel Back to Discover
</Link> </Link>
</div> </div>
</main> </main>
@ -646,24 +648,32 @@ export default function TldDetailPage() {
} }
return ( return (
<div className="min-h-screen bg-background relative flex flex-col"> <div className="min-h-screen bg-[#020202] text-white relative flex flex-col overflow-x-hidden selection:bg-accent/30 selection:text-white">
{/* Subtle ambient */} {/* Background Effects */}
<div className="fixed inset-0 pointer-events-none overflow-hidden"> <div className="fixed inset-0 pointer-events-none z-0">
<div className="absolute -top-40 -right-40 w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-3xl" /> <div className="absolute inset-0 bg-[url('/noise.png')] opacity-[0.04] mix-blend-overlay" />
<div
className="absolute inset-0 opacity-[0.03]"
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',
}}
/>
<div className="absolute top-[-30%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[180px]" />
</div> </div>
<Header /> <Header />
<main className="relative pt-24 sm:pt-28 pb-20 px-4 sm:px-6 flex-1"> <main className="relative z-10 pt-24 sm:pt-28 pb-20 px-4 sm:px-6 flex-1">
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
{/* Breadcrumb */} {/* Breadcrumb */}
<nav className="flex items-center gap-2 text-ui-sm mb-8"> <nav className="flex items-center gap-2 text-xs uppercase tracking-wider mb-8 font-medium">
<Link href="/intel" className="text-foreground-subtle hover:text-foreground transition-colors"> <Link href="/discover" className="text-white/40 hover:text-white transition-colors">
Intel Discover
</Link> </Link>
<ChevronRight className="w-3.5 h-3.5 text-foreground-subtle" /> <ChevronRight className="w-3.5 h-3.5 text-white/30" />
<span className="text-foreground font-medium">.{details.tld}</span> <span className="text-white">.{details.tld}</span>
</nav> </nav>
{/* Hero */} {/* Hero */}
@ -671,46 +681,46 @@ export default function TldDetailPage() {
{/* Left: TLD Info */} {/* Left: TLD Info */}
<div> <div>
<div className="flex items-start gap-4 mb-4"> <div className="flex items-start gap-4 mb-4">
<h1 className="font-mono text-[4rem] sm:text-[5rem] lg:text-[6rem] leading-[0.85] tracking-tight text-foreground"> <h1 className="font-mono text-[4rem] sm:text-[5rem] lg:text-[6rem] leading-[0.85] tracking-tight text-white">
.{details.tld} .{details.tld}
</h1> </h1>
<div className={clsx( <div className={clsx(
"mt-3 flex items-center gap-1.5 px-2.5 py-1 rounded-full text-ui-sm font-medium", "mt-3 flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium uppercase tracking-wider",
details.trend === 'up' ? "bg-orange-500/10 text-orange-400" : details.trend === 'up' ? "bg-orange-500/10 text-orange-400 border border-orange-500/20" :
details.trend === 'down' ? "bg-accent/10 text-accent" : details.trend === 'down' ? "bg-accent/10 text-accent border border-accent/20" :
"bg-foreground/5 text-foreground-muted" "bg-white/5 text-white/50 border border-white/10"
)}> )}>
{getTrendIcon(details.trend)} {getTrendIcon(details.trend)}
<span>{details.trend === 'up' ? 'Rising' : details.trend === 'down' ? 'Falling' : 'Stable'}</span> <span>{details.trend === 'up' ? 'Rising' : details.trend === 'down' ? 'Falling' : 'Stable'}</span>
</div> </div>
</div> </div>
<p className="text-body-lg text-foreground-muted mb-2">{details.description}</p> <p className="text-lg text-white/60 mb-2">{details.description}</p>
<p className="text-body-sm text-foreground-subtle">{details.trend_reason}</p> <p className="text-sm text-white/40">{details.trend_reason}</p>
{/* Quick Stats - All data from table */} {/* Quick Stats - All data from table */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-8"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-8">
<div <div
className="p-4 bg-background-secondary/50 border border-border/50 rounded-xl cursor-help" className="p-4 bg-white/[0.02] border border-white/[0.08] rounded-xl cursor-help backdrop-blur-sm"
title="Lowest first-year registration price across all tracked registrars" title="Lowest first-year registration price across all tracked registrars"
> >
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">Buy (1y)</p> <p className="text-[10px] text-white/40 uppercase tracking-wider mb-1">Buy (1y)</p>
{isAuthenticated ? ( {isAuthenticated ? (
<p className="text-body-lg font-medium text-foreground tabular-nums">${details.pricing.min.toFixed(2)}</p> <p className="text-xl font-medium text-white tabular-nums">${details.pricing.min.toFixed(2)}</p>
) : ( ) : (
<Shimmer className="h-6 w-16 mt-1" /> <Shimmer className="h-6 w-16 mt-1" />
)} )}
</div> </div>
<div <div
className="p-4 bg-background-secondary/50 border border-border/50 rounded-xl cursor-help" className="p-4 bg-white/[0.02] border border-white/[0.08] rounded-xl cursor-help backdrop-blur-sm"
title={renewalInfo?.isTrap title={renewalInfo?.isTrap
? `Warning: Renewal is ${renewalInfo.ratio.toFixed(1)}x the registration price` ? `Warning: Renewal is ${renewalInfo.ratio.toFixed(1)}x the registration price`
: 'Annual renewal price after first year'} : 'Annual renewal price after first year'}
> >
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">Renew (1y)</p> <p className="text-[10px] text-white/40 uppercase tracking-wider mb-1">Renew (1y)</p>
{isAuthenticated ? ( {isAuthenticated ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<p className="text-body-lg font-medium text-foreground tabular-nums"> <p className="text-xl font-medium text-white tabular-nums">
${details.min_renewal_price.toFixed(2)} ${details.min_renewal_price.toFixed(2)}
</p> </p>
{renewalInfo?.isTrap && ( {renewalInfo?.isTrap && (
@ -724,16 +734,16 @@ export default function TldDetailPage() {
)} )}
</div> </div>
<div <div
className="p-4 bg-background-secondary/50 border border-border/50 rounded-xl cursor-help" className="p-4 bg-white/[0.02] border border-white/[0.08] rounded-xl cursor-help backdrop-blur-sm"
title="Price change over the last 12 months" title="Price change over the last 12 months"
> >
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">1y Change</p> <p className="text-[10px] text-white/40 uppercase tracking-wider mb-1">1y Change</p>
{isAuthenticated ? ( {isAuthenticated ? (
<p className={clsx( <p className={clsx(
"text-body-lg font-medium tabular-nums", "text-xl font-medium tabular-nums",
details.price_change_1y > 0 ? "text-orange-400" : details.price_change_1y > 0 ? "text-orange-400" :
details.price_change_1y < 0 ? "text-accent" : details.price_change_1y < 0 ? "text-accent" :
"text-foreground" "text-white"
)}> )}>
{details.price_change_1y > 0 ? '+' : ''}{details.price_change_1y.toFixed(0)}% {details.price_change_1y > 0 ? '+' : ''}{details.price_change_1y.toFixed(0)}%
</p> </p>
@ -742,16 +752,16 @@ export default function TldDetailPage() {
)} )}
</div> </div>
<div <div
className="p-4 bg-background-secondary/50 border border-border/50 rounded-xl cursor-help" className="p-4 bg-white/[0.02] border border-white/[0.08] rounded-xl cursor-help backdrop-blur-sm"
title="Price change over the last 3 years" title="Price change over the last 3 years"
> >
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">3y Change</p> <p className="text-[10px] text-white/40 uppercase tracking-wider mb-1">3y Change</p>
{isAuthenticated ? ( {isAuthenticated ? (
<p className={clsx( <p className={clsx(
"text-body-lg font-medium tabular-nums", "text-xl font-medium tabular-nums",
details.price_change_3y > 0 ? "text-orange-400" : details.price_change_3y > 0 ? "text-orange-400" :
details.price_change_3y < 0 ? "text-accent" : details.price_change_3y < 0 ? "text-accent" :
"text-foreground" "text-white"
)}> )}>
{details.price_change_3y > 0 ? '+' : ''}{details.price_change_3y.toFixed(0)}% {details.price_change_3y > 0 ? '+' : ''}{details.price_change_3y.toFixed(0)}%
</p> </p>
@ -763,10 +773,10 @@ export default function TldDetailPage() {
{/* Risk Assessment */} {/* Risk Assessment */}
{isAuthenticated && ( {isAuthenticated && (
<div className="flex items-center gap-4 mt-4 p-4 bg-background-secondary/30 border border-border/50 rounded-xl"> <div className="flex items-center gap-4 mt-4 p-4 bg-white/[0.02] border border-white/[0.08] rounded-xl backdrop-blur-sm">
<Shield className="w-5 h-5 text-foreground-muted" /> <Shield className="w-5 h-5 text-white/30" />
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium text-foreground">Risk Assessment</p> <p className="text-sm font-medium text-white">Risk Assessment</p>
</div> </div>
{getRiskBadge()} {getRiskBadge()}
</div> </div>
@ -775,16 +785,16 @@ export default function TldDetailPage() {
{/* Right: Price Card */} {/* Right: Price Card */}
<div className="lg:sticky lg:top-28 h-fit"> <div className="lg:sticky lg:top-28 h-fit">
<div className="p-6 bg-background-secondary/80 backdrop-blur-sm border border-border rounded-2xl"> <div className="p-6 bg-[#0a0a0a]/80 backdrop-blur-md border border-white/[0.08] rounded-2xl shadow-2xl">
{isAuthenticated ? ( {isAuthenticated ? (
<> <>
<div className="flex items-baseline gap-1 mb-1"> <div className="flex items-baseline gap-1 mb-1">
<span className="text-[2.75rem] font-semibold text-foreground tracking-tight tabular-nums"> <span className="text-[2.75rem] font-bold text-white tracking-tight tabular-nums">
${details.pricing.min.toFixed(2)} ${details.pricing.min.toFixed(2)}
</span> </span>
<span className="text-body text-foreground-subtle">/yr</span> <span className="text-lg text-white/40">/yr</span>
</div> </div>
<p className="text-ui-sm text-foreground-subtle mb-6"> <p className="text-xs text-white/40 mb-6 uppercase tracking-wide">
Cheapest at {details.cheapest_registrar} Cheapest at {details.cheapest_registrar}
</p> </p>
@ -793,7 +803,7 @@ export default function TldDetailPage() {
href={getRegistrarUrl(details.cheapest_registrar, `example.${tld}`)} href={getRegistrarUrl(details.cheapest_registrar, `example.${tld}`)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full py-3.5 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all" className="flex items-center justify-center gap-2 w-full py-3.5 bg-accent text-[#020202] font-bold rounded-lg hover:bg-accent-hover transition-all uppercase tracking-wide text-sm"
> >
Register Domain Register Domain
<ExternalLink className="w-4 h-4" /> <ExternalLink className="w-4 h-4" />
@ -801,10 +811,10 @@ export default function TldDetailPage() {
</div> </div>
{savings && savings.amount > 0.5 && ( {savings && savings.amount > 0.5 && (
<div className="mt-5 pt-5 border-t border-border/50"> <div className="mt-5 pt-5 border-t border-white/[0.08]">
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
<Zap className="w-4 h-4 text-accent mt-0.5 shrink-0" /> <Zap className="w-4 h-4 text-accent mt-0.5 shrink-0" />
<p className="text-ui-sm text-foreground-muted leading-relaxed"> <p className="text-sm text-white/60 leading-relaxed">
Save <span className="text-accent font-medium">${savings.amount.toFixed(2)}</span>/yr vs {savings.expensiveName} Save <span className="text-accent font-medium">${savings.amount.toFixed(2)}</span>/yr vs {savings.expensiveName}
</p> </p>
</div> </div>
@ -817,7 +827,7 @@ export default function TldDetailPage() {
<Shimmer className="h-4 w-28 mb-6" /> <Shimmer className="h-4 w-28 mb-6" />
<Link <Link
href="/register" href="/register"
className="flex items-center justify-center gap-2 w-full py-3.5 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all" className="flex items-center justify-center gap-2 w-full py-3.5 bg-accent text-[#020202] font-bold rounded-lg hover:bg-accent-hover transition-all uppercase tracking-wide text-sm"
> >
<Lock className="w-4 h-4" /> <Lock className="w-4 h-4" />
Sign in to View Prices Sign in to View Prices
@ -834,7 +844,7 @@ export default function TldDetailPage() {
<AlertTriangle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" /> <AlertTriangle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
<div> <div>
<p className="text-sm font-medium text-amber-400">Renewal Trap Detected</p> <p className="text-sm font-medium text-amber-400">Renewal Trap Detected</p>
<p className="text-sm text-foreground-muted mt-1"> <p className="text-sm text-white/60 mt-1">
The renewal price (${renewalInfo.renewal.toFixed(2)}) is {renewalInfo.ratio.toFixed(1)}x higher than the registration price (${renewalInfo.registration.toFixed(2)}). The renewal price (${renewalInfo.renewal.toFixed(2)}) is {renewalInfo.ratio.toFixed(1)}x higher than the registration price (${renewalInfo.registration.toFixed(2)}).
Consider the total cost of ownership before registering. Consider the total cost of ownership before registering.
</p> </p>
@ -846,22 +856,22 @@ export default function TldDetailPage() {
<section className="mb-12"> <section className="mb-12">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="text-body-lg font-medium text-foreground">Price History</h2> <h2 className="text-lg font-medium text-white">Price History</h2>
{isAuthenticated && !hasPriceHistory && ( {isAuthenticated && !hasPriceHistory && (
<span className="text-ui-xs px-2 py-0.5 rounded-md bg-accent/10 text-accent">Pro</span> <span className="text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded bg-accent/10 text-accent border border-accent/20">Pro</span>
)} )}
</div> </div>
{hasPriceHistory && ( {hasPriceHistory && (
<div className="flex items-center gap-1 p-1 bg-background-secondary/50 border border-border/50 rounded-lg"> <div className="flex items-center gap-1 p-1 bg-white/[0.03] border border-white/[0.08] rounded-lg">
{(['1M', '3M', '1Y', 'ALL'] as ChartPeriod[]).map(period => ( {(['1M', '3M', '1Y', 'ALL'] as ChartPeriod[]).map(period => (
<button <button
key={period} key={period}
onClick={() => setChartPeriod(period)} onClick={() => setChartPeriod(period)}
className={clsx( className={clsx(
"px-3 py-1.5 text-ui-sm font-medium rounded-md transition-all", "px-3 py-1.5 text-xs font-medium rounded transition-all",
chartPeriod === period chartPeriod === period
? "bg-foreground text-background" ? "bg-white text-black"
: "text-foreground-muted hover:text-foreground" : "text-white/40 hover:text-white"
)} )}
> >
{period} {period}
@ -871,26 +881,26 @@ export default function TldDetailPage() {
)} )}
</div> </div>
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl"> <div className="p-6 bg-white/[0.02] border border-white/[0.08] rounded-2xl backdrop-blur-sm">
{!isAuthenticated ? ( {!isAuthenticated ? (
<div className="relative h-48 flex items-center justify-center"> <div className="relative h-48 flex items-center justify-center">
<div className="absolute inset-0 bg-gradient-to-t from-background-secondary/50 to-transparent rounded-xl" /> <div className="absolute inset-0 bg-gradient-to-t from-white/5 to-transparent rounded-xl" />
<div className="relative z-10 flex flex-col items-center gap-3"> <div className="relative z-10 flex flex-col items-center gap-3">
<Lock className="w-5 h-5 text-foreground-subtle" /> <Lock className="w-5 h-5 text-white/30" />
<span className="text-ui-sm text-foreground-muted">Sign in to view price history</span> <span className="text-xs text-white/50 uppercase tracking-wider">Sign in to view price history</span>
<Link href={`/login?redirect=/intel/${tld}`} className="text-ui-sm text-accent hover:text-accent-hover transition-colors"> <Link href={`/login?redirect=/discover/${tld}`} className="text-xs font-bold text-accent hover:text-accent-hover transition-colors uppercase tracking-wider">
Sign in Sign in
</Link> </Link>
</div> </div>
</div> </div>
) : !hasPriceHistory ? ( ) : !hasPriceHistory ? (
<div className="relative h-48 flex items-center justify-center"> <div className="relative h-48 flex items-center justify-center">
<div className="absolute inset-0 bg-gradient-to-t from-background-secondary/50 to-transparent rounded-xl" /> <div className="absolute inset-0 bg-gradient-to-t from-white/5 to-transparent rounded-xl" />
<div className="relative z-10 flex flex-col items-center gap-3"> <div className="relative z-10 flex flex-col items-center gap-3">
<Zap className="w-5 h-5 text-accent" /> <Zap className="w-5 h-5 text-accent" />
<span className="text-ui-sm text-foreground-muted">Price history requires Trader or Tycoon plan</span> <span className="text-xs text-white/50 uppercase tracking-wider">Price history requires Trader or Tycoon plan</span>
<Link href="/pricing" className="flex items-center gap-2 text-ui-sm px-4 py-2 bg-accent text-background rounded-lg hover:bg-accent-hover transition-all"> <Link href="/pricing" className="flex items-center gap-2 text-xs font-bold px-4 py-2 bg-accent text-[#020202] rounded hover:bg-accent-hover transition-all uppercase tracking-wider">
<Zap className="w-4 h-4" /> <Zap className="w-3 h-3" />
Upgrade to Unlock Upgrade to Unlock
</Link> </Link>
</div> </div>
@ -904,21 +914,21 @@ export default function TldDetailPage() {
/> />
{filteredHistory.length > 0 && ( {filteredHistory.length > 0 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border/30 text-ui-sm"> <div className="flex items-center justify-between mt-4 pt-4 border-t border-white/[0.08] text-xs">
<span className="text-foreground-subtle"> <span className="text-white/40 font-mono">
{new Date(filteredHistory[0]?.date).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} {new Date(filteredHistory[0]?.date).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</span> </span>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6 font-mono">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-foreground-subtle">High</span> <span className="text-white/40 uppercase tracking-wider">High</span>
<span className="text-foreground font-medium tabular-nums">${chartStats.high.toFixed(2)}</span> <span className="text-white font-medium">${chartStats.high.toFixed(2)}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-foreground-subtle">Low</span> <span className="text-white/40 uppercase tracking-wider">Low</span>
<span className="text-accent font-medium tabular-nums">${chartStats.low.toFixed(2)}</span> <span className="text-accent font-medium">${chartStats.low.toFixed(2)}</span>
</div> </div>
</div> </div>
<span className="text-foreground-subtle">Today</span> <span className="text-white/40 uppercase tracking-wider">Today</span>
</div> </div>
)} )}
</> </>
@ -928,10 +938,10 @@ export default function TldDetailPage() {
{/* Domain Search */} {/* Domain Search */}
<section className="mb-12"> <section className="mb-12">
<h2 className="text-body-lg font-medium text-foreground mb-4"> <h2 className="text-lg font-medium text-white mb-4">
Check .{details.tld} Availability Check .{details.tld} Availability
</h2> </h2>
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl"> <div className="p-6 bg-white/[0.02] border border-white/[0.08] rounded-2xl backdrop-blur-sm">
<div className="flex gap-3"> <div className="flex gap-3">
<div className="flex-1 relative"> <div className="flex-1 relative">
<input <input
@ -940,16 +950,16 @@ export default function TldDetailPage() {
onChange={(e) => setDomainSearch(e.target.value)} onChange={(e) => setDomainSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleDomainCheck()} onKeyDown={(e) => e.key === 'Enter' && handleDomainCheck()}
placeholder="Enter domain name" placeholder="Enter domain name"
className="w-full px-4 py-3.5 pr-20 bg-background border border-border rounded-xl text-body text-foreground placeholder:text-foreground-subtle focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all" className="w-full px-4 py-3.5 pr-20 bg-[#0a0a0a] border border-white/10 rounded-xl text-white placeholder:text-white/30 focus:outline-none focus:ring-1 focus:ring-accent/50 focus:border-accent/50 transition-all"
/> />
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-foreground-subtle font-mono text-body-sm"> <span className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 font-mono text-sm">
.{tld} .{tld}
</span> </span>
</div> </div>
<button <button
onClick={handleDomainCheck} onClick={handleDomainCheck}
disabled={checkingDomain || !domainSearch.trim()} disabled={checkingDomain || !domainSearch.trim()}
className="px-6 py-3.5 bg-foreground text-background font-medium rounded-xl hover:bg-foreground/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center gap-2" className="px-6 py-3.5 bg-white text-[#020202] font-bold rounded-xl hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center gap-2 uppercase tracking-wide text-sm"
> >
{checkingDomain ? ( {checkingDomain ? (
<RefreshCw className="w-4 h-4 animate-spin" /> <RefreshCw className="w-4 h-4 animate-spin" />
@ -974,76 +984,76 @@ export default function TldDetailPage() {
{/* Registrar Comparison */} {/* Registrar Comparison */}
<section className="mb-12"> <section className="mb-12">
<h2 className="text-body-lg font-medium text-foreground mb-4">Compare Registrars</h2> <h2 className="text-lg font-medium text-white mb-4">Compare Registrars</h2>
{isAuthenticated ? ( {isAuthenticated ? (
<div className="bg-background-secondary/30 border border-border/50 rounded-2xl overflow-hidden"> <div className="bg-white/[0.02] border border-white/[0.08] rounded-2xl overflow-hidden backdrop-blur-sm">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-border/50"> <tr className="border-b border-white/[0.08]">
<th className="text-left text-ui-xs text-foreground-subtle font-medium uppercase tracking-wider px-5 py-4"> <th className="text-left text-[10px] text-white/40 font-bold uppercase tracking-widest px-6 py-4">
Registrar Registrar
</th> </th>
<th className="text-right text-ui-xs text-foreground-subtle font-medium uppercase tracking-wider px-5 py-4 cursor-help" title="First year registration price"> <th className="text-right text-[10px] text-white/40 font-bold uppercase tracking-widest px-6 py-4 cursor-help" title="First year registration price">
Register Register
</th> </th>
<th className="text-right text-ui-xs text-foreground-subtle font-medium uppercase tracking-wider px-5 py-4 hidden sm:table-cell cursor-help" title="Annual renewal price"> <th className="text-right text-[10px] text-white/40 font-bold uppercase tracking-widest px-6 py-4 hidden sm:table-cell cursor-help" title="Annual renewal price">
Renew Renew
</th> </th>
<th className="text-right text-ui-xs text-foreground-subtle font-medium uppercase tracking-wider px-5 py-4 hidden sm:table-cell cursor-help" title="Transfer from another registrar"> <th className="text-right text-[10px] text-white/40 font-bold uppercase tracking-widest px-6 py-4 hidden sm:table-cell cursor-help" title="Transfer from another registrar">
Transfer Transfer
</th> </th>
<th className="px-5 py-4 w-24"></th> <th className="px-6 py-4 w-24"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border/30"> <tbody className="divide-y divide-white/[0.04]">
{details.registrars.map((registrar, i) => { {details.registrars.map((registrar, i) => {
const hasRenewalTrap = registrar.renewal_price / registrar.registration_price > 1.5 const hasRenewalTrap = registrar.renewal_price / registrar.registration_price > 1.5
const isBestValue = i === 0 && !hasRenewalTrap const isBestValue = i === 0 && !hasRenewalTrap
return ( return (
<tr key={registrar.name} className={clsx( <tr key={registrar.name} className={clsx(
"transition-colors group", "transition-colors group hover:bg-white/[0.02]",
isBestValue && "bg-accent/[0.03]" isBestValue && "bg-accent/[0.03]"
)}> )}>
<td className="px-5 py-4"> <td className="px-6 py-4">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<span className="text-body-sm font-medium text-foreground">{registrar.name}</span> <span className="text-sm font-medium text-white">{registrar.name}</span>
{isBestValue && ( {isBestValue && (
<span <span
className="text-ui-xs text-accent bg-accent/10 px-2 py-0.5 rounded-full font-medium cursor-help" className="text-[10px] text-accent bg-accent/10 px-2 py-0.5 rounded font-bold uppercase tracking-wider cursor-help border border-accent/20"
title="Best overall value: lowest registration price without renewal trap" title="Best overall value: lowest registration price without renewal trap"
> >
Best Best Value
</span> </span>
)} )}
{i === 0 && hasRenewalTrap && ( {i === 0 && hasRenewalTrap && (
<span <span
className="text-ui-xs text-amber-400 bg-amber-500/10 px-2 py-0.5 rounded-full font-medium cursor-help" className="text-[10px] text-amber-400 bg-amber-500/10 px-2 py-0.5 rounded font-bold uppercase tracking-wider cursor-help border border-amber-500/20"
title="Cheapest registration but high renewal costs" title="Cheapest registration but high renewal costs"
> >
Cheap Start Promo
</span> </span>
)} )}
</div> </div>
</td> </td>
<td className="px-5 py-4 text-right"> <td className="px-6 py-4 text-right">
<span <span
className={clsx( className={clsx(
"text-body-sm font-medium tabular-nums cursor-help", "text-sm font-medium tabular-nums cursor-help font-mono",
isBestValue ? "text-accent" : "text-foreground" isBestValue ? "text-accent" : "text-white/90"
)} )}
title={`First year: $${registrar.registration_price.toFixed(2)}`} title={`First year: $${registrar.registration_price.toFixed(2)}`}
> >
${registrar.registration_price.toFixed(2)} ${registrar.registration_price.toFixed(2)}
</span> </span>
</td> </td>
<td className="px-5 py-4 text-right hidden sm:table-cell"> <td className="px-6 py-4 text-right hidden sm:table-cell">
<span <span
className={clsx( className={clsx(
"text-body-sm tabular-nums cursor-help", "text-sm tabular-nums cursor-help font-mono",
hasRenewalTrap ? "text-amber-400" : "text-foreground-muted" hasRenewalTrap ? "text-amber-400" : "text-white/50"
)} )}
title={hasRenewalTrap title={hasRenewalTrap
? `Renewal is ${(registrar.renewal_price / registrar.registration_price).toFixed(1)}x the registration price` ? `Renewal is ${(registrar.renewal_price / registrar.registration_price).toFixed(1)}x the registration price`
@ -1057,24 +1067,24 @@ export default function TldDetailPage() {
</span> </span>
)} )}
</td> </td>
<td className="px-5 py-4 text-right hidden sm:table-cell"> <td className="px-6 py-4 text-right hidden sm:table-cell">
<span <span
className="text-body-sm text-foreground-muted tabular-nums cursor-help" className="text-sm text-white/50 tabular-nums cursor-help font-mono"
title={`Transfer from another registrar: $${registrar.transfer_price.toFixed(2)}`} title={`Transfer from another registrar: $${registrar.transfer_price.toFixed(2)}`}
> >
${registrar.transfer_price.toFixed(2)} ${registrar.transfer_price.toFixed(2)}
</span> </span>
</td> </td>
<td className="px-5 py-4"> <td className="px-6 py-4">
<a <a
href={getRegistrarUrl(registrar.name, `example.${tld}`)} href={getRegistrarUrl(registrar.name, `example.${tld}`)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1.5 text-ui-sm text-foreground-muted hover:text-accent transition-colors opacity-0 group-hover:opacity-100" className="flex items-center gap-1.5 text-xs font-bold text-white/40 hover:text-accent transition-colors opacity-0 group-hover:opacity-100 uppercase tracking-wider"
title={`Register at ${registrar.name}`} title={`Register at ${registrar.name}`}
> >
Visit Visit
<ExternalLink className="w-3.5 h-3.5" /> <ExternalLink className="w-3 h-3" />
</a> </a>
</td> </td>
</tr> </tr>
@ -1085,7 +1095,7 @@ export default function TldDetailPage() {
</div> </div>
</div> </div>
) : ( ) : (
<div className="relative p-8 bg-background-secondary/30 border border-border/50 rounded-2xl"> <div className="relative p-8 bg-white/[0.02] border border-white/[0.08] rounded-2xl backdrop-blur-sm">
<div className="space-y-3"> <div className="space-y-3">
{[1, 2, 3, 4].map(i => ( {[1, 2, 3, 4].map(i => (
<div key={i} className="flex items-center justify-between"> <div key={i} className="flex items-center justify-between">
@ -1096,13 +1106,13 @@ export default function TldDetailPage() {
</div> </div>
))} ))}
</div> </div>
<div className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-sm rounded-2xl"> <div className="absolute inset-0 flex items-center justify-center bg-[#0a0a0a]/60 backdrop-blur-sm rounded-2xl">
<div className="text-center"> <div className="text-center">
<Lock className="w-6 h-6 text-foreground-subtle mx-auto mb-2" /> <Lock className="w-6 h-6 text-white/30 mx-auto mb-2" />
<p className="text-body-sm text-foreground-muted mb-3">Sign in to compare registrar prices</p> <p className="text-sm text-white/50 mb-4">Sign in to compare registrar prices</p>
<Link <Link
href="/register" href="/register"
className="inline-flex items-center gap-2 px-4 py-2 bg-accent text-background text-ui-sm font-medium rounded-lg hover:bg-accent-hover transition-all" className="inline-flex items-center gap-2 px-5 py-2.5 bg-accent text-[#020202] text-sm font-bold rounded-lg hover:bg-accent-hover transition-all uppercase tracking-wide"
> >
Join the Hunt Join the Hunt
</Link> </Link>
@ -1114,22 +1124,22 @@ export default function TldDetailPage() {
{/* TLD Info */} {/* TLD Info */}
<section className="mb-12"> <section className="mb-12">
<h2 className="text-body-lg font-medium text-foreground mb-4">About .{details.tld}</h2> <h2 className="text-lg font-medium text-white mb-4">About .{details.tld}</h2>
<div className="grid sm:grid-cols-3 gap-4"> <div className="grid sm:grid-cols-3 gap-4">
<div className="p-5 bg-background-secondary/30 border border-border/50 rounded-xl"> <div className="p-5 bg-white/[0.02] border border-white/[0.08] rounded-xl backdrop-blur-sm">
<Building className="w-5 h-5 text-foreground-subtle mb-3" /> <Building className="w-5 h-5 text-white/30 mb-3" />
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">Registry</p> <p className="text-[10px] text-white/40 uppercase tracking-wider mb-1">Registry</p>
<p className="text-body font-medium text-foreground">{details.registry}</p> <p className="text-base font-medium text-white">{details.registry}</p>
</div> </div>
<div className="p-5 bg-background-secondary/30 border border-border/50 rounded-xl"> <div className="p-5 bg-white/[0.02] border border-white/[0.08] rounded-xl backdrop-blur-sm">
<Calendar className="w-5 h-5 text-foreground-subtle mb-3" /> <Calendar className="w-5 h-5 text-white/30 mb-3" />
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">Introduced</p> <p className="text-[10px] text-white/40 uppercase tracking-wider mb-1">Introduced</p>
<p className="text-body font-medium text-foreground">{details.introduced || 'Unknown'}</p> <p className="text-base font-medium text-white">{details.introduced || 'Unknown'}</p>
</div> </div>
<div className="p-5 bg-background-secondary/30 border border-border/50 rounded-xl"> <div className="p-5 bg-white/[0.02] border border-white/[0.08] rounded-xl backdrop-blur-sm">
<Globe className="w-5 h-5 text-foreground-subtle mb-3" /> <Globe className="w-5 h-5 text-white/30 mb-3" />
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">Type</p> <p className="text-[10px] text-white/40 uppercase tracking-wider mb-1">Type</p>
<p className="text-body font-medium text-foreground capitalize">{details.type}</p> <p className="text-base font-medium text-white capitalize">{details.type}</p>
</div> </div>
</div> </div>
</section> </section>
@ -1137,19 +1147,19 @@ export default function TldDetailPage() {
{/* Related TLDs */} {/* Related TLDs */}
{relatedTlds.length > 0 && ( {relatedTlds.length > 0 && (
<section className="mb-12"> <section className="mb-12">
<h2 className="text-body-lg font-medium text-foreground mb-4">Similar Extensions</h2> <h2 className="text-lg font-medium text-white mb-4">Similar Extensions</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{relatedTlds.map(related => ( {relatedTlds.map(related => (
<Link <Link
key={related.tld} key={related.tld}
href={`/intel/${related.tld}`} href={`/discover/${related.tld}`}
className="group p-5 bg-background-secondary/30 border border-border/50 rounded-xl hover:border-accent/30 hover:bg-background-secondary/50 transition-all" className="group p-5 bg-white/[0.02] border border-white/[0.08] rounded-xl hover:border-accent/30 hover:bg-white/[0.04] transition-all backdrop-blur-sm"
> >
<p className="font-mono text-body-lg text-foreground group-hover:text-accent transition-colors mb-1"> <p className="font-mono text-lg text-white group-hover:text-accent transition-colors mb-1">
.{related.tld} .{related.tld}
</p> </p>
{isAuthenticated ? ( {isAuthenticated ? (
<p className="text-ui-sm text-foreground-subtle tabular-nums"> <p className="text-xs text-white/50 tabular-nums">
from ${related.price.toFixed(2)}/yr from ${related.price.toFixed(2)}/yr
</p> </p>
) : ( ) : (
@ -1162,16 +1172,16 @@ export default function TldDetailPage() {
)} )}
{/* CTA */} {/* CTA */}
<section className="p-8 sm:p-10 bg-gradient-to-br from-accent/10 via-accent/5 to-transparent border border-accent/20 rounded-2xl text-center"> <section className="p-8 sm:p-10 bg-gradient-to-br from-accent/10 via-accent/5 to-transparent border border-accent/20 rounded-2xl text-center backdrop-blur-sm">
<h3 className="text-heading-sm font-medium text-foreground mb-2"> <h3 className="text-xl font-bold text-white mb-2">
Track .{details.tld} Domains Track .{details.tld} Domains
</h3> </h3>
<p className="text-body text-foreground-muted mb-8 max-w-lg mx-auto"> <p className="text-white/60 mb-8 max-w-lg mx-auto">
Monitor specific domains and get instant notifications when they become available. Monitor specific domains and get instant notifications when they become available.
</p> </p>
<Link <Link
href={isAuthenticated ? '/terminal' : '/register'} href={isAuthenticated ? '/terminal' : '/register'}
className="inline-flex items-center gap-2 px-8 py-4 bg-foreground text-background text-ui font-medium rounded-xl hover:bg-foreground/90 transition-all" className="inline-flex items-center gap-2 px-8 py-4 bg-white text-[#020202] font-bold rounded-xl hover:bg-white/90 transition-all uppercase tracking-wide text-sm"
> >
{isAuthenticated ? 'Go to Command Center' : 'Start Monitoring Free'} {isAuthenticated ? 'Go to Command Center' : 'Start Monitoring Free'}
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
@ -1185,3 +1195,4 @@ export default function TldDetailPage() {
</div> </div>
) )
} }

View File

@ -15,6 +15,7 @@ import {
Globe, Globe,
AlertTriangle, AlertTriangle,
ArrowUpDown, ArrowUpDown,
Sparkles,
} from 'lucide-react' } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import clsx from 'clsx' import clsx from 'clsx'
@ -52,7 +53,7 @@ interface PaginationData {
has_more: boolean has_more: boolean
} }
// TLDs that are shown completely to non-authenticated users (gemäß pounce_public.md) // TLDs that are shown completely to non-authenticated users
const PUBLIC_PREVIEW_TLDS = ['com', 'net', 'org'] const PUBLIC_PREVIEW_TLDS = ['com', 'net', 'org']
// Sparkline component // Sparkline component
@ -64,7 +65,7 @@ function Sparkline({ trend }: { trend: number }) {
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<svg width="40" height="16" viewBox="0 0 40 16" className="overflow-visible"> <svg width="40" height="16" viewBox="0 0 40 16" className="overflow-visible">
{isNeutral ? ( {isNeutral ? (
<line x1="0" y1="8" x2="40" y2="8" stroke="currentColor" className="text-foreground-muted" strokeWidth="1.5" /> <line x1="0" y1="8" x2="40" y2="8" stroke="currentColor" className="text-white/30" strokeWidth="1.5" />
) : isPositive ? ( ) : isPositive ? (
<polyline <polyline
points="0,14 10,12 20,10 30,6 40,2" points="0,14 10,12 20,10 30,6 40,2"
@ -91,7 +92,19 @@ function Sparkline({ trend }: { trend: number }) {
) )
} }
export default function IntelPage() { // Shimmer component
function Shimmer({ className }: { className?: string }) {
return (
<div className={clsx(
"relative overflow-hidden rounded bg-white/5",
className
)}>
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/10 to-transparent" />
</div>
)
}
export default function DiscoverPage() {
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore() const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
const [tlds, setTlds] = useState<TldData[]>([]) const [tlds, setTlds] = useState<TldData[]>([])
const [trending, setTrending] = useState<TrendingTld[]>([]) const [trending, setTrending] = useState<TrendingTld[]>([])
@ -153,8 +166,6 @@ export default function IntelPage() {
} }
} }
// Check if TLD should show full data for non-authenticated users
// Gemäß pounce_public.md: .com, .net, .org are fully visible
const isPublicPreviewTld = (tld: TldData) => { const isPublicPreviewTld = (tld: TldData) => {
return PUBLIC_PREVIEW_TLDS.includes(tld.tld.toLowerCase()) return PUBLIC_PREVIEW_TLDS.includes(tld.tld.toLowerCase())
} }
@ -198,85 +209,92 @@ export default function IntelPage() {
if (authLoading) { if (authLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center bg-[#020202]">
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" /> <div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div> </div>
) )
} }
return ( return (
<div className="min-h-screen bg-background relative overflow-hidden"> <div className="min-h-screen bg-[#020202] text-white relative overflow-x-hidden selection:bg-accent/30 selection:text-white">
{/* Background Effects */} {/* Cinematic Background - Matches Landing Page */}
<div className="fixed inset-0 pointer-events-none"> <div className="fixed inset-0 pointer-events-none z-0">
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" /> {/* Fine Noise */}
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" /> <div className="absolute inset-0 bg-[url('/noise.png')] opacity-[0.04] mix-blend-overlay" />
{/* Architectural Grid - Ultra fine */}
<div <div
className="absolute inset-0 opacity-[0.015]" className="absolute inset-0 opacity-[0.03]"
style={{ style={{
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`, 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: '64px 64px', backgroundSize: '160px 160px',
}} }}
/> />
{/* Ambient Light - Very Subtle */}
<div className="absolute top-[-30%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[180px]" />
</div> </div>
<Header /> <Header />
<main className="relative pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6 flex-1"> <main className="relative z-10 pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6 flex-1">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
{/* Header - gemäß pounce_public.md: "TLD Market Inflation Monitor" */} {/* Header */}
<div className="text-center mb-12 sm:mb-16 animate-fade-in"> <div className="text-center mb-12 sm:mb-16 animate-fade-in">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 text-accent text-sm mb-6"> <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/5 border border-accent/20 text-accent text-sm mb-6">
<TrendingUp className="w-4 h-4" /> <Sparkles className="w-4 h-4" />
<span>Real-time Market Data</span> <span>Domain Intelligence</span>
</div> </div>
<h1 className="font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] leading-[0.95] tracking-[-0.03em] text-foreground"> <h1 className="font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] leading-[0.95] tracking-[-0.03em] text-white">
TLD Market Discover
<span className="block text-accent">Inflation Monitor</span> <span className="block text-accent">Market Opportunities</span>
</h1> </h1>
<p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto"> <p className="mt-5 text-lg sm:text-xl text-white/50 max-w-2xl mx-auto font-light">
Don&apos;t fall for promo prices. See renewal costs, spot traps, and track price trends across {pagination.total}+ extensions. Don&apos;t fall for promo prices. See renewal costs, spot traps, and track price trends across {pagination.total}+ extensions.
</p> </p>
{/* Top Movers Cards */} {/* Feature Pills */}
<div className="flex flex-wrap items-center justify-center gap-3 mt-8"> <div className="flex flex-wrap items-center justify-center gap-3 mt-8">
<div className="flex items-center gap-2 px-4 py-2 bg-foreground/5 rounded-full text-sm"> <div className="flex items-center gap-2 px-4 py-2 bg-white/5 border border-white/5 rounded-full text-sm backdrop-blur-sm">
<AlertTriangle className="w-4 h-4 text-amber-400" /> <AlertTriangle className="w-4 h-4 text-amber-400" />
<span className="text-foreground-muted">Renewal Trap Detection</span> <span className="text-white/60">Renewal Trap Detection</span>
</div> </div>
<div className="flex items-center gap-2 px-4 py-2 bg-foreground/5 rounded-full text-sm"> <div className="flex items-center gap-2 px-4 py-2 bg-white/5 border border-white/5 rounded-full text-sm backdrop-blur-sm">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-accent" /> <span className="w-1.5 h-1.5 rounded-full bg-accent" />
<span className="w-2 h-2 rounded-full bg-amber-400" /> <span className="w-1.5 h-1.5 rounded-full bg-amber-400" />
<span className="w-2 h-2 rounded-full bg-red-400" /> <span className="w-1.5 h-1.5 rounded-full bg-red-400" />
</div> </div>
<span className="text-foreground-muted">Risk Levels</span> <span className="text-white/60">Risk Levels</span>
</div> </div>
<div className="flex items-center gap-2 px-4 py-2 bg-foreground/5 rounded-full text-sm"> <div className="flex items-center gap-2 px-4 py-2 bg-white/5 border border-white/5 rounded-full text-sm backdrop-blur-sm">
<TrendingUp className="w-4 h-4 text-orange-400" /> <TrendingUp className="w-4 h-4 text-orange-400" />
<span className="text-foreground-muted">1y/3y Trends</span> <span className="text-white/60">1y/3y Trends</span>
</div> </div>
</div> </div>
</div> </div>
{/* Login Banner for non-authenticated users */} {/* Login Banner for non-authenticated users */}
{!isAuthenticated && ( {!isAuthenticated && (
<div className="mb-8 p-6 bg-gradient-to-r from-accent/10 to-accent/5 border border-accent/20 rounded-2xl animate-fade-in"> <div className="mb-8 p-6 bg-gradient-to-r from-accent/10 to-accent/5 border border-accent/20 rounded-2xl animate-fade-in relative overflow-hidden group">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4"> <div className="absolute inset-0 bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="relative flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-12 h-12 bg-accent/20 rounded-xl flex items-center justify-center"> <div className="w-12 h-12 bg-accent/20 rounded-xl flex items-center justify-center border border-accent/20">
<Lock className="w-6 h-6 text-accent" /> <Lock className="w-6 h-6 text-accent" />
</div> </div>
<div> <div>
<p className="font-medium text-foreground">Stop overpaying. Know the true costs.</p> <p className="font-medium text-white text-lg">Stop overpaying. Know the true costs.</p>
<p className="text-sm text-foreground-muted"> <p className="text-sm text-white/50">
Unlock renewal prices and risk analysis for all {pagination.total}+ TLDs. Unlock renewal prices and risk analysis for all {pagination.total}+ TLDs.
</p> </p>
</div> </div>
</div> </div>
<Link <Link
href="/register" href="/register"
className="shrink-0 px-6 py-3 bg-accent text-background font-medium rounded-xl className="shrink-0 px-6 py-3 bg-accent text-[#020202] font-bold rounded-none clip-path-slant-sm
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20" hover:bg-accent-hover transition-all shadow-[0_0_20px_rgba(16,185,129,0.3)] uppercase tracking-wide text-sm"
style={{ clipPath: 'polygon(10px 0, 100% 0, 100% 100%, 0 100%, 0 10px)' }}
> >
Start Hunting Start Hunting
</Link> </Link>
@ -287,7 +305,7 @@ export default function IntelPage() {
{/* Trending Section - Top Movers */} {/* Trending Section - Top Movers */}
{trending.length > 0 && ( {trending.length > 0 && (
<div className="mb-12 sm:mb-16 animate-slide-up"> <div className="mb-12 sm:mb-16 animate-slide-up">
<h2 className="text-body-lg sm:text-heading-sm font-medium text-foreground mb-4 sm:mb-6 flex items-center gap-2"> <h2 className="text-lg font-medium text-white mb-6 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-accent" /> <TrendingUp className="w-5 h-5 text-accent" />
Top Movers Top Movers
</h2> </h2>
@ -295,28 +313,28 @@ export default function IntelPage() {
{trending.map((item) => ( {trending.map((item) => (
<Link <Link
key={item.tld} key={item.tld}
href={isAuthenticated ? `/intel/${item.tld}` : '/register'} href={`/discover/${item.tld}`}
className="p-4 sm:p-5 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover hover:bg-background-secondary transition-all duration-300 text-left group" className="p-5 bg-white/[0.02] border border-white/[0.08] rounded-xl hover:border-accent/30 hover:bg-white/[0.04] transition-all duration-300 text-left group backdrop-blur-sm"
> >
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="font-mono text-body-lg sm:text-heading-sm text-foreground">.{item.tld}</span> <span className="font-mono text-xl text-white group-hover:text-accent transition-colors">.{item.tld}</span>
<span className={clsx( <span className={clsx(
"text-ui-sm font-medium px-2 py-0.5 rounded-full", "text-xs font-medium px-2 py-0.5 rounded-full border",
item.price_change > 0 item.price_change > 0
? "text-[#f97316] bg-[#f9731615]" ? "text-orange-400 bg-orange-400/10 border-orange-400/20"
: "text-accent bg-accent-muted" : "text-accent bg-accent/10 border-accent/20"
)}> )}>
{item.price_change > 0 ? '+' : ''}{item.price_change.toFixed(1)}% {item.price_change > 0 ? '+' : ''}{item.price_change.toFixed(1)}%
</span> </span>
</div> </div>
<p className="text-body-sm text-foreground-muted mb-2 line-clamp-2"> <p className="text-sm text-white/50 mb-3 line-clamp-2 min-h-[2.5em]">
{item.reason} {item.reason}
</p> </p>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between pt-3 border-t border-white/5">
<span className="text-body-sm text-foreground-subtle"> <span className="text-xs text-white/40 uppercase tracking-wider">Current Price</span>
<span className="text-sm text-white/70 font-mono">
${item.current_price.toFixed(2)}/yr ${item.current_price.toFixed(2)}/yr
</span> </span>
<ChevronRight className="w-4 h-4 text-foreground-subtle group-hover:text-accent transition-colors" />
</div> </div>
</Link> </Link>
))} ))}
@ -327,7 +345,7 @@ export default function IntelPage() {
{/* Search & Sort Controls */} {/* Search & Sort Controls */}
<div className="flex flex-col sm:flex-row gap-4 mb-6 animate-slide-up"> <div className="flex flex-col sm:flex-row gap-4 mb-6 animate-slide-up">
<div className="relative flex-1 max-w-md"> <div className="relative flex-1 max-w-md">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" /> <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/30" />
<input <input
type="text" type="text"
placeholder="Search TLDs (e.g., com, io, ai)..." placeholder="Search TLDs (e.g., com, io, ai)..."
@ -336,9 +354,9 @@ export default function IntelPage() {
setSearchQuery(e.target.value) setSearchQuery(e.target.value)
setPage(0) setPage(0)
}} }}
className="w-full pl-12 pr-10 py-3 bg-background-secondary/50 border border-border rounded-xl className="w-full pl-12 pr-10 py-3.5 bg-white/[0.03] border border-white/[0.08] rounded-xl
text-body text-foreground placeholder:text-foreground-subtle text-white placeholder:text-white/30
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/50 focus:border-accent/50
transition-all duration-300" transition-all duration-300"
/> />
{searchQuery && ( {searchQuery && (
@ -347,7 +365,7 @@ export default function IntelPage() {
setSearchQuery('') setSearchQuery('')
setPage(0) setPage(0)
}} }}
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 text-foreground-subtle hover:text-foreground transition-colors" className="absolute right-4 top-1/2 -translate-y-1/2 p-1 text-white/30 hover:text-white transition-colors"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
@ -361,34 +379,28 @@ export default function IntelPage() {
setSortBy(e.target.value) setSortBy(e.target.value)
setPage(0) setPage(0)
}} }}
className="appearance-none pl-4 pr-10 py-3 bg-background-secondary/50 border border-border rounded-xl className="appearance-none pl-4 pr-10 py-3.5 bg-white/[0.03] border border-white/[0.08] rounded-xl
text-body text-foreground focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent text-white focus:outline-none focus:ring-1 focus:ring-accent/50 focus:border-accent/50
transition-all cursor-pointer min-w-[180px]" transition-all cursor-pointer min-w-[180px]"
> >
<option value="popularity">Most Popular</option> <option value="popularity" className="bg-[#0a0a0a]">Most Popular</option>
<option value="name">Alphabetical</option> <option value="name" className="bg-[#0a0a0a]">Alphabetical</option>
<option value="price_asc">Price: Low High</option> <option value="price_asc" className="bg-[#0a0a0a]">Price: Low High</option>
<option value="price_desc">Price: High Low</option> <option value="price_desc" className="bg-[#0a0a0a]">Price: High Low</option>
</select> </select>
<ArrowUpDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted pointer-events-none" /> <ArrowUpDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30 pointer-events-none" />
</div> </div>
</div> </div>
{/* TLD Table - gemäß pounce_public.md: {/* TLD Table */}
- .com, .net, .org: vollständig sichtbar
- Alle anderen: Buy Price + Trend sichtbar, Renewal + Risk geblurrt */}
<PremiumTable <PremiumTable
data={tlds} data={tlds}
keyExtractor={(tld) => tld.tld} keyExtractor={(tld) => tld.tld}
loading={loading} loading={loading}
onRowClick={(tld) => { onRowClick={(tld) => {
if (isAuthenticated) { window.location.href = `/discover/${tld.tld}`
window.location.href = `/intel/${tld.tld}`
} else {
window.location.href = `/login?redirect=/intel/${tld.tld}`
}
}} }}
emptyIcon={<Globe className="w-12 h-12 text-foreground-subtle" />} emptyIcon={<Globe className="w-12 h-12 text-white/20" />}
emptyTitle="No TLDs found" emptyTitle="No TLDs found"
emptyDescription={searchQuery ? `No TLDs matching "${searchQuery}"` : "Check back later for TLD data"} emptyDescription={searchQuery ? `No TLDs matching "${searchQuery}"` : "Check back later for TLD data"}
columns={[ columns={[
@ -398,7 +410,7 @@ export default function IntelPage() {
width: '100px', width: '100px',
render: (tld) => ( render: (tld) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono text-lg font-semibold text-foreground group-hover:text-accent transition-colors"> <span className="font-mono text-lg font-semibold text-white group-hover:text-accent transition-colors">
.{tld.tld} .{tld.tld}
</span> </span>
</div> </div>
@ -409,9 +421,8 @@ export default function IntelPage() {
header: 'Current Price', header: 'Current Price',
align: 'right', align: 'right',
width: '120px', width: '120px',
// Buy price is visible for all TLDs (gemäß pounce_public.md)
render: (tld) => ( render: (tld) => (
<span className="font-semibold text-foreground tabular-nums"> <span className="font-semibold text-white/90 tabular-nums">
${tld.min_registration_price.toFixed(2)} ${tld.min_registration_price.toFixed(2)}
</span> </span>
), ),
@ -421,7 +432,6 @@ export default function IntelPage() {
header: 'Trend (1y)', header: 'Trend (1y)',
width: '100px', width: '100px',
hideOnMobile: true, hideOnMobile: true,
// Trend is visible for all TLDs
render: (tld) => { render: (tld) => {
const change = tld.price_change_1y || 0 const change = tld.price_change_1y || 0
return ( return (
@ -429,7 +439,7 @@ export default function IntelPage() {
<Sparkline trend={change} /> <Sparkline trend={change} />
<span className={clsx( <span className={clsx(
"font-medium tabular-nums text-sm", "font-medium tabular-nums text-sm",
change > 0 ? "text-orange-400" : change < 0 ? "text-accent" : "text-foreground-muted" change > 0 ? "text-orange-400" : change < 0 ? "text-accent" : "text-white/30"
)}> )}>
{change > 0 ? '+' : ''}{change.toFixed(0)}% {change > 0 ? '+' : ''}{change.toFixed(0)}%
</span> </span>
@ -442,21 +452,19 @@ export default function IntelPage() {
header: 'Renewal Price', header: 'Renewal Price',
align: 'right', align: 'right',
width: '130px', width: '130px',
// Renewal price: visible for .com/.net/.org OR authenticated users
// Geblurrt/Locked für alle anderen
render: (tld) => { render: (tld) => {
const showData = isAuthenticated || isPublicPreviewTld(tld) const showData = isAuthenticated || isPublicPreviewTld(tld)
if (!showData) { if (!showData) {
return ( return (
<div className="flex items-center gap-1 justify-end"> <div className="flex items-center gap-1 justify-end group/lock">
<span className="text-foreground-muted blur-[3px] select-none">$XX.XX</span> <span className="text-white/30 blur-[4px] select-none group-hover/lock:blur-none transition-all duration-300">$XX.XX</span>
<Lock className="w-3 h-3 text-foreground-subtle" /> <Lock className="w-3 h-3 text-white/20" />
</div> </div>
) )
} }
return ( return (
<div className="flex items-center gap-1 justify-end"> <div className="flex items-center gap-1 justify-end">
<span className="text-foreground-muted tabular-nums"> <span className="text-white/50 tabular-nums">
${tld.min_renewal_price?.toFixed(2) || '—'} ${tld.min_renewal_price?.toFixed(2) || '—'}
</span> </span>
{getRenewalTrap(tld)} {getRenewalTrap(tld)}
@ -469,18 +477,16 @@ export default function IntelPage() {
header: 'Risk Level', header: 'Risk Level',
align: 'center', align: 'center',
width: '140px', width: '140px',
// Risk: visible for .com/.net/.org OR authenticated users
// Geblurrt/Locked für alle anderen
render: (tld) => { render: (tld) => {
const showData = isAuthenticated || isPublicPreviewTld(tld) const showData = isAuthenticated || isPublicPreviewTld(tld)
if (!showData) { if (!showData) {
return ( return (
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-foreground/5 blur-[3px] select-none"> <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-white/5 blur-[4px] select-none">
<span className="w-2.5 h-2.5 rounded-full bg-foreground-subtle" /> <span className="w-2.5 h-2.5 rounded-full bg-white/20" />
<span className="hidden sm:inline ml-1">Hidden</span> <span className="hidden sm:inline ml-1 text-white/20">Hidden</span>
</span> </span>
<Lock className="w-3 h-3 text-foreground-subtle" /> <Lock className="w-3 h-3 text-white/20" />
</div> </div>
) )
} }
@ -493,7 +499,7 @@ export default function IntelPage() {
align: 'right', align: 'right',
width: '80px', width: '80px',
render: () => ( render: () => (
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" /> <ChevronRight className="w-5 h-5 text-white/20 group-hover:text-accent transition-colors" />
), ),
}, },
]} ]}
@ -501,24 +507,24 @@ export default function IntelPage() {
{/* Pagination */} {/* Pagination */}
{!loading && pagination.total > pagination.limit && ( {!loading && pagination.total > pagination.limit && (
<div className="flex items-center justify-center gap-4 pt-2"> <div className="flex items-center justify-center gap-4 pt-6">
<button <button
onClick={() => setPage(Math.max(0, page - 1))} onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0} disabled={page === 0}
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground className="px-4 py-2 text-sm font-medium text-white/50 hover:text-white
bg-foreground/5 hover:bg-foreground/10 rounded-lg bg-white/5 hover:bg-white/10 rounded-lg border border-white/5
disabled:opacity-50 disabled:cursor-not-allowed transition-colors" disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
Previous Previous
</button> </button>
<span className="text-sm text-foreground-muted tabular-nums"> <span className="text-sm text-white/50 tabular-nums font-mono">
Page {currentPage} of {totalPages} Page {currentPage} of {totalPages}
</span> </span>
<button <button
onClick={() => setPage(page + 1)} onClick={() => setPage(page + 1)}
disabled={!pagination.has_more} disabled={!pagination.has_more}
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground className="px-4 py-2 text-sm font-medium text-white/50 hover:text-white
bg-foreground/5 hover:bg-foreground/10 rounded-lg bg-white/5 hover:bg-white/10 rounded-lg border border-white/5
disabled:opacity-50 disabled:cursor-not-allowed transition-colors" disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
Next Next
@ -529,10 +535,10 @@ export default function IntelPage() {
{/* Stats */} {/* Stats */}
{!loading && ( {!loading && (
<div className="mt-6 flex justify-center"> <div className="mt-6 flex justify-center">
<p className="text-ui-sm text-foreground-subtle"> <p className="text-xs text-white/30 uppercase tracking-widest font-medium">
{searchQuery {searchQuery
? `Found ${pagination.total} TLDs matching "${searchQuery}"` ? `Found ${pagination.total} TLDs matching "${searchQuery}"`
: `${pagination.total} TLDs tracked` : `${pagination.total} TLDs tracked in real-time`
} }
</p> </p>
</div> </div>

View File

@ -0,0 +1,26 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
/**
* Redirect /intelligence to /tld-pricing
* This page is kept for backwards compatibility
*/
export default function IntelligenceRedirect() {
const router = useRouter()
useEffect(() => {
router.replace('/tld-pricing')
}, [router])
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-foreground-muted">Redirecting to TLD Pricing...</p>
</div>
</div>
)
}

View File

@ -150,7 +150,7 @@ export default function MarketPage() {
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [selectedPlatform, setSelectedPlatform] = useState('All') const [selectedPlatform, setSelectedPlatform] = useState('All')
const [maxBid, setMaxBid] = useState('') const [maxBid, setMaxBid] = useState('')
useEffect(() => { useEffect(() => {
checkAuth() checkAuth()
loadAuctions() loadAuctions()
@ -268,7 +268,7 @@ export default function MarketPage() {
</div> </div>
) )
} }
return ( return (
<div className="min-h-screen bg-background relative overflow-hidden"> <div className="min-h-screen bg-background relative overflow-hidden">
{/* Background Effects */} {/* Background Effects */}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,743 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import { useStore } from '@/lib/store'
import { api, PriceAlert } from '@/lib/api'
import {
User,
Bell,
CreditCard,
Shield,
ChevronRight,
Loader2,
Check,
AlertCircle,
Trash2,
ExternalLink,
Crown,
Zap,
Key,
TrendingUp,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
type SettingsTab = 'profile' | 'notifications' | 'billing' | 'security'
export default function SettingsPage() {
const router = useRouter()
const { user, isAuthenticated, isLoading, checkAuth, subscription } = useStore()
const [activeTab, setActiveTab] = useState<SettingsTab>('profile')
const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
// Profile form
const [profileForm, setProfileForm] = useState({
name: '',
email: '',
})
// Notification preferences (local state - would be persisted via API in production)
const [notificationPrefs, setNotificationPrefs] = useState({
domain_availability: true,
price_alerts: true,
weekly_digest: false,
})
const [savingNotifications, setSavingNotifications] = useState(false)
// Price alerts
const [priceAlerts, setPriceAlerts] = useState<PriceAlert[]>([])
const [loadingAlerts, setLoadingAlerts] = useState(false)
const [deletingAlertId, setDeletingAlertId] = useState<number | null>(null)
useEffect(() => {
checkAuth()
}, [checkAuth])
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login')
}
}, [isLoading, isAuthenticated, router])
useEffect(() => {
if (user) {
setProfileForm({
name: user.name || '',
email: user.email || '',
})
}
}, [user])
useEffect(() => {
if (isAuthenticated && activeTab === 'notifications') {
loadPriceAlerts()
}
}, [isAuthenticated, activeTab])
const loadPriceAlerts = async () => {
setLoadingAlerts(true)
try {
const alerts = await api.getPriceAlerts()
setPriceAlerts(alerts)
} catch (err) {
console.error('Failed to load alerts:', err)
} finally {
setLoadingAlerts(false)
}
}
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setError(null)
setSuccess(null)
try {
await api.updateMe({ name: profileForm.name || undefined })
// Update store with new user info
const { checkAuth } = useStore.getState()
await checkAuth()
setSuccess('Profile updated successfully')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update profile')
} finally {
setSaving(false)
}
}
const handleSaveNotifications = async () => {
setSavingNotifications(true)
setError(null)
setSuccess(null)
try {
// Store in localStorage for now (would be API in production)
localStorage.setItem('notification_prefs', JSON.stringify(notificationPrefs))
setSuccess('Notification preferences saved')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save preferences')
} finally {
setSavingNotifications(false)
}
}
// Load notification preferences from localStorage
useEffect(() => {
const saved = localStorage.getItem('notification_prefs')
if (saved) {
try {
setNotificationPrefs(JSON.parse(saved))
} catch {}
}
}, [])
const handleDeletePriceAlert = async (tld: string, alertId: number) => {
setDeletingAlertId(alertId)
try {
await api.deletePriceAlert(tld)
setPriceAlerts(prev => prev.filter(a => a.id !== alertId))
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete alert')
} finally {
setDeletingAlertId(null)
}
}
const handleOpenBillingPortal = async () => {
try {
const { portal_url } = await api.createPortalSession()
window.location.href = portal_url
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to open billing portal')
}
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (!isAuthenticated || !user) {
return null
}
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
const isProOrHigher = ['Trader', 'Tycoon', 'Professional', 'Enterprise'].includes(tierName)
const tabs = [
{ id: 'profile' as const, label: 'Profile', icon: User },
{ id: 'notifications' as const, label: 'Notifications', icon: Bell },
{ id: 'billing' as const, label: 'Billing', icon: CreditCard },
{ id: 'security' as const, label: 'Security', icon: Shield },
]
return (
<div className="min-h-screen bg-background relative overflow-hidden">
{/* Background Effects - matching landing page */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
<div
className="absolute inset-0 opacity-[0.015]"
style={{
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
backgroundSize: '64px 64px',
}}
/>
</div>
<Header />
<main className="relative flex-1 pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-12 sm:mb-16 animate-fade-in">
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Settings</span>
<h1 className="mt-4 font-display text-[2rem] sm:text-[2.75rem] md:text-[3.5rem] leading-[1.1] tracking-[-0.03em] text-foreground">
Your account.
</h1>
<p className="mt-3 text-lg text-foreground-muted">
Your rules. Configure everything in one place.
</p>
</div>
{/* Messages */}
{error && (
<div className="mb-6 p-4 bg-danger/5 border border-danger/20 rounded-2xl flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-danger shrink-0" />
<p className="text-body-sm text-danger flex-1">{error}</p>
<button onClick={() => setError(null)} className="text-danger hover:text-danger/80">
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
{success && (
<div className="mb-6 p-4 bg-accent/5 border border-accent/20 rounded-2xl flex items-center gap-3">
<Check className="w-5 h-5 text-accent shrink-0" />
<p className="text-body-sm text-accent flex-1">{success}</p>
<button onClick={() => setSuccess(null)} className="text-accent hover:text-accent/80">
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
<div className="flex flex-col lg:flex-row gap-8 animate-slide-up">
{/* Sidebar - Horizontal scroll on mobile, vertical on desktop */}
<div className="lg:w-72 shrink-0">
{/* Mobile: Horizontal scroll tabs */}
<nav className="lg:hidden flex gap-2 overflow-x-auto pb-2 -mx-4 px-4 scrollbar-hide">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsx(
"flex items-center gap-2.5 px-5 py-3 text-sm font-medium rounded-xl whitespace-nowrap transition-all duration-300",
activeTab === tab.id
? "bg-accent text-background shadow-lg shadow-accent/20"
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground border border-border hover:border-accent/30"
)}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</nav>
{/* Desktop: Vertical tabs */}
<nav className="hidden lg:block p-2 bg-background-secondary/50 border border-border rounded-2xl">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsx(
"w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium rounded-xl transition-all duration-300",
activeTab === tab.id
? "bg-accent text-background shadow-lg shadow-accent/20"
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
)}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</nav>
{/* Plan info - hidden on mobile, shown in content area instead */}
<div className="hidden lg:block mt-5 p-6 bg-accent/5 border border-accent/20 rounded-2xl">
<div className="flex items-center gap-2 mb-3">
{isProOrHigher ? <Crown className="w-5 h-5 text-accent" /> : <Zap className="w-5 h-5 text-accent" />}
<span className="text-sm font-semibold text-foreground">{tierName} Plan</span>
</div>
<p className="text-xs text-foreground-muted mb-4">
{subscription?.domains_used || 0} / {subscription?.domain_limit || 5} domains tracked
</p>
{!isProOrHigher && (
<Link
href="/pricing"
className="flex items-center justify-center gap-2 w-full py-2.5 bg-accent text-background text-ui-sm font-medium rounded-xl hover:bg-accent-hover transition-all"
>
Upgrade
<ChevronRight className="w-3.5 h-3.5" />
</Link>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Profile Tab */}
{activeTab === 'profile' && (
<div className="p-6 sm:p-8 bg-background-secondary/30 border border-border rounded-2xl">
<h2 className="text-body-lg font-medium text-foreground mb-6">Profile Information</h2>
<form onSubmit={handleSaveProfile} className="space-y-5">
<div>
<label className="block text-ui-sm text-foreground-muted mb-2">Name</label>
<input
type="text"
value={profileForm.name}
onChange={(e) => setProfileForm({ ...profileForm, name: e.target.value })}
placeholder="Your name"
className="w-full px-4 py-3.5 bg-background border border-border rounded-xl text-body text-foreground
placeholder:text-foreground-subtle focus:outline-none focus:ring-1 focus:ring-accent/50 focus:border-accent/50 transition-all"
/>
</div>
<div>
<label className="block text-ui-sm text-foreground-muted mb-2">Email</label>
<input
type="email"
value={profileForm.email}
disabled
className="w-full px-4 py-3.5 bg-background-tertiary border border-border rounded-xl text-body text-foreground-muted cursor-not-allowed"
/>
<p className="text-ui-xs text-foreground-subtle mt-1.5">Email cannot be changed</p>
</div>
<button
type="submit"
disabled={saving}
className="px-6 py-3.5 bg-foreground text-background text-ui font-medium rounded-xl
hover:bg-foreground/90 disabled:opacity-50 transition-all flex items-center gap-2 shadow-lg shadow-foreground/10"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Save Changes
</button>
</form>
</div>
)}
{/* Notifications Tab */}
{activeTab === 'notifications' && (
<div className="space-y-6">
<div className="p-6 sm:p-8 bg-background-secondary/30 border border-border rounded-2xl">
<h2 className="text-body-lg font-medium text-foreground mb-5">Email Preferences</h2>
<div className="space-y-3">
<label className="flex items-center justify-between p-4 bg-background border border-border rounded-xl cursor-pointer hover:border-foreground/20 transition-colors">
<div>
<p className="text-body-sm font-medium text-foreground">Domain Availability</p>
<p className="text-body-xs text-foreground-muted">Get notified when watched domains become available</p>
</div>
<input
type="checkbox"
checked={notificationPrefs.domain_availability}
onChange={(e) => setNotificationPrefs({ ...notificationPrefs, domain_availability: e.target.checked })}
className="w-5 h-5 accent-accent cursor-pointer"
/>
</label>
<label className="flex items-center justify-between p-4 bg-background border border-border rounded-xl cursor-pointer hover:border-foreground/20 transition-colors">
<div>
<p className="text-body-sm font-medium text-foreground">Price Alerts</p>
<p className="text-body-xs text-foreground-muted">Get notified when TLD prices change</p>
</div>
<input
type="checkbox"
checked={notificationPrefs.price_alerts}
onChange={(e) => setNotificationPrefs({ ...notificationPrefs, price_alerts: e.target.checked })}
className="w-5 h-5 accent-accent cursor-pointer"
/>
</label>
<label className="flex items-center justify-between p-4 bg-background border border-border rounded-xl cursor-pointer hover:border-foreground/20 transition-colors">
<div>
<p className="text-body-sm font-medium text-foreground">Weekly Digest</p>
<p className="text-body-xs text-foreground-muted">Receive a weekly summary of your portfolio</p>
</div>
<input
type="checkbox"
checked={notificationPrefs.weekly_digest}
onChange={(e) => setNotificationPrefs({ ...notificationPrefs, weekly_digest: e.target.checked })}
className="w-5 h-5 accent-accent cursor-pointer"
/>
</label>
</div>
<button
onClick={handleSaveNotifications}
disabled={savingNotifications}
className="mt-5 px-6 py-3 bg-foreground text-background text-ui font-medium rounded-xl
hover:bg-foreground/90 disabled:opacity-50 transition-all flex items-center gap-2"
>
{savingNotifications ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Save Preferences
</button>
</div>
{/* Active Price Alerts */}
<div className="p-6 sm:p-8 bg-background-secondary/30 border border-border rounded-2xl">
<h2 className="text-body-lg font-medium text-foreground mb-5">Active Price Alerts</h2>
{loadingAlerts ? (
<div className="py-10 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-accent" />
</div>
) : priceAlerts.length === 0 ? (
<div className="py-12 text-center border border-dashed border-border/50 rounded-xl bg-background/30">
<Bell className="w-10 h-10 text-foreground-subtle mx-auto mb-4" />
<p className="text-body text-foreground-muted mb-3">No price alerts set</p>
<Link
href="/tld-pricing"
className="text-accent hover:text-accent-hover text-body-sm font-medium"
>
Browse TLD prices
</Link>
</div>
) : (
<div className="space-y-2">
{priceAlerts.map((alert) => (
<div
key={alert.id}
className="flex items-center justify-between p-4 bg-background border border-border rounded-xl hover:border-foreground/20 transition-colors"
>
<div className="flex items-center gap-3">
<div className="relative">
<div className={clsx(
"w-2.5 h-2.5 rounded-full",
alert.is_active ? "bg-accent" : "bg-foreground-subtle"
)} />
{alert.is_active && (
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-40" />
)}
</div>
<div>
<Link
href={`/tld-pricing/${alert.tld}`}
className="text-body-sm font-mono font-medium text-foreground hover:text-accent transition-colors"
>
.{alert.tld}
</Link>
<p className="text-body-xs text-foreground-muted">
Alert on {alert.threshold_percent}% change
{alert.target_price && ` or below $${alert.target_price}`}
</p>
</div>
</div>
<button
onClick={() => handleDeletePriceAlert(alert.tld, alert.id)}
disabled={deletingAlertId === alert.id}
className="p-2 text-foreground-subtle hover:text-danger hover:bg-danger/10 rounded-lg transition-all"
>
{deletingAlertId === alert.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Billing Tab */}
{activeTab === 'billing' && (
<div className="space-y-6">
{/* Current Plan */}
<div className="p-6 sm:p-8 bg-background-secondary/30 border border-border rounded-2xl">
<h2 className="text-body-lg font-medium text-foreground mb-6">Your Current Plan</h2>
<div className="p-5 bg-accent/5 border border-accent/20 rounded-xl mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
{tierName === 'Tycoon' ? (
<Crown className="w-6 h-6 text-accent" />
) : tierName === 'Trader' ? (
<TrendingUp className="w-6 h-6 text-accent" />
) : (
<Zap className="w-6 h-6 text-accent" />
)}
<div>
<p className="text-xl font-semibold text-foreground">{tierName}</p>
<p className="text-body-sm text-foreground-muted">
{tierName === 'Scout' ? 'Free forever' : tierName === 'Trader' ? '$9/month' : '$29/month'}
</p>
</div>
</div>
<span className={clsx(
"px-3 py-1.5 text-ui-xs font-medium rounded-full",
isProOrHigher ? "bg-accent/10 text-accent" : "bg-foreground/5 text-foreground-muted"
)}>
{isProOrHigher ? 'Active' : 'Free'}
</span>
</div>
{/* Plan Stats */}
<div className="grid grid-cols-3 gap-4 p-4 bg-background/50 rounded-xl mb-4">
<div className="text-center">
<p className="text-2xl font-semibold text-foreground">{subscription?.domain_limit || 5}</p>
<p className="text-xs text-foreground-muted">Domains</p>
</div>
<div className="text-center border-x border-border/50">
<p className="text-2xl font-semibold text-foreground">
{subscription?.check_frequency === 'realtime' ? '10m' :
subscription?.check_frequency === 'hourly' ? '1h' : '24h'}
</p>
<p className="text-xs text-foreground-muted">Check Interval</p>
</div>
<div className="text-center">
<p className="text-2xl font-semibold text-foreground">
{subscription?.portfolio_limit === -1 ? '∞' : subscription?.portfolio_limit || 0}
</p>
<p className="text-xs text-foreground-muted">Portfolio</p>
</div>
</div>
{isProOrHigher ? (
<button
onClick={handleOpenBillingPortal}
className="w-full py-3 bg-background text-foreground text-ui font-medium rounded-xl border border-border
hover:border-foreground/20 transition-all flex items-center justify-center gap-2"
>
<ExternalLink className="w-4 h-4" />
Manage Subscription
</button>
) : (
<Link
href="/pricing"
className="w-full py-3 bg-accent text-background text-ui font-medium rounded-xl
hover:bg-accent-hover transition-all flex items-center justify-center gap-2 shadow-lg shadow-accent/20"
>
<Zap className="w-4 h-4" />
Upgrade Plan
</Link>
)}
</div>
{/* Plan Features */}
<h3 className="text-body-sm font-medium text-foreground mb-3">Your Plan Includes</h3>
<ul className="grid grid-cols-2 gap-2">
<li className="flex items-center gap-2 text-body-sm">
<Check className="w-4 h-4 text-accent" />
<span className="text-foreground">{subscription?.domain_limit || 5} Watchlist Domains</span>
</li>
<li className="flex items-center gap-2 text-body-sm">
<Check className="w-4 h-4 text-accent" />
<span className="text-foreground">
{subscription?.check_frequency === 'realtime' ? '10-minute' :
subscription?.check_frequency === 'hourly' ? 'Hourly' : 'Daily'} Scans
</span>
</li>
<li className="flex items-center gap-2 text-body-sm">
<Check className="w-4 h-4 text-accent" />
<span className="text-foreground">Email Alerts</span>
</li>
<li className="flex items-center gap-2 text-body-sm">
<Check className="w-4 h-4 text-accent" />
<span className="text-foreground">TLD Price Data</span>
</li>
{subscription?.features?.domain_valuation && (
<li className="flex items-center gap-2 text-body-sm">
<Check className="w-4 h-4 text-accent" />
<span className="text-foreground">Domain Valuation</span>
</li>
)}
{(subscription?.portfolio_limit ?? 0) !== 0 && (
<li className="flex items-center gap-2 text-body-sm">
<Check className="w-4 h-4 text-accent" />
<span className="text-foreground">
{subscription?.portfolio_limit === -1 ? 'Unlimited' : subscription?.portfolio_limit} Portfolio
</span>
</li>
)}
{subscription?.features?.expiration_tracking && (
<li className="flex items-center gap-2 text-body-sm">
<Check className="w-4 h-4 text-accent" />
<span className="text-foreground">Expiry Tracking</span>
</li>
)}
{(subscription?.history_days ?? 0) !== 0 && (
<li className="flex items-center gap-2 text-body-sm">
<Check className="w-4 h-4 text-accent" />
<span className="text-foreground">
{subscription?.history_days === -1 ? 'Full' : `${subscription?.history_days}-day`} History
</span>
</li>
)}
</ul>
</div>
{/* Compare All Plans */}
<div className="p-6 sm:p-8 bg-background-secondary/30 border border-border rounded-2xl">
<h2 className="text-body-lg font-medium text-foreground mb-6">Compare All Plans</h2>
<div className="overflow-x-auto -mx-2">
<table className="w-full min-w-[500px]">
<thead>
<tr className="border-b border-border">
<th className="text-left py-3 px-3 text-body-sm font-medium text-foreground-muted">Feature</th>
<th className={clsx(
"text-center py-3 px-3 text-body-sm font-medium",
tierName === 'Scout' ? "text-accent" : "text-foreground-muted"
)}>Scout</th>
<th className={clsx(
"text-center py-3 px-3 text-body-sm font-medium",
tierName === 'Trader' ? "text-accent" : "text-foreground-muted"
)}>Trader</th>
<th className={clsx(
"text-center py-3 px-3 text-body-sm font-medium",
tierName === 'Tycoon' ? "text-accent" : "text-foreground-muted"
)}>Tycoon</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-border/50">
<td className="py-3 px-3 text-body-sm text-foreground">Price</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">Free</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">$9/mo</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">$29/mo</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 px-3 text-body-sm text-foreground">Watchlist Domains</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">5</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">50</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">500</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 px-3 text-body-sm text-foreground">Scan Frequency</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">Daily</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">Hourly</td>
<td className="py-3 px-3 text-center text-body-sm text-accent font-medium">10 min</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 px-3 text-body-sm text-foreground">Portfolio</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground-muted"></td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">25</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">Unlimited</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 px-3 text-body-sm text-foreground">Domain Valuation</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground-muted"></td>
<td className="py-3 px-3 text-center"><Check className="w-5 h-5 text-accent mx-auto" /></td>
<td className="py-3 px-3 text-center"><Check className="w-5 h-5 text-accent mx-auto" /></td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 px-3 text-body-sm text-foreground">Price History</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground-muted"></td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">90 days</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">Unlimited</td>
</tr>
<tr>
<td className="py-3 px-3 text-body-sm text-foreground">Expiry Tracking</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground-muted"></td>
<td className="py-3 px-3 text-center"><Check className="w-5 h-5 text-accent mx-auto" /></td>
<td className="py-3 px-3 text-center"><Check className="w-5 h-5 text-accent mx-auto" /></td>
</tr>
</tbody>
</table>
</div>
{!isProOrHigher && (
<div className="mt-6 text-center">
<Link
href="/pricing"
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background text-ui font-medium rounded-xl
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
>
<Zap className="w-4 h-4" />
Upgrade Now
</Link>
</div>
)}
</div>
</div>
)}
{/* Security Tab */}
{activeTab === 'security' && (
<div className="space-y-6">
<div className="p-6 sm:p-8 bg-background-secondary/30 border border-border rounded-2xl">
<h2 className="text-body-lg font-medium text-foreground mb-4">Password</h2>
<p className="text-body-sm text-foreground-muted mb-5">
Change your password or reset it if you've forgotten it.
</p>
<Link
href="/forgot-password"
className="inline-flex items-center gap-2 px-5 py-3 bg-background border border-border text-foreground text-ui font-medium rounded-xl
hover:border-foreground/20 transition-all"
>
<Key className="w-4 h-4" />
Change Password
</Link>
</div>
<div className="p-6 sm:p-8 bg-background-secondary/30 border border-border rounded-2xl">
<h2 className="text-body-lg font-medium text-foreground mb-5">Account Security</h2>
<div className="space-y-3">
<div className="flex items-center justify-between p-4 bg-background border border-border rounded-xl">
<div>
<p className="text-body-sm font-medium text-foreground">Email Verified</p>
<p className="text-body-xs text-foreground-muted">Your email address has been verified</p>
</div>
<div className="w-8 h-8 bg-accent/10 rounded-lg flex items-center justify-center">
<Check className="w-4 h-4 text-accent" />
</div>
</div>
<div className="flex items-center justify-between p-4 bg-background border border-border rounded-xl">
<div>
<p className="text-body-sm font-medium text-foreground">Two-Factor Authentication</p>
<p className="text-body-xs text-foreground-muted">Coming soon</p>
</div>
<span className="text-ui-xs px-2.5 py-1 bg-foreground/5 text-foreground-muted rounded-full">Soon</span>
</div>
</div>
</div>
<div className="p-6 sm:p-8 bg-danger/5 border border-danger/20 rounded-2xl">
<h2 className="text-body-lg font-medium text-danger mb-2">Danger Zone</h2>
<p className="text-body-sm text-foreground-muted mb-5">
Permanently delete your account and all associated data.
</p>
<button
className="px-5 py-3 bg-danger text-white text-ui font-medium rounded-xl hover:bg-danger/90 transition-all"
>
Delete Account
</button>
</div>
</div>
)}
</div>
</div>
</div>
</main>
<Footer />
</div>
)
}

View File

@ -37,7 +37,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: 0.9, priority: 0.9,
}, },
{ {
url: `${siteUrl}/intel`, url: `${siteUrl}/discover`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'daily', changeFrequency: 'daily',
priority: 0.9, priority: 0.9,
@ -64,7 +64,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// Add TLD pages (programmatic SEO - high priority for search) // Add TLD pages (programmatic SEO - high priority for search)
const tldPages: MetadataRoute.Sitemap = TOP_TLDS.map((tld) => ({ const tldPages: MetadataRoute.Sitemap = TOP_TLDS.map((tld) => ({
url: `${siteUrl}/intel/${tld}`, url: `${siteUrl}/discover/${tld}`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'daily', changeFrequency: 'daily',
priority: 0.8, priority: 0.8,

View File

@ -0,0 +1,987 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { TerminalLayout } from '@/components/TerminalLayout'
import {
Plus,
TrendingUp,
TrendingDown,
Wallet,
DollarSign,
Calendar,
RefreshCw,
Trash2,
Edit3,
Loader2,
CheckCircle,
AlertCircle,
X,
Briefcase,
PiggyBank,
Target,
ArrowRight,
MoreHorizontal,
Tag,
Clock,
Sparkles,
Shield
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
// ============================================================================
// SHARED COMPONENTS
// ============================================================================
function StatCard({
label,
value,
subValue,
icon: Icon,
trend,
color = 'emerald'
}: {
label: string
value: string | number
subValue?: string
icon: any
trend?: 'up' | 'down' | 'neutral'
color?: 'emerald' | 'blue' | 'amber' | 'rose'
}) {
const colors = {
emerald: 'text-emerald-400',
blue: 'text-blue-400',
amber: 'text-amber-400',
rose: 'text-rose-400',
}
return (
<div className="bg-zinc-900/40 border border-white/5 p-4 relative overflow-hidden group hover:border-white/10 transition-colors">
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Icon className="w-16 h-16" />
</div>
<div className="relative z-10">
<div className="flex items-center gap-2 text-zinc-400 mb-1">
<Icon className={clsx("w-4 h-4", colors[color])} />
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-white tracking-tight">{value}</span>
{subValue && <span className="text-xs text-zinc-500 font-medium">{subValue}</span>}
</div>
{trend && (
<div className={clsx(
"mt-2 text-[10px] font-medium px-1.5 py-0.5 w-fit rounded border flex items-center gap-1",
trend === 'up' && "text-emerald-400 border-emerald-400/20 bg-emerald-400/5",
trend === 'down' && "text-rose-400 border-rose-400/20 bg-rose-400/5",
trend === 'neutral' && "text-zinc-400 border-zinc-400/20 bg-zinc-400/5",
)}>
{trend === 'up' ? <TrendingUp className="w-3 h-3" /> : trend === 'down' ? <TrendingDown className="w-3 h-3" /> : null}
{trend === 'up' ? 'PROFIT' : trend === 'down' ? 'LOSS' : 'NEUTRAL'}
</div>
)}
</div>
</div>
)
}
// ============================================================================
// TYPES
// ============================================================================
interface PortfolioDomain {
id: number
domain: string
purchase_date: string | null
purchase_price: number | null
purchase_registrar: string | null
registrar: string | null
renewal_date: string | null
renewal_cost: number | null
auto_renew: boolean
estimated_value: number | null
value_updated_at: string | null
is_sold: boolean
sale_date: string | null
sale_price: number | null
status: string
notes: string | null
tags: string | null
roi: number | null
created_at: string
updated_at: string
}
interface PortfolioSummary {
total_domains: number
active_domains: number
sold_domains: number
total_invested: number
total_value: number
total_sold_value: number
unrealized_profit: number
realized_profit: number
overall_roi: number
}
// ============================================================================
// MAIN PAGE
// ============================================================================
export default function PortfolioPage() {
const { subscription } = useStore()
const [domains, setDomains] = useState<PortfolioDomain[]>([])
const [summary, setSummary] = useState<PortfolioSummary | null>(null)
const [loading, setLoading] = useState(true)
// Modals
const [showAddModal, setShowAddModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [showSellModal, setShowSellModal] = useState(false)
const [showListModal, setShowListModal] = useState(false)
const [selectedDomain, setSelectedDomain] = useState<PortfolioDomain | null>(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
// List for sale form
const [listData, setListData] = useState({
asking_price: '',
price_type: 'negotiable',
})
// Form state
const [formData, setFormData] = useState({
domain: '',
purchase_date: '',
purchase_price: '',
registrar: '',
renewal_date: '',
renewal_cost: '',
notes: '',
tags: '',
})
const [sellData, setSellData] = useState({
sale_date: new Date().toISOString().split('T')[0],
sale_price: '',
})
const loadData = useCallback(async () => {
setLoading(true)
try {
const [domainsData, summaryData] = await Promise.all([
api.request<PortfolioDomain[]>('/portfolio'),
api.request<PortfolioSummary>('/portfolio/summary'),
])
setDomains(domainsData)
setSummary(summaryData)
} catch (err: any) {
console.error('Failed to load portfolio:', err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadData()
}, [loadData])
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setError(null)
try {
await api.request('/portfolio', {
method: 'POST',
body: JSON.stringify({
domain: formData.domain,
purchase_date: formData.purchase_date || null,
purchase_price: formData.purchase_price ? parseFloat(formData.purchase_price) : null,
registrar: formData.registrar || null,
renewal_date: formData.renewal_date || null,
renewal_cost: formData.renewal_cost ? parseFloat(formData.renewal_cost) : null,
notes: formData.notes || null,
tags: formData.tags || null,
}),
})
setSuccess('Domain added to portfolio!')
setShowAddModal(false)
setFormData({ domain: '', purchase_date: '', purchase_price: '', registrar: '', renewal_date: '', renewal_cost: '', notes: '', tags: '' })
loadData()
} catch (err: any) {
setError(err.message)
} finally {
setSaving(false)
}
}
const handleEdit = async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedDomain) return
setSaving(true)
setError(null)
try {
await api.request(`/portfolio/${selectedDomain.id}`, {
method: 'PUT',
body: JSON.stringify({
purchase_date: formData.purchase_date || null,
purchase_price: formData.purchase_price ? parseFloat(formData.purchase_price) : null,
registrar: formData.registrar || null,
renewal_date: formData.renewal_date || null,
renewal_cost: formData.renewal_cost ? parseFloat(formData.renewal_cost) : null,
notes: formData.notes || null,
tags: formData.tags || null,
}),
})
setSuccess('Domain updated!')
setShowEditModal(false)
loadData()
} catch (err: any) {
setError(err.message)
} finally {
setSaving(false)
}
}
const handleSell = async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedDomain) return
setSaving(true)
setError(null)
try {
await api.request(`/portfolio/${selectedDomain.id}/sell`, {
method: 'POST',
body: JSON.stringify({
sale_date: sellData.sale_date,
sale_price: parseFloat(sellData.sale_price),
}),
})
setSuccess(`🎉 Congratulations! ${selectedDomain.domain} marked as sold!`)
setShowSellModal(false)
loadData()
} catch (err: any) {
setError(err.message)
} finally {
setSaving(false)
}
}
const handleDelete = async (domain: PortfolioDomain) => {
if (!confirm(`Remove ${domain.domain} from portfolio?`)) return
try {
await api.request(`/portfolio/${domain.id}`, { method: 'DELETE' })
setSuccess('Domain removed from portfolio')
loadData()
} catch (err: any) {
setError(err.message)
}
}
const handleRefreshValue = async (domain: PortfolioDomain) => {
try {
await api.request(`/portfolio/${domain.id}/refresh-value`, { method: 'POST' })
setSuccess(`Value refreshed for ${domain.domain}`)
loadData()
} catch (err: any) {
setError(err.message)
}
}
const openEditModal = (domain: PortfolioDomain) => {
setSelectedDomain(domain)
setFormData({
domain: domain.domain,
purchase_date: domain.purchase_date?.split('T')[0] || '',
purchase_price: domain.purchase_price?.toString() || '',
registrar: domain.registrar || '',
renewal_date: domain.renewal_date?.split('T')[0] || '',
renewal_cost: domain.renewal_cost?.toString() || '',
notes: domain.notes || '',
tags: domain.tags || '',
})
setShowEditModal(true)
}
const openSellModal = (domain: PortfolioDomain) => {
setSelectedDomain(domain)
setSellData({
sale_date: new Date().toISOString().split('T')[0],
sale_price: domain.estimated_value?.toString() || '',
})
setShowSellModal(true)
}
const openListModal = (domain: PortfolioDomain) => {
setSelectedDomain(domain)
setListData({
asking_price: domain.estimated_value?.toString() || '',
price_type: 'negotiable',
})
setShowListModal(true)
}
const handleListForSale = async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedDomain) return
setSaving(true)
setError(null)
try {
// Create a listing for this domain
await api.request('/listings', {
method: 'POST',
body: JSON.stringify({
domain: selectedDomain.domain,
asking_price: listData.asking_price ? parseFloat(listData.asking_price) : null,
price_type: listData.price_type,
allow_offers: true,
}),
})
setSuccess(`${selectedDomain.domain} is now listed for sale! Go to "For Sale" to verify ownership and publish.`)
setShowListModal(false)
} catch (err: any) {
setError(err.message)
} finally {
setSaving(false)
}
}
const formatCurrency = (value: number | null) => {
if (value === null) return '—'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
}).format(value)
}
const formatDate = (date: string | null) => {
if (!date) return '—'
return new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
// Tier check
const tier = subscription?.tier || 'scout'
const canUsePortfolio = tier !== 'scout'
return (
<TerminalLayout hideHeaderSearch={true}>
<div className="relative font-sans text-zinc-100 selection:bg-emerald-500/30 pb-20">
{/* Ambient Background Glow */}
<div className="fixed inset-0 pointer-events-none overflow-hidden">
<div className="absolute top-0 right-1/4 w-[800px] h-[600px] bg-blue-500/5 rounded-full blur-[120px] mix-blend-screen" />
<div className="absolute bottom-0 left-1/4 w-[600px] h-[500px] bg-emerald-500/5 rounded-full blur-[100px] mix-blend-screen" />
</div>
<div className="relative z-10 max-w-[1600px] mx-auto p-4 md:p-8 space-y-8">
{/* Header Section */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6 border-b border-white/5 pb-8">
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="h-8 w-1 bg-blue-500 rounded-full shadow-[0_0_10px_rgba(59,130,246,0.5)]" />
<h1 className="text-3xl font-bold tracking-tight text-white">Portfolio</h1>
</div>
<p className="text-zinc-400 max-w-lg">
Track your domain investments, valuations, and ROI. Your personal domain asset manager.
</p>
</div>
{canUsePortfolio && (
<button
onClick={() => {
setFormData({ domain: '', purchase_date: '', purchase_price: '', registrar: '', renewal_date: '', renewal_cost: '', notes: '', tags: '' })
setShowAddModal(true)
}}
className="px-4 py-2 bg-blue-500 text-white font-medium rounded-lg hover:bg-blue-400 transition-all shadow-lg shadow-blue-500/20 flex items-center gap-2"
>
<Plus className="w-4 h-4" /> Add Domain
</button>
)}
</div>
{/* Messages */}
{error && (
<div className="p-4 bg-rose-500/10 border border-rose-500/20 rounded-xl flex items-center gap-3 text-rose-400 animate-in fade-in slide-in-from-top-2">
<AlertCircle className="w-5 h-5" />
<p className="text-sm flex-1">{error}</p>
<button onClick={() => setError(null)}><X className="w-4 h-4" /></button>
</div>
)}
{success && (
<div className="p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-xl flex items-center gap-3 text-emerald-400 animate-in fade-in slide-in-from-top-2">
<CheckCircle className="w-5 h-5" />
<p className="text-sm flex-1">{success}</p>
<button onClick={() => setSuccess(null)}><X className="w-4 h-4" /></button>
</div>
)}
{/* Paywall */}
{!canUsePortfolio && (
<div className="p-8 bg-gradient-to-br from-blue-900/20 to-black border border-blue-500/20 rounded-2xl text-center relative overflow-hidden">
<div className="absolute inset-0 bg-grid-white/[0.02] bg-[length:20px_20px]" />
<div className="relative z-10">
<Briefcase className="w-12 h-12 text-blue-400 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-white mb-2">Unlock Portfolio Management</h2>
<p className="text-zinc-400 mb-6 max-w-md mx-auto">
Track your domain investments, monitor valuations, and calculate ROI. Know exactly how your portfolio is performing.
</p>
<Link
href="/pricing"
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-500 text-white font-bold rounded-xl hover:bg-blue-400 transition-all shadow-lg shadow-blue-500/20"
>
Upgrade to Trader <ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
)}
{/* Stats Grid */}
{canUsePortfolio && summary && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
label="Total Value"
value={formatCurrency(summary.total_value)}
subValue={`${summary.active_domains} active`}
icon={Wallet}
color="blue"
trend={summary.unrealized_profit > 0 ? 'up' : summary.unrealized_profit < 0 ? 'down' : 'neutral'}
/>
<StatCard
label="Invested"
value={formatCurrency(summary.total_invested)}
subValue="Total cost"
icon={PiggyBank}
color="amber"
/>
<StatCard
label="Unrealized P/L"
value={formatCurrency(summary.unrealized_profit)}
subValue="Paper gains"
icon={TrendingUp}
color={summary.unrealized_profit >= 0 ? 'emerald' : 'rose'}
trend={summary.unrealized_profit > 0 ? 'up' : summary.unrealized_profit < 0 ? 'down' : 'neutral'}
/>
<StatCard
label="ROI"
value={`${summary.overall_roi > 0 ? '+' : ''}${summary.overall_roi.toFixed(1)}%`}
subValue={`${summary.sold_domains} sold`}
icon={Target}
color={summary.overall_roi >= 0 ? 'emerald' : 'rose'}
trend={summary.overall_roi > 0 ? 'up' : summary.overall_roi < 0 ? 'down' : 'neutral'}
/>
</div>
)}
{/* Domains Table */}
{canUsePortfolio && (
<div className="bg-zinc-900/40 border border-white/5 rounded-xl overflow-hidden backdrop-blur-sm">
{/* Table Header */}
<div className="grid grid-cols-12 gap-4 px-6 py-3 bg-white/[0.02] border-b border-white/5 text-[11px] font-semibold text-zinc-500 uppercase tracking-wider">
<div className="col-span-12 md:col-span-3">Domain</div>
<div className="hidden md:block md:col-span-2 text-right">Cost</div>
<div className="hidden md:block md:col-span-2 text-right">Value</div>
<div className="hidden md:block md:col-span-2 text-right">ROI</div>
<div className="hidden md:block md:col-span-1 text-center">Status</div>
<div className="hidden md:block md:col-span-2 text-right">Actions</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
</div>
) : domains.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-16 h-16 rounded-full bg-white/5 flex items-center justify-center mb-4">
<Briefcase className="w-8 h-8 text-zinc-600" />
</div>
<h3 className="text-lg font-medium text-white mb-1">No domains in portfolio</h3>
<p className="text-zinc-500 text-sm max-w-xs mx-auto mb-6">
Add your first domain to start tracking your investments.
</p>
<button
onClick={() => setShowAddModal(true)}
className="text-blue-400 text-sm hover:text-blue-300 transition-colors flex items-center gap-2 font-medium"
>
Add Domain <ArrowRight className="w-4 h-4" />
</button>
</div>
) : (
<div className="divide-y divide-white/5">
{domains.map((domain) => (
<div key={domain.id} className="grid grid-cols-12 gap-4 px-6 py-4 items-center hover:bg-white/[0.04] transition-all group">
{/* Domain */}
<div className="col-span-12 md:col-span-3">
<div className="flex items-center gap-3">
<div className={clsx(
"w-10 h-10 rounded-lg flex items-center justify-center text-lg font-bold",
domain.is_sold ? "bg-blue-500/10 text-blue-400" : "bg-zinc-800 text-zinc-400"
)}>
{domain.domain.charAt(0).toUpperCase()}
</div>
<div>
<div className="font-mono font-bold text-white tracking-tight">{domain.domain}</div>
<div className="text-xs text-zinc-500">
{domain.registrar || 'No registrar'}
{domain.renewal_date && (
<span className="ml-2 text-zinc-600"> Renews {formatDate(domain.renewal_date)}</span>
)}
</div>
</div>
</div>
</div>
{/* Cost */}
<div className="hidden md:block col-span-2 text-right">
<div className="font-mono text-zinc-400">{formatCurrency(domain.purchase_price)}</div>
{domain.purchase_date && (
<div className="text-[10px] text-zinc-600">{formatDate(domain.purchase_date)}</div>
)}
</div>
{/* Value */}
<div className="hidden md:block col-span-2 text-right">
<div className="font-mono text-white font-medium">
{domain.is_sold ? formatCurrency(domain.sale_price) : formatCurrency(domain.estimated_value)}
</div>
{domain.is_sold ? (
<div className="text-[10px] text-blue-400">Sold {formatDate(domain.sale_date)}</div>
) : domain.value_updated_at && (
<div className="text-[10px] text-zinc-600">Updated {formatDate(domain.value_updated_at)}</div>
)}
</div>
{/* ROI */}
<div className="hidden md:block col-span-2 text-right">
{domain.roi !== null ? (
<div className={clsx(
"font-mono font-medium",
domain.roi >= 0 ? "text-emerald-400" : "text-rose-400"
)}>
{domain.roi > 0 ? '+' : ''}{domain.roi.toFixed(1)}%
</div>
) : (
<div className="text-zinc-600"></div>
)}
</div>
{/* Status */}
<div className="hidden md:flex col-span-1 justify-center">
<span className={clsx(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider border",
domain.is_sold ? "bg-blue-500/10 text-blue-400 border-blue-500/20" :
domain.status === 'active' ? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" :
"bg-zinc-800/50 text-zinc-400 border-zinc-700"
)}>
{domain.is_sold ? 'Sold' : domain.status}
</span>
</div>
{/* Actions */}
<div className="hidden md:flex col-span-2 justify-end gap-1">
{!domain.is_sold && (
<>
<button
onClick={() => openListModal(domain)}
className="p-2 rounded-lg text-zinc-600 hover:text-amber-400 hover:bg-amber-500/10 transition-all"
title="List for sale"
>
<Tag className="w-4 h-4" />
</button>
<button
onClick={() => handleRefreshValue(domain)}
className="p-2 rounded-lg text-zinc-600 hover:text-blue-400 hover:bg-blue-500/10 transition-all"
title="Refresh value"
>
<RefreshCw className="w-4 h-4" />
</button>
<button
onClick={() => openSellModal(domain)}
className="p-2 rounded-lg text-zinc-600 hover:text-emerald-400 hover:bg-emerald-500/10 transition-all"
title="Record sale"
>
<DollarSign className="w-4 h-4" />
</button>
</>
)}
<button
onClick={() => openEditModal(domain)}
className="p-2 rounded-lg text-zinc-600 hover:text-white hover:bg-white/10 transition-all"
title="Edit"
>
<Edit3 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(domain)}
className="p-2 rounded-lg text-zinc-600 hover:text-rose-400 hover:bg-rose-500/10 transition-all"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Add Modal */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md">
<div className="w-full max-w-lg bg-[#0A0A0A] border border-white/10 rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
<div className="p-6 border-b border-white/5">
<h2 className="text-xl font-bold text-white">Add to Portfolio</h2>
<p className="text-sm text-zinc-500">Track a domain you own</p>
</div>
<form onSubmit={handleAdd} className="p-6 space-y-4">
<div>
<label className="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">Domain Name *</label>
<input
type="text"
required
value={formData.domain}
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
placeholder="example.com"
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder:text-zinc-600 focus:outline-none focus:border-blue-500/50 transition-all font-mono"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">Purchase Date</label>
<input
type="date"
value={formData.purchase_date}
onChange={(e) => setFormData({ ...formData, purchase_date: e.target.value })}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-blue-500/50 transition-all"
/>
</div>
<div>
<label className="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">Purchase Price</label>
<div className="relative">
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
type="number"
step="0.01"
value={formData.purchase_price}
onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })}
placeholder="0.00"
className="w-full pl-9 pr-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder:text-zinc-600 focus:outline-none focus:border-blue-500/50 transition-all font-mono"
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">Registrar</label>
<input
type="text"
value={formData.registrar}
onChange={(e) => setFormData({ ...formData, registrar: e.target.value })}
placeholder="Namecheap, GoDaddy..."
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder:text-zinc-600 focus:outline-none focus:border-blue-500/50 transition-all"
/>
</div>
<div>
<label className="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">Renewal Date</label>
<input
type="date"
value={formData.renewal_date}
onChange={(e) => setFormData({ ...formData, renewal_date: e.target.value })}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-blue-500/50 transition-all"
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">Notes</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Why did you buy this domain?"
rows={2}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder:text-zinc-600 focus:outline-none focus:border-blue-500/50 transition-all resize-none"
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowAddModal(false)}
className="flex-1 px-4 py-3 border border-white/10 text-zinc-400 rounded-xl hover:bg-white/5 hover:text-white transition-all font-medium"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-blue-500 text-white font-bold rounded-xl hover:bg-blue-400 transition-all disabled:opacity-50 shadow-lg shadow-blue-500/20"
>
{saving ? <Loader2 className="w-5 h-5 animate-spin" /> : <Plus className="w-5 h-5" />}
{saving ? 'Adding...' : 'Add Domain'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit Modal */}
{showEditModal && selectedDomain && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md">
<div className="w-full max-w-lg bg-[#0A0A0A] border border-white/10 rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
<div className="p-6 border-b border-white/5">
<h2 className="text-xl font-bold text-white">Edit {selectedDomain.domain}</h2>
<p className="text-sm text-zinc-500">Update domain information</p>
</div>
<form onSubmit={handleEdit} className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">Purchase Date</label>
<input
type="date"
value={formData.purchase_date}
onChange={(e) => setFormData({ ...formData, purchase_date: e.target.value })}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-blue-500/50 transition-all"
/>
</div>
<div>
<label className="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">Purchase Price</label>
<div className="relative">
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
type="number"
step="0.01"
value={formData.purchase_price}
onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })}
className="w-full pl-9 pr-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-blue-500/50 transition-all font-mono"
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">Registrar</label>
<input
type="text"
value={formData.registrar}
onChange={(e) => setFormData({ ...formData, registrar: e.target.value })}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-blue-500/50 transition-all"
/>
</div>
<div>
<label className="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">Renewal Date</label>
<input
type="date"
value={formData.renewal_date}
onChange={(e) => setFormData({ ...formData, renewal_date: e.target.value })}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-blue-500/50 transition-all"
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">Notes</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={2}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-blue-500/50 transition-all resize-none"
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="flex-1 px-4 py-3 border border-white/10 text-zinc-400 rounded-xl hover:bg-white/5 hover:text-white transition-all font-medium"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-blue-500 text-white font-bold rounded-xl hover:bg-blue-400 transition-all disabled:opacity-50 shadow-lg shadow-blue-500/20"
>
{saving ? <Loader2 className="w-5 h-5 animate-spin" /> : <CheckCircle className="w-5 h-5" />}
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Sell Modal */}
{showSellModal && selectedDomain && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md">
<div className="w-full max-w-md bg-[#0A0A0A] border border-white/10 rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
<div className="p-6 border-b border-white/5 bg-gradient-to-r from-emerald-500/10 to-transparent">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Sparkles className="w-5 h-5 text-emerald-400" />
Record Sale
</h2>
<p className="text-sm text-zinc-400 mt-1">
Congratulations on selling <strong className="text-white">{selectedDomain.domain}</strong>!
</p>
</div>
<form onSubmit={handleSell} className="p-6 space-y-4">
<div>
<label className="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">Sale Date *</label>
<input
type="date"
required
value={sellData.sale_date}
onChange={(e) => setSellData({ ...sellData, sale_date: e.target.value })}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-emerald-500/50 transition-all"
/>
</div>
<div>
<label className="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">Sale Price *</label>
<div className="relative">
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-emerald-500" />
<input
type="number"
step="0.01"
required
value={sellData.sale_price}
onChange={(e) => setSellData({ ...sellData, sale_price: e.target.value })}
placeholder="0.00"
className="w-full pl-9 pr-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all font-mono text-lg"
/>
</div>
{selectedDomain.purchase_price && sellData.sale_price && (
<div className={clsx(
"mt-2 text-sm font-medium",
parseFloat(sellData.sale_price) > selectedDomain.purchase_price ? "text-emerald-400" : "text-rose-400"
)}>
{parseFloat(sellData.sale_price) > selectedDomain.purchase_price ? '📈' : '📉'}
{' '}ROI: {(((parseFloat(sellData.sale_price) - selectedDomain.purchase_price) / selectedDomain.purchase_price) * 100).toFixed(1)}%
{' '}(${(parseFloat(sellData.sale_price) - selectedDomain.purchase_price).toLocaleString()} profit)
</div>
)}
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowSellModal(false)}
className="flex-1 px-4 py-3 border border-white/10 text-zinc-400 rounded-xl hover:bg-white/5 hover:text-white transition-all font-medium"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-emerald-500 text-white font-bold rounded-xl hover:bg-emerald-400 transition-all disabled:opacity-50 shadow-lg shadow-emerald-500/20"
>
{saving ? <Loader2 className="w-5 h-5 animate-spin" /> : <DollarSign className="w-5 h-5" />}
{saving ? 'Saving...' : 'Record Sale'}
</button>
</div>
</form>
</div>
</div>
)}
{/* List for Sale Modal */}
{showListModal && selectedDomain && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md">
<div className="w-full max-w-md bg-[#0A0A0A] border border-white/10 rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
<div className="p-6 border-b border-white/5 bg-gradient-to-r from-amber-500/10 to-transparent">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Tag className="w-5 h-5 text-amber-400" />
List for Sale
</h2>
<p className="text-sm text-zinc-400 mt-1">
Put <strong className="text-white">{selectedDomain.domain}</strong> on the marketplace
</p>
</div>
<form onSubmit={handleListForSale} className="p-6 space-y-4">
<div>
<label className="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">Asking Price</label>
<div className="relative">
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-amber-500" />
<input
type="number"
step="0.01"
value={listData.asking_price}
onChange={(e) => setListData({ ...listData, asking_price: e.target.value })}
placeholder="Leave empty for 'Make Offer'"
className="w-full pl-9 pr-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder:text-zinc-600 focus:outline-none focus:border-amber-500/50 transition-all font-mono text-lg"
/>
</div>
{selectedDomain.estimated_value && (
<p className="mt-2 text-xs text-zinc-500">
Estimated value: <span className="text-amber-400">{formatCurrency(selectedDomain.estimated_value)}</span>
</p>
)}
</div>
<div>
<label className="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">Price Type</label>
<select
value={listData.price_type}
onChange={(e) => setListData({ ...listData, price_type: e.target.value })}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-amber-500/50 transition-all appearance-none"
>
<option value="negotiable">Negotiable</option>
<option value="fixed">Fixed Price</option>
<option value="make_offer">Make Offer Only</option>
</select>
</div>
<div className="p-4 bg-amber-500/5 border border-amber-500/10 rounded-xl">
<p className="text-xs text-amber-400/80 leading-relaxed">
💡 After creating the listing, you'll need to verify domain ownership via DNS before it goes live on the marketplace.
</p>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowListModal(false)}
className="flex-1 px-4 py-3 border border-white/10 text-zinc-400 rounded-xl hover:bg-white/5 hover:text-white transition-all font-medium"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-black font-bold rounded-xl hover:bg-amber-400 transition-all disabled:opacity-50 shadow-lg shadow-amber-500/20"
>
{saving ? <Loader2 className="w-5 h-5 animate-spin" /> : <Tag className="w-5 h-5" />}
{saving ? 'Creating...' : 'Create Listing'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
</TerminalLayout>
)
}

View File

@ -4,7 +4,7 @@ import { useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation' import { useRouter, useParams } from 'next/navigation'
/** /**
* Redirect /tld-pricing/[tld] to /intel/[tld] * Redirect /tld-pricing/[tld] to /discover/[tld]
* This page is kept for backwards compatibility * This page is kept for backwards compatibility
*/ */
export default function TldDetailRedirect() { export default function TldDetailRedirect() {
@ -13,14 +13,14 @@ export default function TldDetailRedirect() {
const tld = params.tld as string const tld = params.tld as string
useEffect(() => { useEffect(() => {
router.replace(`/intel/${tld}`) router.replace(`/discover/${tld}`)
}, [router, tld]) }, [router, tld])
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background"> <div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center"> <div className="text-center">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" /> <div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-foreground-muted">Redirecting to Intel...</p> <p className="text-foreground-muted">Redirecting to Discover...</p>
</div> </div>
</div> </div>
) )

View File

@ -4,21 +4,21 @@ import { useEffect } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
/** /**
* Redirect /tld-pricing to /intel * Redirect /tld-pricing to /discover
* This page is kept for backwards compatibility * This page is kept for backwards compatibility
*/ */
export default function TldPricingRedirect() { export default function TldPricingRedirect() {
const router = useRouter() const router = useRouter()
useEffect(() => { useEffect(() => {
router.replace('/intel') router.replace('/discover')
}, [router]) }, [router])
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background"> <div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center"> <div className="text-center">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" /> <div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-foreground-muted">Redirecting to Intel...</p> <p className="text-foreground-muted">Redirecting to Discover...</p>
</div> </div>
</div> </div>
) )

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect } from 'react'
import { Search, Check, X, Loader2, Calendar, Building2, Server, Plus, AlertTriangle, Clock } from 'lucide-react' import { Search, Check, X, Loader2, Calendar, Building2, Server, Plus, AlertTriangle, Clock, Lock, Crosshair, ArrowRight, Sparkles } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
import Link from 'next/link' import Link from 'next/link'
@ -17,6 +17,15 @@ interface CheckResult {
error_message: string | null error_message: string | null
} }
const PLACEHOLDERS = [
'crypto.ai',
'hotel.zurich',
'startup.io',
'finance.xyz',
'meta.com',
'shop.app'
]
export function DomainChecker() { export function DomainChecker() {
const [domain, setDomain] = useState('') const [domain, setDomain] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -24,6 +33,55 @@ export function DomainChecker() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const { isAuthenticated } = useStore() const { isAuthenticated } = useStore()
const [isFocused, setIsFocused] = useState(false) const [isFocused, setIsFocused] = useState(false)
// Typing effect state
const [placeholder, setPlaceholder] = useState('')
const [placeholderIndex, setPlaceholderIndex] = useState(0)
const [charIndex, setCharIndex] = useState(0)
const [isDeleting, setIsDeleting] = useState(false)
const [isPaused, setIsPaused] = useState(false)
// Typing effect logic
useEffect(() => {
if (isFocused || domain) return // Stop animation when user interacts
const currentWord = PLACEHOLDERS[placeholderIndex]
const typeSpeed = isDeleting ? 50 : 100
const pauseTime = 2000
if (isPaused) {
const timeout = setTimeout(() => {
setIsPaused(false)
setIsDeleting(true)
}, pauseTime)
return () => clearTimeout(timeout)
}
const timeout = setTimeout(() => {
if (!isDeleting) {
// Typing
if (charIndex < currentWord.length) {
setPlaceholder(currentWord.substring(0, charIndex + 1))
setCharIndex(prev => prev + 1)
} else {
// Finished typing, pause before deleting
setIsPaused(true)
}
} else {
// Deleting
if (charIndex > 0) {
setPlaceholder(currentWord.substring(0, charIndex - 1))
setCharIndex(prev => prev - 1)
} else {
// Finished deleting, move to next word
setIsDeleting(false)
setPlaceholderIndex((prev) => (prev + 1) % PLACEHOLDERS.length)
}
}
}, typeSpeed)
return () => clearTimeout(timeout)
}, [charIndex, isDeleting, isPaused, placeholderIndex, isFocused, domain])
const handleCheck = async (e: React.FormEvent) => { const handleCheck = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@ -64,39 +122,46 @@ export function DomainChecker() {
return ( return (
<div className="w-full max-w-2xl mx-auto"> <div className="w-full max-w-2xl mx-auto">
{/* Search Form */} {/* Search Form */}
<form onSubmit={handleCheck} className="relative"> <form onSubmit={handleCheck} className="relative group">
{/* Glow effect container - always visible, stronger on focus */} {/* Glow effect container - always visible, stronger on focus */}
<div className={clsx( <div className={clsx(
"absolute -inset-1 rounded-2xl transition-opacity duration-500", "absolute -inset-1 transition-opacity duration-500",
isFocused ? "opacity-100" : "opacity-60" isFocused ? "opacity-100" : "opacity-40 group-hover:opacity-60"
)}> )}>
<div className="absolute inset-0 bg-gradient-to-r from-accent/30 via-accent/20 to-accent/30 rounded-2xl blur-xl" /> <div className="absolute inset-0 bg-gradient-to-r from-accent/30 via-accent/10 to-accent/30 blur-xl" />
</div> </div>
{/* Input container */} {/* Input container */}
<div className={clsx( <div className={clsx(
"relative bg-background-secondary rounded-2xl transition-all duration-300 shadow-2xl shadow-accent/10", "relative bg-[#050505] transition-all duration-300 shadow-2xl shadow-black/50 border border-white/20",
isFocused ? "ring-2 ring-accent/50" : "ring-1 ring-accent/30" isFocused ? "border-accent shadow-[0_0_30px_rgba(16,185,129,0.1)]" : "group-hover:border-white/40"
)}> )}>
{/* Tech Corners */}
<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" />
<input <input
type="text" type="text"
value={domain} value={domain}
onChange={(e) => setDomain(e.target.value)} onChange={(e) => setDomain(e.target.value)}
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
placeholder="Hunt any domain..." placeholder={isFocused ? "Enter domain..." : `Search ${placeholder}|`}
className="w-full px-5 sm:px-7 py-5 sm:py-6 pr-32 sm:pr-40 bg-transparent rounded-2xl className="w-full px-5 sm:px-7 py-5 sm:py-6 pr-32 sm:pr-40 bg-transparent rounded-none
text-base sm:text-lg text-foreground placeholder:text-foreground-subtle text-base sm:text-lg text-white placeholder:text-white/40
focus:outline-none transition-colors" focus:outline-none transition-colors font-mono tracking-tight"
/> />
<button <button
type="submit" type="submit"
disabled={loading || !domain.trim()} disabled={loading || !domain.trim()}
className="absolute right-2.5 sm:right-3 top-1/2 -translate-y-1/2 className="absolute right-2 top-2 bottom-2
px-5 sm:px-7 py-3 sm:py-3.5 bg-accent text-background text-sm sm:text-base font-semibold rounded-xl px-5 sm:px-7 bg-accent text-background text-sm sm:text-base font-bold uppercase tracking-wider
hover:bg-accent-hover active:scale-[0.98] shadow-lg shadow-accent/25 hover:bg-accent-hover active:scale-[0.98]
disabled:opacity-40 disabled:cursor-not-allowed disabled:opacity-40 disabled:cursor-not-allowed
transition-all duration-300 flex items-center gap-2" transition-all duration-300 flex items-center gap-2 clip-path-slant"
style={{ clipPath: 'polygon(10% 0, 100% 0, 100% 100%, 0 100%)' }}
> >
{loading ? ( {loading ? (
<Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin" /> <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin" />
@ -106,10 +171,6 @@ export function DomainChecker() {
<span>Hunt</span> <span>Hunt</span>
</button> </button>
</div> </div>
<p className="mt-3 sm:mt-4 text-center text-xs sm:text-sm text-foreground-subtle">
Try <span className="text-accent/70">dream.com</span>, <span className="text-accent/70">startup.io</span>, or <span className="text-accent/70">next.ai</span>
</p>
</form> </form>
{/* Error State */} {/* Error State */}
@ -124,158 +185,99 @@ export function DomainChecker() {
<div className="mt-8 sm:mt-10 animate-scale-in"> <div className="mt-8 sm:mt-10 animate-scale-in">
{result.is_available ? ( {result.is_available ? (
/* ========== AVAILABLE DOMAIN ========== */ /* ========== AVAILABLE DOMAIN ========== */
<div className="rounded-xl sm:rounded-2xl border border-accent/30 overflow-hidden text-left"> <div className="relative group">
{/* Header */} <div className="absolute -inset-0.5 bg-gradient-to-r from-emerald-500/50 to-emerald-900/20 opacity-50 blur-sm transition-opacity group-hover:opacity-100" />
<div className="p-5 sm:p-6 bg-gradient-to-br from-accent/10 via-accent/5 to-transparent"> <div className="relative bg-[#050505] border border-emerald-500/30 p-1 shadow-2xl">
<div className="flex items-center gap-4"> {/* Inner Content */}
<div className="w-11 h-11 rounded-xl bg-accent/15 border border-accent/20 flex items-center justify-center shrink-0"> <div className="bg-[#080a08] relative overflow-hidden">
<Check className="w-5 h-5 text-accent" strokeWidth={2.5} /> <div className="absolute top-0 right-0 p-4 opacity-10">
<Sparkles className="w-24 h-24 text-emerald-500" />
</div>
<div className="p-6 flex items-start justify-between relative z-10">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center gap-1.5 px-2 py-0.5 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-[10px] font-bold uppercase tracking-widest">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
Available
</div>
<span className="text-emerald-500/40 text-[10px] font-mono">// IMMEDIATE_DEPLOY</span>
</div> </div>
<div className="flex-1 min-w-0 text-left"> <h3 className="text-3xl sm:text-4xl font-display text-white tracking-tight mb-1">{result.domain}</h3>
<p className="font-mono text-body sm:text-body-lg font-medium text-foreground text-left"> <p className="text-emerald-400/60 font-mono text-xs">Ready for immediate acquisition.</p>
{result.domain}
</p>
<p className="text-body-sm text-accent text-left">
It&apos;s yours for the taking.
</p>
</div> </div>
<span className="px-3 py-1.5 bg-accent text-background text-ui-sm font-medium rounded-lg shrink-0"> <div className="w-12 h-12 bg-emerald-500/10 border border-emerald-500/30 flex items-center justify-center text-emerald-400">
Available <Check className="w-6 h-6" strokeWidth={3} />
</span>
</div> </div>
</div> </div>
{/* CTA */} <div className="h-px w-full bg-gradient-to-r from-emerald-500/20 via-emerald-500/10 to-transparent" />
<div className="p-4 sm:p-5 bg-background-secondary border-t border-accent/20">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4"> <div className="p-5 flex items-center justify-between bg-emerald-950/[0.05]">
<p className="text-body-sm text-foreground-muted text-left"> <div className="flex flex-col">
Grab it now or track it in your watchlist. <span className="text-[10px] text-white/30 uppercase tracking-widest font-mono mb-1">Status</span>
</p> <span className="text-sm font-mono text-emerald-100/80">Market Open</span>
</div>
<Link <Link
href={isAuthenticated ? '/terminal/radar' : '/register'} href={isAuthenticated ? '/terminal/radar' : '/register'}
className="shrink-0 flex items-center justify-center sm:justify-start gap-2 px-5 py-2.5 className="group relative px-6 py-3 bg-emerald-500 hover:bg-emerald-400 transition-colors text-black font-bold uppercase tracking-wider text-xs flex items-center gap-2"
bg-accent text-background text-ui font-medium rounded-lg style={{ clipPath: 'polygon(12px 0, 100% 0, 100% 100%, 0 100%, 0 12px)' }}
hover:bg-accent-hover transition-all duration-300"
> >
<Plus className="w-4 h-4" /> <span>Acquire Asset</span>
<span>Track This</span> <ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform" />
</Link> </Link>
</div>
</div> </div>
</div> </div>
</div> </div>
) : ( ) : (
/* ========== TAKEN DOMAIN ========== */ /* ========== TAKEN DOMAIN ========== */
<div className="rounded-xl sm:rounded-2xl border border-border overflow-hidden bg-background-secondary text-left"> <div className="relative group">
{/* Header */} <div className="absolute -inset-0.5 bg-gradient-to-r from-rose-500/40 to-rose-900/10 opacity-30 blur-sm transition-opacity group-hover:opacity-60" />
<div className="p-5 sm:p-6 border-b border-border"> <div className="relative bg-[#050505] border border-rose-500/20 p-1 shadow-2xl">
<div className="flex items-center gap-4"> <div className="bg-[#0a0505] relative overflow-hidden">
<div className="w-11 h-11 rounded-xl bg-danger-muted border border-danger/20 flex items-center justify-center shrink-0"> <div className="p-6 flex items-start justify-between relative z-10">
<X className="w-5 h-5 text-danger" strokeWidth={2} /> <div>
</div> <div className="flex items-center gap-3 mb-2">
<div className="flex-1 min-w-0 text-left"> <div className="flex items-center gap-1.5 px-2 py-0.5 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-[10px] font-bold uppercase tracking-widest">
<p className="font-mono text-body sm:text-body-lg font-medium text-foreground text-left"> <div className="w-1.5 h-1.5 rounded-full bg-rose-500" />
{result.domain} Locked
</p>
<p className="text-body-sm text-foreground-muted text-left">
Someone got there first. For now.
</p>
</div>
<span className="px-3 py-1.5 bg-background-tertiary text-foreground-muted text-ui-sm font-medium rounded-lg border border-border shrink-0">
Taken
</span>
</div> </div>
</div> </div>
<h3 className="text-3xl sm:text-4xl font-display text-white/40 tracking-tight mb-1 line-through decoration-rose-500/30">{result.domain}</h3>
</div>
<div className="w-12 h-12 bg-rose-500/5 border border-rose-500/10 flex items-center justify-center text-rose-500/50">
<Lock className="w-6 h-6" />
</div>
</div>
{/* Domain Info */} {/* Details Grid */}
{(result.registrar || result.expiration_date) && ( <div className="grid grid-cols-2 border-t border-rose-500/10 divide-x divide-rose-500/10">
<div className="p-5 sm:p-6 border-b border-border bg-background-tertiary/30"> <div className="p-4 bg-rose-950/[0.02]">
<div className="grid sm:grid-cols-2 gap-5"> <span className="text-[10px] text-rose-200/30 uppercase tracking-widest font-mono block mb-1">Registrar</span>
{result.registrar && ( <span className="text-sm text-rose-100/60 font-mono truncate block">{result.registrar || 'Unknown'}</span>
<div className="flex items-start gap-3 text-left">
<div className="w-9 h-9 bg-background-tertiary rounded-lg flex items-center justify-center shrink-0">
<Building2 className="w-4 h-4 text-foreground-subtle" />
</div> </div>
<div className="min-w-0 text-left"> <div className="p-4 bg-rose-950/[0.02]">
<p className="text-ui-sm text-foreground-subtle uppercase tracking-wider mb-0.5 text-left">Registrar</p> <span className="text-[10px] text-rose-200/30 uppercase tracking-widest font-mono block mb-1">Expires</span>
<p className="text-body-sm text-foreground truncate text-left">{result.registrar}</p> <span className={clsx("text-sm font-mono block",
</div> getDaysUntilExpiration(result.expiration_date) !== null && getDaysUntilExpiration(result.expiration_date)! < 90 ? "text-rose-400 font-bold" : "text-rose-100/60"
</div>
)}
{result.expiration_date && (
<div className="flex items-start gap-3 text-left">
<div className={clsx(
"w-9 h-9 rounded-lg flex items-center justify-center shrink-0",
getDaysUntilExpiration(result.expiration_date) !== null &&
getDaysUntilExpiration(result.expiration_date)! <= 90
? "bg-warning-muted"
: "bg-background-tertiary"
)}>
<Calendar className={clsx(
"w-4 h-4",
getDaysUntilExpiration(result.expiration_date) !== null &&
getDaysUntilExpiration(result.expiration_date)! <= 90
? "text-warning"
: "text-foreground-subtle"
)} />
</div>
<div className="min-w-0 text-left">
<p className="text-ui-sm text-foreground-subtle uppercase tracking-wider mb-0.5 text-left">Expires</p>
<p className="text-body-sm text-foreground text-left">
{formatDate(result.expiration_date)}
{getDaysUntilExpiration(result.expiration_date) !== null && (
<span className={clsx(
"ml-2 text-ui-sm",
getDaysUntilExpiration(result.expiration_date)! <= 30
? "text-danger"
: getDaysUntilExpiration(result.expiration_date)! <= 90
? "text-warning"
: "text-foreground-subtle"
)}> )}>
({getDaysUntilExpiration(result.expiration_date)} days) {formatDate(result.expiration_date) || 'Unknown'}
</span> </span>
)}
</p>
</div>
</div>
)}
{result.name_servers && result.name_servers.length > 0 && (
<div className="flex items-start gap-3 sm:col-span-2 text-left">
<div className="w-9 h-9 bg-background-tertiary rounded-lg flex items-center justify-center shrink-0">
<Server className="w-4 h-4 text-foreground-subtle" />
</div>
<div className="min-w-0 text-left">
<p className="text-ui-sm text-foreground-subtle uppercase tracking-wider mb-0.5 text-left">Name Servers</p>
<p className="text-body-sm font-mono text-foreground-muted truncate text-left">
{result.name_servers.slice(0, 2).join(' · ')}
{result.name_servers.length > 2 && (
<span className="text-foreground-subtle"> +{result.name_servers.length - 2}</span>
)}
</p>
</div>
</div>
)}
</div> </div>
</div> </div>
)}
<div className="p-4 bg-rose-950/[0.05] border-t border-rose-500/10 flex items-center justify-between">
{/* Watchlist CTA */} <span className="text-xs text-rose-500/50 font-mono">Target this asset?</span>
<div className="p-4 sm:p-5">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div className="flex items-center gap-2 text-body-sm text-foreground-muted text-left">
<Clock className="w-4 h-4 text-foreground-subtle shrink-0" />
<span className="text-left">We&apos;ll alert you the moment it drops.</span>
</div>
<Link <Link
href={isAuthenticated ? '/terminal/radar' : '/register'} href={isAuthenticated ? '/terminal/radar' : '/register'}
className="shrink-0 flex items-center justify-center sm:justify-start gap-2 px-4 py-2.5 className="group relative px-6 py-3 bg-rose-500 hover:bg-rose-400 transition-colors text-black font-bold uppercase tracking-wider text-xs flex items-center gap-2"
bg-background-tertiary text-foreground text-ui font-medium rounded-lg style={{ clipPath: 'polygon(12px 0, 100% 0, 100% 100%, 0 100%, 0 12px)' }}
border border-border hover:border-border-hover transition-all duration-300"
> >
<Plus className="w-4 h-4" /> <span>Monitor Status</span>
<span>Track This</span> <Crosshair className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform" />
</Link> </Link>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -68,8 +68,8 @@ export function Footer() {
</Link> </Link>
</li> </li>
<li> <li>
<Link href="/intel" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors"> <Link href="/discover" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
Intel Discover
</Link> </Link>
</li> </li>
<li> <li>

View File

@ -37,12 +37,11 @@ export function Header() {
const tierName = subscription?.tier_name || subscription?.tier || 'Scout' const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
// Public navigation - same for all visitors // Navigation: Discover | Acquire | Yield | Pricing
// Navigation: Market | Intel | Yield | Pricing
const publicNavItems = [ const publicNavItems = [
{ href: '/market', label: 'Market', icon: Gavel }, { href: '/discover', label: 'Discover', icon: TrendingUp },
{ href: '/intel', label: 'Intel', icon: TrendingUp }, { href: '/market', label: 'Acquire', icon: Gavel },
{ href: '/yield', label: 'Yield', icon: Coins, isNew: true }, { href: '/yield', label: 'Yield', icon: Coins },
{ href: '/pricing', label: 'Pricing', icon: CreditCard }, { href: '/pricing', label: 'Pricing', icon: CreditCard },
] ]
@ -70,7 +69,7 @@ export function Header() {
className="flex items-center h-full hover:opacity-80 transition-opacity duration-300" className="flex items-center h-full hover:opacity-80 transition-opacity duration-300"
> >
<span <span
className="text-[1.25rem] sm:text-[1.5rem] font-bold tracking-[0.15em] text-foreground" className="text-[1.25rem] sm:text-[1.5rem] font-black tracking-[0.1em] text-foreground"
style={{ fontFamily: 'var(--font-display), Playfair Display, Georgia, serif' }} style={{ fontFamily: 'var(--font-display), Playfair Display, Georgia, serif' }}
> >
POUNCE POUNCE
@ -84,9 +83,9 @@ export function Header() {
key={item.href} key={item.href}
href={item.href} href={item.href}
className={clsx( className={clsx(
"flex items-center h-9 px-3 text-[0.8125rem] rounded-lg transition-all duration-200", "flex items-center h-9 px-3 text-[0.8125rem] transition-all duration-200 uppercase tracking-wide",
isActive(item.href) isActive(item.href)
? "text-foreground bg-foreground/5 font-medium" ? "text-foreground font-bold border-b-2 border-accent"
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5" : "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
)} )}
> >
@ -104,7 +103,8 @@ export function Header() {
<Link <Link
href="/terminal/radar" href="/terminal/radar"
className="flex items-center gap-2 h-9 px-4 text-[0.8125rem] bg-accent text-background className="flex items-center gap-2 h-9 px-4 text-[0.8125rem] bg-accent text-background
rounded-lg font-medium hover:bg-accent-hover transition-all duration-200" font-bold uppercase tracking-wider hover:bg-accent-hover transition-all duration-200 rounded-none clip-path-slant-sm"
style={{ clipPath: 'polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px)' }}
> >
<LayoutDashboard className="w-4 h-4" /> <LayoutDashboard className="w-4 h-4" />
Command Center Command Center
@ -115,14 +115,15 @@ export function Header() {
<Link <Link
href="/login" href="/login"
className="flex items-center h-9 px-4 text-[0.8125rem] text-foreground-muted hover:text-foreground className="flex items-center h-9 px-4 text-[0.8125rem] text-foreground-muted hover:text-foreground
hover:bg-foreground/5 rounded-lg transition-all duration-200" hover:bg-foreground/5 transition-all duration-200 uppercase tracking-wide rounded-none"
> >
Sign In Sign In
</Link> </Link>
<Link <Link
href="/register" href="/register"
className="flex items-center h-9 ml-1 px-5 text-[0.8125rem] bg-accent text-background rounded-lg className="flex items-center h-9 ml-1 px-5 text-[0.8125rem] bg-accent text-background rounded-none
font-medium hover:bg-accent-hover transition-all duration-200 shadow-[0_0_20px_rgba(16,185,129,0.2)]" font-bold uppercase tracking-wider hover:bg-accent-hover transition-all duration-200 shadow-[0_0_20px_rgba(16,185,129,0.2)]"
style={{ clipPath: 'polygon(10px 0, 100% 0, 100% 100%, 0 100%, 0 10px)' }}
> >
Start Hunting Start Hunting
</Link> </Link>
@ -167,7 +168,7 @@ export function Header() {
<Link <Link
href="/terminal/radar" href="/terminal/radar"
className="flex items-center gap-3 px-4 py-3 text-body-sm text-center bg-accent text-background className="flex items-center gap-3 px-4 py-3 text-body-sm text-center bg-accent text-background
rounded-xl font-medium hover:bg-accent-hover transition-all duration-200" font-bold uppercase tracking-wider hover:bg-accent-hover transition-all duration-200 rounded-none"
> >
<LayoutDashboard className="w-5 h-5" /> <LayoutDashboard className="w-5 h-5" />
<span>Command Center</span> <span>Command Center</span>
@ -178,14 +179,14 @@ export function Header() {
<Link <Link
href="/login" href="/login"
className="block px-4 py-3 text-body-sm text-foreground-muted className="block px-4 py-3 text-body-sm text-foreground-muted
hover:text-foreground hover:bg-foreground/5 rounded-xl transition-all duration-200" hover:text-foreground hover:bg-foreground/5 transition-all duration-200 uppercase tracking-wide rounded-none"
> >
Sign In Sign In
</Link> </Link>
<Link <Link
href="/register" href="/register"
className="block px-4 py-3 text-body-sm text-center bg-accent text-background className="block px-4 py-3 text-body-sm text-center bg-accent text-background
rounded-xl font-medium hover:bg-accent-hover transition-all duration-200 shadow-[0_0_20px_rgba(16,185,129,0.2)]" font-bold uppercase tracking-wider hover:bg-accent-hover transition-all duration-200 shadow-[0_0_20px_rgba(16,185,129,0.2)] rounded-none"
> >
Start Hunting Start Hunting
</Link> </Link>