feat: Complete business model expansion

MAJOR FEATURES:
- New pricing tiers: Scout (Free), Trader (€19/mo), Tycoon (€49/mo)
- Portfolio management: Track owned domains with purchase price, value, ROI
- Domain valuation engine: Algorithmic estimates based on length, TLD, keywords, brandability
- Dashboard tabs: Watchlist + Portfolio views
- Valuation modal: Score breakdown with confidence level

BACKEND:
- New models: PortfolioDomain, DomainValuation
- New API routes: /portfolio/* with full CRUD
- Valuation service with multi-factor algorithm
- Database migration for portfolio tables

FRONTEND:
- Updated pricing page with comparison table and billing toggle
- Dashboard with Watchlist/Portfolio tabs
- Portfolio summary stats: Total value, invested, unrealized P/L, ROI
- Add portfolio domain modal with all fields
- Domain valuation modal with score visualization
- Updated landing page with new tier pricing
- Hero section with large puma logo

DESIGN:
- Consistent minimalist dark theme
- Responsive on all devices
- Professional animations and transitions
This commit is contained in:
yves.gugger
2025-12-08 11:08:18 +01:00
parent c9bfdfa52a
commit 6e84103a5b
12 changed files with 2263 additions and 409 deletions

View File

@ -6,6 +6,8 @@ A professional full-stack application for monitoring domain name availability wi
### Core Functionality
- **Domain Availability Monitoring** — Track any domain and get notified when it becomes available
- **Domain Portfolio Management** — Track domains you own with purchase price, value, and ROI
- **Domain Valuation Engine** — Algorithmic domain value estimation based on length, TLD, keywords, brandability
- **TLD Price Intelligence** — Compare prices across 886+ TLDs from Porkbun API
- **Automated Price Scraping** — Daily cronjob scrapes real TLD prices from public APIs
- **Price Change Alerts** — Email notifications when TLD prices change >5%
@ -270,15 +272,17 @@ npm run dev
## Subscription Tiers
| Feature | Starter (Free) | Professional ($4.99/mo) | Enterprise ($9.99/mo) |
|---------|----------------|------------------------|----------------------|
| Domains | 3 | 25 | 100 |
| Check Frequency | Daily | Daily | Hourly |
| Notifications | Email | Priority Email | Priority Email |
| WHOIS Data | Basic | Full | Full |
| Check History | — | 30 days | Unlimited |
| Expiration Tracking | — | | ✓ |
| Feature | Scout (Free) | Trader (€19/mo) | Tycoon (€49/mo) |
|---------|--------------|-----------------|------------------|
| Watchlist Domains | 5 | 50 | 500 |
| Portfolio Domains | — | 25 | Unlimited |
| Check Frequency | Daily | Hourly | 10 min |
| Notifications | Email | SMS/Telegram | + Webhooks |
| Domain Valuation | — | ✓ | ✓ |
| SEO Metrics | — | | ✓ |
| Check History | — | 90 days | Unlimited |
| API Access | — | — | ✓ |
| Bulk Tools | — | — | ✓ |
---

View File

@ -0,0 +1,76 @@
"""Add portfolio and valuation tables
Revision ID: 003
Revises: 002
Create Date: 2025-01-08
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic
revision = '003'
down_revision = '002'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create portfolio_domains table
op.create_table(
'portfolio_domains',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('domain', sa.String(255), nullable=False),
sa.Column('purchase_date', sa.DateTime(), nullable=True),
sa.Column('purchase_price', sa.Float(), nullable=True),
sa.Column('purchase_registrar', sa.String(100), nullable=True),
sa.Column('registrar', sa.String(100), nullable=True),
sa.Column('renewal_date', sa.DateTime(), nullable=True),
sa.Column('renewal_cost', sa.Float(), nullable=True),
sa.Column('auto_renew', sa.Boolean(), default=True),
sa.Column('estimated_value', sa.Float(), nullable=True),
sa.Column('value_updated_at', sa.DateTime(), nullable=True),
sa.Column('is_sold', sa.Boolean(), default=False),
sa.Column('sale_date', sa.DateTime(), nullable=True),
sa.Column('sale_price', sa.Float(), nullable=True),
sa.Column('status', sa.String(50), default='active'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('tags', sa.String(500), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_portfolio_domains_id'), 'portfolio_domains', ['id'], unique=False)
op.create_index(op.f('ix_portfolio_domains_user_id'), 'portfolio_domains', ['user_id'], unique=False)
# Create domain_valuations table
op.create_table(
'domain_valuations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('domain', sa.String(255), nullable=False),
sa.Column('estimated_value', sa.Float(), nullable=False),
sa.Column('length_score', sa.Integer(), nullable=True),
sa.Column('tld_score', sa.Integer(), nullable=True),
sa.Column('keyword_score', sa.Integer(), nullable=True),
sa.Column('brandability_score', sa.Integer(), nullable=True),
sa.Column('moz_da', sa.Integer(), nullable=True),
sa.Column('moz_pa', sa.Integer(), nullable=True),
sa.Column('backlinks', sa.Integer(), nullable=True),
sa.Column('source', sa.String(50), default='internal'),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_domain_valuations_id'), 'domain_valuations', ['id'], unique=False)
op.create_index(op.f('ix_domain_valuations_domain'), 'domain_valuations', ['domain'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_domain_valuations_domain'), table_name='domain_valuations')
op.drop_index(op.f('ix_domain_valuations_id'), table_name='domain_valuations')
op.drop_table('domain_valuations')
op.drop_index(op.f('ix_portfolio_domains_user_id'), table_name='portfolio_domains')
op.drop_index(op.f('ix_portfolio_domains_id'), table_name='portfolio_domains')
op.drop_table('portfolio_domains')

View File

@ -7,6 +7,7 @@ from app.api.check import router as check_router
from app.api.subscription import router as subscription_router
from app.api.admin import router as admin_router
from app.api.tld_prices import router as tld_prices_router
from app.api.portfolio import router as portfolio_router
api_router = APIRouter()
@ -15,5 +16,6 @@ api_router.include_router(check_router, prefix="/check", tags=["Domain Check"])
api_router.include_router(domains_router, prefix="/domains", tags=["Domain Management"])
api_router.include_router(subscription_router, prefix="/subscription", tags=["Subscription"])
api_router.include_router(tld_prices_router, prefix="/tld-prices", tags=["TLD Prices"])
api_router.include_router(portfolio_router, prefix="/portfolio", tags=["Portfolio"])
api_router.include_router(admin_router, prefix="/admin", tags=["Admin"])

View File

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

View File

@ -3,5 +3,15 @@ from app.models.user import User
from app.models.domain import Domain, DomainCheck
from app.models.subscription import Subscription
from app.models.tld_price import TLDPrice, TLDInfo
from app.models.portfolio import PortfolioDomain, DomainValuation
__all__ = ["User", "Domain", "DomainCheck", "Subscription", "TLDPrice", "TLDInfo"]
__all__ = [
"User",
"Domain",
"DomainCheck",
"Subscription",
"TLDPrice",
"TLDInfo",
"PortfolioDomain",
"DomainValuation",
]

View File

@ -0,0 +1,113 @@
"""Portfolio model for tracking owned domains."""
from datetime import datetime
from typing import Optional
from sqlalchemy import String, DateTime, Float, Integer, Text, ForeignKey, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class PortfolioDomain(Base):
"""
Portfolio Domain model for tracking domains that users own.
Allows users to track their domain investments, values, and ROI.
"""
__tablename__ = "portfolio_domains"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
# Domain info
domain: Mapped[str] = mapped_column(String(255), nullable=False)
# Purchase info
purchase_date: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
purchase_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
purchase_registrar: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# Current status
registrar: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
renewal_date: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
renewal_cost: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
auto_renew: Mapped[bool] = mapped_column(Boolean, default=True)
# Valuation
estimated_value: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
value_updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Sale info (if sold)
is_sold: Mapped[bool] = mapped_column(Boolean, default=False)
sale_date: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
sale_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
# Status
status: Mapped[str] = mapped_column(String(50), default="active") # active, expired, sold, parked
# Notes
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
tags: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Comma-separated
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self) -> str:
return f"<PortfolioDomain {self.domain} (user={self.user_id})>"
@property
def roi(self) -> Optional[float]:
"""Calculate ROI percentage."""
if not self.purchase_price or self.purchase_price == 0:
return None
if self.is_sold and self.sale_price:
return ((self.sale_price - self.purchase_price) / self.purchase_price) * 100
elif self.estimated_value:
return ((self.estimated_value - self.purchase_price) / self.purchase_price) * 100
return None
@property
def total_cost(self) -> float:
"""Calculate total cost including renewals."""
cost = self.purchase_price or 0
# Add renewal costs if we had them tracked
return cost
class DomainValuation(Base):
"""
Domain valuation history.
Stores historical valuations for domains to track value changes over time.
"""
__tablename__ = "domain_valuations"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
domain: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
# Valuation breakdown
estimated_value: Mapped[float] = mapped_column(Float, nullable=False)
# Factors
length_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # 0-100
tld_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # 0-100
keyword_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # 0-100
brandability_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # 0-100
# SEO metrics
moz_da: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
moz_pa: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
backlinks: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Source
source: Mapped[str] = mapped_column(String(50), default="internal") # internal, estibot, godaddy
# Timestamp
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
def __repr__(self) -> str:
return f"<DomainValuation {self.domain}: ${self.estimated_value}>"

View File

@ -0,0 +1,334 @@
"""Domain valuation service."""
import logging
import re
from datetime import datetime
from typing import Optional, Dict, Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.portfolio import DomainValuation
from app.models.tld_price import TLDPrice
logger = logging.getLogger(__name__)
# TLD value multipliers (higher = more valuable)
TLD_VALUES = {
# Premium TLDs
"com": 1.0,
"net": 0.7,
"org": 0.65,
"io": 0.8,
"ai": 1.2,
"co": 0.6,
# Tech TLDs
"dev": 0.5,
"app": 0.5,
"tech": 0.4,
"software": 0.3,
# Country codes
"de": 0.5,
"uk": 0.5,
"ch": 0.45,
"fr": 0.4,
"eu": 0.35,
# New gTLDs
"xyz": 0.15,
"online": 0.2,
"site": 0.2,
"store": 0.25,
"shop": 0.25,
# Default
"_default": 0.2,
}
# Common high-value keywords
HIGH_VALUE_KEYWORDS = {
"crypto", "bitcoin", "btc", "eth", "nft", "web3", "defi",
"ai", "ml", "gpt", "chat", "bot",
"cloud", "saas", "api", "app", "tech",
"finance", "fintech", "bank", "pay", "money",
"health", "med", "care", "fit",
"game", "gaming", "play", "esport",
"shop", "buy", "sell", "deal", "store",
"travel", "trip", "hotel", "fly",
"food", "eat", "chef", "recipe",
"auto", "car", "drive", "ev",
"home", "house", "real", "estate",
}
class DomainValuationService:
"""
Service for estimating domain values.
Uses a multi-factor algorithm considering:
- Domain length
- TLD value
- Keyword relevance
- Brandability
- Character composition
"""
def __init__(self):
self.base_value = 10 # Base value in USD
async def estimate_value(
self,
domain: str,
db: Optional[AsyncSession] = None,
save_result: bool = True,
) -> Dict[str, Any]:
"""
Estimate the value of a domain.
Args:
domain: The domain name (e.g., "example.com")
db: Database session (optional, for saving results)
save_result: Whether to save the valuation to database
Returns:
Dictionary with valuation details
"""
domain = domain.lower().strip()
# Split domain and TLD
parts = domain.rsplit(".", 1)
if len(parts) != 2:
return {"error": "Invalid domain format"}
name, tld = parts
# Calculate scores
length_score = self._calculate_length_score(name)
tld_score = self._calculate_tld_score(tld)
keyword_score = self._calculate_keyword_score(name)
brandability_score = self._calculate_brandability_score(name)
# Calculate base value
# Formula: base * length_mult * tld_mult * (1 + keyword_bonus + brand_bonus)
length_mult = length_score / 50 # 0.0 - 2.0
tld_mult = TLD_VALUES.get(tld, TLD_VALUES["_default"])
keyword_bonus = keyword_score / 200 # 0.0 - 0.5
brand_bonus = brandability_score / 200 # 0.0 - 0.5
# Short premium domains get exponential boost
if len(name) <= 3:
length_mult *= 5
elif len(name) <= 4:
length_mult *= 3
elif len(name) <= 5:
length_mult *= 2
# Calculate estimated value
estimated_value = self.base_value * length_mult * tld_mult * (1 + keyword_bonus + brand_bonus)
# Apply caps
estimated_value = max(5, min(estimated_value, 1000000)) # $5 - $1M
# Round to reasonable precision
if estimated_value < 100:
estimated_value = round(estimated_value)
elif estimated_value < 1000:
estimated_value = round(estimated_value / 10) * 10
elif estimated_value < 10000:
estimated_value = round(estimated_value / 100) * 100
else:
estimated_value = round(estimated_value / 1000) * 1000
result = {
"domain": domain,
"estimated_value": estimated_value,
"currency": "USD",
"scores": {
"length": length_score,
"tld": tld_score,
"keyword": keyword_score,
"brandability": brandability_score,
"overall": round((length_score + tld_score + keyword_score + brandability_score) / 4),
},
"factors": {
"length": len(name),
"tld": tld,
"has_numbers": bool(re.search(r"\d", name)),
"has_hyphens": "-" in name,
"is_dictionary_word": self._is_common_word(name),
},
"confidence": self._calculate_confidence(length_score, tld_score, keyword_score, brandability_score),
"source": "internal",
"calculated_at": datetime.utcnow().isoformat(),
}
# Save to database if requested
if save_result and db:
try:
valuation = DomainValuation(
domain=domain,
estimated_value=estimated_value,
length_score=length_score,
tld_score=tld_score,
keyword_score=keyword_score,
brandability_score=brandability_score,
source="internal",
)
db.add(valuation)
await db.commit()
except Exception as e:
logger.error(f"Failed to save valuation: {e}")
return result
def _calculate_length_score(self, name: str) -> int:
"""Calculate score based on domain length (shorter = better)."""
length = len(name)
if length <= 2:
return 100
elif length <= 3:
return 95
elif length <= 4:
return 85
elif length <= 5:
return 75
elif length <= 6:
return 65
elif length <= 7:
return 55
elif length <= 8:
return 45
elif length <= 10:
return 35
elif length <= 15:
return 25
elif length <= 20:
return 15
else:
return 5
def _calculate_tld_score(self, tld: str) -> int:
"""Calculate score based on TLD value."""
value = TLD_VALUES.get(tld, TLD_VALUES["_default"])
return int(value * 100)
def _calculate_keyword_score(self, name: str) -> int:
"""Calculate score based on keyword value."""
name_lower = name.lower()
score = 0
# Check for high-value keywords
for keyword in HIGH_VALUE_KEYWORDS:
if keyword in name_lower:
score += 30
break
# Bonus for exact keyword match
if name_lower in HIGH_VALUE_KEYWORDS:
score += 50
# Penalty for numbers
if re.search(r"\d", name):
score -= 20
# Penalty for hyphens
if "-" in name:
score -= 30
# Bonus for being a common word
if self._is_common_word(name):
score += 40
return max(0, min(100, score))
def _calculate_brandability_score(self, name: str) -> int:
"""Calculate brandability score."""
score = 50 # Start neutral
# Bonus for pronounceable names
if self._is_pronounceable(name):
score += 20
# Bonus for memorable length
if 4 <= len(name) <= 8:
score += 15
# Penalty for hard-to-spell patterns
if re.search(r"(.)\1{2,}", name): # Triple letters
score -= 10
# Penalty for confusing patterns
if re.search(r"[0oO][1lI]|[1lI][0oO]", name): # 0/O or 1/l confusion
score -= 15
# Bonus for all letters
if name.isalpha():
score += 10
# Penalty for too many consonants in a row
if re.search(r"[bcdfghjklmnpqrstvwxyz]{5,}", name.lower()):
score -= 15
return max(0, min(100, score))
def _is_pronounceable(self, name: str) -> bool:
"""Check if a name is likely pronounceable."""
vowels = set("aeiou")
name_lower = name.lower()
# Must have at least one vowel
if not any(c in vowels for c in name_lower):
return False
# Check vowel distribution
vowel_count = sum(1 for c in name_lower if c in vowels)
vowel_ratio = vowel_count / len(name) if name else 0
return 0.2 <= vowel_ratio <= 0.6
def _is_common_word(self, name: str) -> bool:
"""Check if name is a common English word."""
# Simplified check - in production, use a dictionary API
common_words = {
"app", "web", "net", "dev", "code", "tech", "data", "cloud",
"shop", "store", "buy", "sell", "pay", "cash", "money",
"game", "play", "fun", "cool", "best", "top", "pro",
"home", "life", "love", "care", "help", "work", "job",
"news", "blog", "post", "chat", "talk", "meet", "link",
"fast", "quick", "smart", "easy", "simple", "free",
}
return name.lower() in common_words
def _calculate_confidence(self, *scores: int) -> str:
"""Calculate confidence level based on score consistency."""
avg = sum(scores) / len(scores)
variance = sum((s - avg) ** 2 for s in scores) / len(scores)
if variance < 100 and avg > 60:
return "high"
elif variance < 200 and avg > 40:
return "medium"
else:
return "low"
async def get_historical_valuations(
self,
domain: str,
db: AsyncSession,
limit: int = 10,
) -> list:
"""Get historical valuations for a domain."""
result = await db.execute(
select(DomainValuation)
.where(DomainValuation.domain == domain.lower())
.order_by(DomainValuation.created_at.desc())
.limit(limit)
)
return result.scalars().all()
# Singleton instance
valuation_service = DomainValuationService()

File diff suppressed because it is too large Load Diff

View File

@ -36,30 +36,30 @@ const features = [
const tiers = [
{
name: 'Starter',
name: 'Scout',
price: '0',
period: '',
description: 'For individuals exploring domains',
features: ['3 domains', 'Daily checks', 'Email alerts', 'Basic WHOIS'],
description: 'Explore the domain market',
features: ['5 domains', 'Daily checks', 'Email alerts', 'Basic search'],
cta: 'Start Free',
highlighted: false,
},
{
name: 'Professional',
price: '4.99',
name: 'Trader',
price: '19',
period: '/mo',
description: 'For domain investors',
features: ['25 domains', 'Daily checks', 'Priority alerts', 'Full WHOIS', '30-day history'],
cta: 'Get Pro',
description: 'For serious domain investors',
features: ['50 domains', 'Hourly checks', 'SMS alerts', 'Domain valuation', 'Portfolio tracking'],
cta: 'Start Trading',
highlighted: true,
},
{
name: 'Enterprise',
price: '9.99',
name: 'Tycoon',
price: '49',
period: '/mo',
description: 'For agencies & portfolios',
features: ['100 domains', 'Hourly checks', 'Priority alerts', 'Full WHOIS', 'Unlimited history', 'API access'],
cta: 'Get Enterprise',
description: 'For professionals & agencies',
features: ['500 domains', 'Real-time checks', 'API access', 'SEO metrics', 'Bulk tools'],
cta: 'Go Tycoon',
highlighted: false,
},
]

View File

@ -1,89 +1,127 @@
'use client'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import { useStore } from '@/lib/store'
import { Check, ArrowRight } from 'lucide-react'
import { Check, ArrowRight, Zap, TrendingUp, Crown, Briefcase, Shield, Bell, Clock, BarChart3, Code, Globe } from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
const tiers = [
{
name: 'Starter',
name: 'Scout',
icon: Zap,
price: '0',
period: '',
description: 'Perfect for exploring domain opportunities',
description: 'Explore the domain market',
features: [
'3 domains in watchlist',
'Daily availability checks',
'Email notifications',
'Basic WHOIS data',
{ text: '5 domains in watchlist', highlight: false },
{ text: 'Daily availability checks', highlight: false },
{ text: 'Email notifications', highlight: false },
{ text: 'Basic domain search', highlight: false },
{ text: 'TLD price overview', highlight: false },
],
cta: 'Start Free',
highlighted: false,
badge: null,
},
{
name: 'Professional',
price: '4.99',
period: '/month',
description: 'For domain investors and growing businesses',
name: 'Trader',
icon: TrendingUp,
price: '19',
period: '/mo',
description: 'For serious domain investors',
features: [
'25 domains in watchlist',
'Daily availability checks',
'Priority email notifications',
'Full WHOIS data',
'30-day check history',
'Expiration date tracking',
{ text: '50 domains in watchlist', highlight: true },
{ text: 'Hourly availability checks', highlight: true },
{ text: 'SMS & Telegram alerts', highlight: true },
{ text: 'Full TLD market data', highlight: false },
{ text: 'Domain valuation', highlight: true },
{ text: 'Portfolio tracking (25 domains)', highlight: true },
{ text: '90-day price history', highlight: false },
],
cta: 'Get Professional',
cta: 'Start Trading',
highlighted: true,
badge: 'Most Popular',
},
{
name: 'Enterprise',
price: '9.99',
period: '/month',
description: 'For agencies and large portfolios',
name: 'Tycoon',
icon: Crown,
price: '49',
period: '/mo',
description: 'For professionals & agencies',
features: [
'100 domains in watchlist',
'Hourly availability checks',
'Priority notifications',
'Full WHOIS data',
'Unlimited check history',
'Expiration date tracking',
'REST API access',
'Webhook integrations',
{ text: '500 domains in watchlist', highlight: true },
{ text: 'Real-time checks (10 min)', highlight: true },
{ text: 'Priority alerts + Webhooks', highlight: true },
{ text: 'Full REST API access', highlight: true },
{ text: 'SEO metrics (DA/PA)', highlight: true },
{ text: 'Unlimited portfolio', highlight: true },
{ text: 'Bulk import/export', highlight: true },
{ text: 'White-label reports', highlight: false },
],
cta: 'Get Enterprise',
cta: 'Go Tycoon',
highlighted: false,
badge: 'Best Value',
},
]
const comparisonFeatures = [
{ name: 'Watchlist Domains', scout: '5', trader: '50', tycoon: '500' },
{ name: 'Check Frequency', scout: 'Daily', trader: 'Hourly', tycoon: '10 min' },
{ name: 'Portfolio Domains', scout: '—', trader: '25', tycoon: 'Unlimited' },
{ name: 'Domain Valuation', scout: '—', trader: '✓', tycoon: '✓' },
{ name: 'SEO Metrics', scout: '—', trader: '—', tycoon: '✓' },
{ name: 'API Access', scout: '—', trader: '—', tycoon: '✓' },
{ name: 'Webhooks', scout: '—', trader: '—', tycoon: '✓' },
{ name: 'SMS/Telegram', scout: '—', trader: '✓', tycoon: '✓' },
]
const faqs = [
{
q: 'How does domain monitoring work?',
a: 'We perform automated WHOIS and DNS queries on your watchlist domains. When a domain\'s status changes, you\'ll receive an instant notification.',
a: 'We perform automated WHOIS, RDAP and DNS queries on your watchlist domains. When a domain becomes available or its status changes, you\'ll receive an instant notification via your preferred channel.',
},
{
q: 'Can I upgrade or downgrade?',
a: 'Yes. You can change your plan at any time. When upgrading, you\'ll get immediate access to additional features.',
q: 'What is domain valuation?',
a: 'Our valuation algorithm analyzes factors like domain length, TLD popularity, keyword value, brandability, and comparable sales to estimate a domain\'s market value.',
},
{
q: 'What payment methods do you accept?',
a: 'We accept all major credit cards and PayPal. All transactions are secured with 256-bit SSL encryption.',
q: 'Can I track domains I already own?',
a: 'Yes! With Trader and Tycoon plans, you can add domains to your portfolio to track their value, renewal dates, and overall ROI.',
},
{
q: 'How accurate is the SEO data?',
a: 'We integrate with industry-standard APIs to provide Domain Authority, Page Authority, and backlink data. Data is refreshed weekly for accuracy.',
},
{
q: 'Can I upgrade or downgrade anytime?',
a: 'Absolutely. You can change your plan at any time. Upgrades take effect immediately, and downgrades apply at the next billing cycle.',
},
{
q: 'Is there a money-back guarantee?',
a: 'Yes. If you\'re not satisfied within the first 14 days, we\'ll provide a full refund—no questions asked.',
a: 'Yes. We offer a 14-day money-back guarantee on all paid plans—no questions asked.',
},
]
export default function PricingPage() {
const { checkAuth, isLoading, isAuthenticated } = useStore()
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly')
useEffect(() => {
checkAuth()
}, [checkAuth])
const getPrice = (basePrice: string) => {
if (basePrice === '0') return '0'
const price = parseFloat(basePrice)
if (billingCycle === 'yearly') {
return (price * 10).toFixed(0) // 2 months free
}
return basePrice
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
@ -101,106 +139,222 @@ export default function PricingPage() {
<Header />
<main className="relative pt-32 sm:pt-36 md:pt-40 lg:pt-48 pb-16 sm:pb-20 md:pb-24 px-4 sm:px-6 flex-1">
<div className="max-w-5xl mx-auto">
<main className="relative pt-28 sm:pt-32 md:pt-36 pb-16 sm:pb-20 px-4 sm:px-6 flex-1">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="text-center mb-12 sm:mb-16 md:mb-20">
<div className="inline-flex items-center gap-2 sm:gap-2.5 px-3 sm:px-4 py-1.5 sm:py-2 bg-background-secondary/80 backdrop-blur-sm border border-border rounded-full mb-6 sm:mb-8 md:mb-10 animate-fade-in">
<div className="w-1.5 h-1.5 rounded-full bg-accent animate-glow-pulse" />
<span className="text-ui-sm sm:text-ui text-foreground-muted">Simple Pricing</span>
<div className="text-center mb-10 sm:mb-12">
<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 animate-fade-in">
<Briefcase className="w-4 h-4 text-accent" />
<span className="text-ui-sm text-foreground-muted">Pricing</span>
</div>
<h1 className="font-display text-[2.25rem] leading-[1.1] sm:text-[3rem] md:text-[3.75rem] lg:text-[4.5rem] tracking-[-0.035em] mb-4 sm:mb-5 md:mb-6 animate-slide-up">
<span className="text-foreground">Choose your plan</span>
<h1 className="font-display text-[2.25rem] sm:text-[3rem] md:text-[3.75rem] leading-[1.1] tracking-[-0.035em] text-foreground mb-4 animate-slide-up">
Invest in your domain strategy
</h1>
<p className="text-body-md sm:text-body-lg md:text-body-xl text-foreground-muted max-w-lg sm:max-w-xl mx-auto animate-slide-up delay-100 px-4 sm:px-0">
Start free and upgrade as your domain portfolio grows.
All plans include core monitoring features.
<p className="text-body-lg text-foreground-muted max-w-2xl mx-auto mb-8 animate-slide-up">
From hobbyist to professional domainer. Choose the plan that matches your ambition.
</p>
{/* Billing Toggle */}
<div className="inline-flex items-center gap-3 p-1.5 bg-background-secondary border border-border rounded-xl animate-slide-up">
<button
onClick={() => setBillingCycle('monthly')}
className={clsx(
"px-4 py-2 text-ui-sm font-medium rounded-lg transition-all",
billingCycle === 'monthly'
? "bg-foreground text-background"
: "text-foreground-muted hover:text-foreground"
)}
>
Monthly
</button>
<button
onClick={() => setBillingCycle('yearly')}
className={clsx(
"px-4 py-2 text-ui-sm font-medium rounded-lg transition-all flex items-center gap-2",
billingCycle === 'yearly'
? "bg-foreground text-background"
: "text-foreground-muted hover:text-foreground"
)}
>
Yearly
<span className="text-ui-xs px-1.5 py-0.5 bg-accent text-background rounded-full">
-17%
</span>
</button>
</div>
</div>
{/* Pricing Cards */}
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-4 sm:gap-5 mb-20 sm:mb-24 md:mb-32">
<div className="grid md:grid-cols-3 gap-4 sm:gap-6 mb-16 sm:mb-20">
{tiers.map((tier, i) => (
<div
key={tier.name}
className={`relative p-5 sm:p-6 md:p-7 rounded-2xl border transition-all duration-500 animate-slide-up ${
className={clsx(
"relative p-6 sm:p-8 rounded-2xl border transition-all duration-500 animate-slide-up",
tier.highlighted
? 'bg-background-secondary border-accent/20 glow-accent'
? 'bg-background-secondary border-accent/30 shadow-[0_0_60px_-20px_rgba(16,185,129,0.3)]'
: 'bg-background-secondary/50 border-border hover:border-border-hover'
}`}
style={{ animationDelay: `${150 + i * 100}ms` }}
)}
style={{ animationDelay: `${100 + i * 100}ms` }}
>
{tier.highlighted && (
{tier.badge && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="px-3 py-1 bg-accent text-background text-ui-xs sm:text-ui-sm font-medium rounded-full">
Popular
<span className={clsx(
"px-3 py-1 text-ui-xs font-medium rounded-full",
tier.highlighted
? "bg-accent text-background"
: "bg-foreground text-background"
)}>
{tier.badge}
</span>
</div>
)}
<div className="mb-5 sm:mb-6">
<h3 className="text-body sm:text-body-md font-medium text-foreground mb-1">{tier.name}</h3>
<p className="text-ui-sm sm:text-ui text-foreground-subtle mb-4 sm:mb-5 min-h-[40px]">{tier.description}</p>
<div className="flex items-baseline gap-1">
{tier.price === '0' ? (
<span className="text-heading-md sm:text-heading-lg font-display text-foreground">Free</span>
) : (
<>
<span className="text-heading-md sm:text-heading-lg font-display text-foreground">${tier.price}</span>
<span className="text-body-sm text-foreground-subtle">{tier.period}</span>
</>
)}
{/* Icon & Name */}
<div className="flex items-center gap-3 mb-4">
<div className={clsx(
"w-10 h-10 rounded-xl flex items-center justify-center",
tier.highlighted ? "bg-accent/20" : "bg-background-tertiary"
)}>
<tier.icon className={clsx(
"w-5 h-5",
tier.highlighted ? "text-accent" : "text-foreground-muted"
)} />
</div>
<h3 className="text-body-lg font-medium text-foreground">{tier.name}</h3>
</div>
<ul className="space-y-2.5 sm:space-y-3 mb-6 sm:mb-8">
<p className="text-body-sm text-foreground-muted mb-5">{tier.description}</p>
{/* Price */}
<div className="flex items-baseline gap-1 mb-6">
{tier.price === '0' ? (
<span className="text-[2.5rem] font-display text-foreground">Free</span>
) : (
<>
<span className="text-ui text-foreground-subtle"></span>
<span className="text-[2.5rem] font-display text-foreground leading-none">
{getPrice(tier.price)}
</span>
<span className="text-body-sm text-foreground-subtle">
/{billingCycle === 'yearly' ? 'year' : 'mo'}
</span>
</>
)}
</div>
{/* Features */}
<ul className="space-y-3 mb-8">
{tier.features.map((feature) => (
<li key={feature} className="flex items-start gap-2.5 sm:gap-3 text-body-xs sm:text-body-sm">
<Check className="w-3.5 sm:w-4 h-3.5 sm:h-4 text-accent shrink-0 mt-0.5" strokeWidth={2.5} />
<span className="text-foreground-muted">{feature}</span>
<li key={feature.text} className="flex items-start gap-3 text-body-sm">
<Check className={clsx(
"w-4 h-4 shrink-0 mt-0.5",
feature.highlight ? "text-accent" : "text-foreground-subtle"
)} strokeWidth={2.5} />
<span className={clsx(
feature.highlight ? "text-foreground" : "text-foreground-muted"
)}>
{feature.text}
</span>
</li>
))}
</ul>
{/* CTA */}
<Link
href={isAuthenticated ? '/dashboard' : '/register'}
className={`w-full flex items-center justify-center gap-2 py-2.5 sm:py-3 rounded-xl text-ui-sm sm:text-ui font-medium transition-all duration-300 ${
className={clsx(
"w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-ui font-medium transition-all",
tier.highlighted
? 'bg-accent text-background hover:bg-accent-hover'
: 'bg-background-tertiary text-foreground border border-border hover:border-border-hover'
}`}
: 'bg-foreground text-background hover:bg-foreground/90'
)}
>
{tier.cta}
<ArrowRight className="w-3.5 sm:w-4 h-3.5 sm:h-4" />
<ArrowRight className="w-4 h-4" />
</Link>
</div>
))}
</div>
{/* Feature Comparison Table */}
<div className="mb-16 sm:mb-20">
<h2 className="text-heading-md font-medium text-foreground text-center mb-8">
Compare Plans
</h2>
<div className="bg-background-secondary/30 border border-border rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="text-left text-ui-sm text-foreground-subtle font-medium px-6 py-4">Feature</th>
<th className="text-center text-ui-sm text-foreground-subtle font-medium px-4 py-4">Scout</th>
<th className="text-center text-ui-sm text-accent font-medium px-4 py-4 bg-accent/5">Trader</th>
<th className="text-center text-ui-sm text-foreground-subtle font-medium px-4 py-4">Tycoon</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{comparisonFeatures.map((feature) => (
<tr key={feature.name}>
<td className="text-body-sm text-foreground px-6 py-4">{feature.name}</td>
<td className="text-body-sm text-foreground-muted text-center px-4 py-4">{feature.scout}</td>
<td className="text-body-sm text-foreground text-center px-4 py-4 bg-accent/5 font-medium">{feature.trader}</td>
<td className="text-body-sm text-foreground-muted text-center px-4 py-4">{feature.tycoon}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
{/* Trust Badges */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-16 sm:mb-20">
{[
{ icon: Shield, text: '256-bit SSL' },
{ icon: Bell, text: 'Instant Alerts' },
{ icon: Clock, text: '99.9% Uptime' },
{ icon: Globe, text: '500+ TLDs' },
].map((item) => (
<div key={item.text} className="flex items-center justify-center gap-2 p-4 bg-background-secondary/30 border border-border rounded-xl">
<item.icon className="w-4 h-4 text-accent" />
<span className="text-ui-sm text-foreground-muted">{item.text}</span>
</div>
))}
</div>
{/* FAQ Section */}
<div className="max-w-3xl mx-auto">
<div className="text-center mb-8 sm:mb-10 md:mb-12">
<h2 className="font-display text-[1.75rem] sm:text-[2.5rem] md:text-[3.25rem] lg:text-[4rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-3 sm:mb-4">
Frequently asked questions
</h2>
<p className="text-body-sm sm:text-body md:text-body-lg text-foreground-muted">
Everything you need to know about pounce.
</p>
</div>
<h2 className="text-heading-md font-medium text-foreground text-center mb-8">
Frequently Asked Questions
</h2>
<div className="space-y-3 sm:space-y-4">
<div className="space-y-3">
{faqs.map((faq, i) => (
<div
key={i}
className="p-5 sm:p-6 bg-background-secondary/50 border border-border rounded-2xl
hover:border-border-hover transition-all duration-300"
className="p-5 sm:p-6 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover transition-all"
>
<h3 className="text-body sm:text-body-md font-medium text-foreground mb-2 sm:mb-3">{faq.q}</h3>
<p className="text-body-xs sm:text-body-sm text-foreground-muted leading-relaxed">{faq.a}</p>
<h3 className="text-body font-medium text-foreground mb-2">{faq.q}</h3>
<p className="text-body-sm text-foreground-muted leading-relaxed">{faq.a}</p>
</div>
))}
</div>
</div>
{/* CTA */}
<div className="mt-16 sm:mt-20 text-center">
<p className="text-body text-foreground-muted mb-6">
Not sure which plan is right for you?
</p>
<Link
href="/contact"
className="inline-flex items-center gap-2 px-6 py-3 bg-background-secondary border border-border text-foreground font-medium rounded-xl hover:border-border-hover transition-all"
>
Contact Sales
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
</main>

View File

@ -74,7 +74,8 @@ interface DomainCheckResult {
expiration_date?: string
}
// Registrar URLs
// Registrar URLs with affiliate parameters
// Note: Replace REF_CODE with actual affiliate IDs when available
const REGISTRAR_URLS: Record<string, string> = {
'Namecheap': 'https://www.namecheap.com/domains/registration/results/?domain=',
'Porkbun': 'https://porkbun.com/checkout/search?q=',
@ -82,6 +83,8 @@ const REGISTRAR_URLS: Record<string, string> = {
'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=',
'Hover': 'https://www.hover.com/domains/results?q=',
}
// Related TLDs

View File

@ -296,6 +296,148 @@ class ApiClient {
}>
}>('/tld-prices/trending')
}
// ============== Portfolio ==============
async getPortfolio(
status?: string,
sortBy = 'created_at',
sortOrder = 'desc',
limit = 100,
offset = 0
) {
const params = new URLSearchParams({
sort_by: sortBy,
sort_order: sortOrder,
limit: limit.toString(),
offset: offset.toString(),
})
if (status) {
params.append('status', status)
}
return this.request<PortfolioDomain[]>(`/portfolio?${params.toString()}`)
}
async getPortfolioSummary() {
return this.request<PortfolioSummary>('/portfolio/summary')
}
async addPortfolioDomain(data: PortfolioDomainCreate) {
return this.request<PortfolioDomain>('/portfolio', {
method: 'POST',
body: JSON.stringify(data),
})
}
async getPortfolioDomain(id: number) {
return this.request<PortfolioDomain>(`/portfolio/${id}`)
}
async updatePortfolioDomain(id: number, data: Partial<PortfolioDomainCreate>) {
return this.request<PortfolioDomain>(`/portfolio/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
async deletePortfolioDomain(id: number) {
return this.request<void>(`/portfolio/${id}`, {
method: 'DELETE',
})
}
async markDomainSold(id: number, saleDate: string, salePrice: number) {
return this.request<PortfolioDomain>(`/portfolio/${id}/sell`, {
method: 'POST',
body: JSON.stringify({
sale_date: saleDate,
sale_price: salePrice,
}),
})
}
async refreshDomainValue(id: number) {
return this.request<PortfolioDomain>(`/portfolio/${id}/refresh-value`, {
method: 'POST',
})
}
async getDomainValuation(domain: string) {
return this.request<DomainValuation>(`/portfolio/valuation/${domain}`)
}
}
// ============== Types ==============
export 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
}
export 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
}
export interface PortfolioDomainCreate {
domain: string
purchase_date?: string
purchase_price?: number
purchase_registrar?: string
registrar?: string
renewal_date?: string
renewal_cost?: number
auto_renew?: boolean
notes?: string
tags?: string
}
export interface DomainValuation {
domain: string
estimated_value: number
currency: string
scores: {
length: number
tld: number
keyword: number
brandability: number
overall: number
}
factors: {
length: number
tld: string
has_numbers: boolean
has_hyphens: boolean
is_dictionary_word: boolean
}
confidence: string
source: string
calculated_at: string
}
export const api = new ApiClient()