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
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:
542
backend/app/routes/portfolio.py
Executable file
542
backend/app/routes/portfolio.py
Executable 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)
|
||||||
|
|
||||||
36
backend/scripts/seed_auctions.py
Executable file
36
backend/scripts/seed_auctions.py
Executable 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())
|
||||||
@ -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',
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
106
frontend/src/app/auctions/layout.tsx
Executable file
106
frontend/src/app/auctions/layout.tsx
Executable 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}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ -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
209
frontend/src/app/careers/page.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
597
frontend/src/app/command/alerts/page.tsx
Executable file
597
frontend/src/app/command/alerts/page.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
578
frontend/src/app/command/auctions/page.tsx
Executable file
578
frontend/src/app/command/auctions/page.tsx
Executable 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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
402
frontend/src/app/command/dashboard/page.tsx
Executable file
402
frontend/src/app/command/dashboard/page.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
582
frontend/src/app/command/listings/page.tsx
Executable file
582
frontend/src/app/command/listings/page.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
302
frontend/src/app/command/marketplace/page.tsx
Executable file
302
frontend/src/app/command/marketplace/page.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
19
frontend/src/app/command/page.tsx
Executable file
19
frontend/src/app/command/page.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
955
frontend/src/app/command/portfolio/page.tsx
Executable file
955
frontend/src/app/command/portfolio/page.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
722
frontend/src/app/command/pricing/[tld]/page.tsx
Executable file
722
frontend/src/app/command/pricing/[tld]/page.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
387
frontend/src/app/command/pricing/page.tsx
Executable file
387
frontend/src/app/command/pricing/page.tsx
Executable 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 >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>
|
||||||
|
)
|
||||||
|
}
|
||||||
508
frontend/src/app/command/seo/page.tsx
Executable file
508
frontend/src/app/command/seo/page.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
563
frontend/src/app/command/settings/page.tsx
Executable file
563
frontend/src/app/command/settings/page.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
620
frontend/src/app/command/watchlist/page.tsx
Executable file
620
frontend/src/app/command/watchlist/page.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
})
|
||||||
221
frontend/src/app/command/welcome/page.tsx
Executable file
221
frontend/src/app/command/welcome/page.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
1105
frontend/src/app/dashboard/page.tsx
Executable file
1105
frontend/src/app/dashboard/page.tsx
Executable file
File diff suppressed because it is too large
Load Diff
@ -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}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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't fall for promo prices. See renewal costs, spot traps, and track price trends across {pagination.total}+ extensions.
|
Don'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>
|
||||||
26
frontend/src/app/intelligence/page.tsx
Executable file
26
frontend/src/app/intelligence/page.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ -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
743
frontend/src/app/settings/page.tsx
Executable file
743
frontend/src/app/settings/page.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ -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,
|
||||||
|
|||||||
987
frontend/src/app/terminal/portfolio/page.tsx
Executable file
987
frontend/src/app/terminal/portfolio/page.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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'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'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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user