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 ### Core Functionality
- **Domain Availability Monitoring** — Track any domain and get notified when it becomes available - **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 - **TLD Price Intelligence** — Compare prices across 886+ TLDs from Porkbun API
- **Automated Price Scraping** — Daily cronjob scrapes real TLD prices from public APIs - **Automated Price Scraping** — Daily cronjob scrapes real TLD prices from public APIs
- **Price Change Alerts** — Email notifications when TLD prices change >5% - **Price Change Alerts** — Email notifications when TLD prices change >5%
@ -270,15 +272,17 @@ npm run dev
## Subscription Tiers ## Subscription Tiers
| Feature | Starter (Free) | Professional ($4.99/mo) | Enterprise ($9.99/mo) | | Feature | Scout (Free) | Trader (€19/mo) | Tycoon (€49/mo) |
|---------|----------------|------------------------|----------------------| |---------|--------------|-----------------|------------------|
| Domains | 3 | 25 | 100 | | Watchlist Domains | 5 | 50 | 500 |
| Check Frequency | Daily | Daily | Hourly | | Portfolio Domains | — | 25 | Unlimited |
| Notifications | Email | Priority Email | Priority Email | | Check Frequency | Daily | Hourly | 10 min |
| WHOIS Data | Basic | Full | Full | | Notifications | Email | SMS/Telegram | + Webhooks |
| Check History | — | 30 days | Unlimited | | Domain Valuation | — | ✓ | ✓ |
| Expiration Tracking | — | | ✓ | | SEO Metrics | — | | ✓ |
| Check History | — | 90 days | Unlimited |
| API Access | — | — | ✓ | | 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.subscription import router as subscription_router
from app.api.admin import router as admin_router from app.api.admin import router as admin_router
from app.api.tld_prices import router as tld_prices_router from app.api.tld_prices import router as tld_prices_router
from app.api.portfolio import router as portfolio_router
api_router = APIRouter() 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(domains_router, prefix="/domains", tags=["Domain Management"])
api_router.include_router(subscription_router, prefix="/subscription", tags=["Subscription"]) 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(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"]) 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.domain import Domain, DomainCheck
from app.models.subscription import Subscription from app.models.subscription import Subscription
from app.models.tld_price import TLDPrice, TLDInfo 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 = [ const tiers = [
{ {
name: 'Starter', name: 'Scout',
price: '0', price: '0',
period: '', period: '',
description: 'For individuals exploring domains', description: 'Explore the domain market',
features: ['3 domains', 'Daily checks', 'Email alerts', 'Basic WHOIS'], features: ['5 domains', 'Daily checks', 'Email alerts', 'Basic search'],
cta: 'Start Free', cta: 'Start Free',
highlighted: false, highlighted: false,
}, },
{ {
name: 'Professional', name: 'Trader',
price: '4.99', price: '19',
period: '/mo', period: '/mo',
description: 'For domain investors', description: 'For serious domain investors',
features: ['25 domains', 'Daily checks', 'Priority alerts', 'Full WHOIS', '30-day history'], features: ['50 domains', 'Hourly checks', 'SMS alerts', 'Domain valuation', 'Portfolio tracking'],
cta: 'Get Pro', cta: 'Start Trading',
highlighted: true, highlighted: true,
}, },
{ {
name: 'Enterprise', name: 'Tycoon',
price: '9.99', price: '49',
period: '/mo', period: '/mo',
description: 'For agencies & portfolios', description: 'For professionals & agencies',
features: ['100 domains', 'Hourly checks', 'Priority alerts', 'Full WHOIS', 'Unlimited history', 'API access'], features: ['500 domains', 'Real-time checks', 'API access', 'SEO metrics', 'Bulk tools'],
cta: 'Get Enterprise', cta: 'Go Tycoon',
highlighted: false, highlighted: false,
}, },
] ]

View File

@ -1,89 +1,127 @@
'use client' 'use client'
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { Header } from '@/components/Header' import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer' import { Footer } from '@/components/Footer'
import { useStore } from '@/lib/store' 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 Link from 'next/link'
import clsx from 'clsx'
const tiers = [ const tiers = [
{ {
name: 'Starter', name: 'Scout',
icon: Zap,
price: '0', price: '0',
period: '', period: '',
description: 'Perfect for exploring domain opportunities', description: 'Explore the domain market',
features: [ features: [
'3 domains in watchlist', { text: '5 domains in watchlist', highlight: false },
'Daily availability checks', { text: 'Daily availability checks', highlight: false },
'Email notifications', { text: 'Email notifications', highlight: false },
'Basic WHOIS data', { text: 'Basic domain search', highlight: false },
{ text: 'TLD price overview', highlight: false },
], ],
cta: 'Start Free', cta: 'Start Free',
highlighted: false, highlighted: false,
badge: null,
}, },
{ {
name: 'Professional', name: 'Trader',
price: '4.99', icon: TrendingUp,
period: '/month', price: '19',
description: 'For domain investors and growing businesses', period: '/mo',
description: 'For serious domain investors',
features: [ features: [
'25 domains in watchlist', { text: '50 domains in watchlist', highlight: true },
'Daily availability checks', { text: 'Hourly availability checks', highlight: true },
'Priority email notifications', { text: 'SMS & Telegram alerts', highlight: true },
'Full WHOIS data', { text: 'Full TLD market data', highlight: false },
'30-day check history', { text: 'Domain valuation', highlight: true },
'Expiration date tracking', { text: 'Portfolio tracking (25 domains)', highlight: true },
{ text: '90-day price history', highlight: false },
], ],
cta: 'Get Professional', cta: 'Start Trading',
highlighted: true, highlighted: true,
badge: 'Most Popular',
}, },
{ {
name: 'Enterprise', name: 'Tycoon',
price: '9.99', icon: Crown,
period: '/month', price: '49',
description: 'For agencies and large portfolios', period: '/mo',
description: 'For professionals & agencies',
features: [ features: [
'100 domains in watchlist', { text: '500 domains in watchlist', highlight: true },
'Hourly availability checks', { text: 'Real-time checks (10 min)', highlight: true },
'Priority notifications', { text: 'Priority alerts + Webhooks', highlight: true },
'Full WHOIS data', { text: 'Full REST API access', highlight: true },
'Unlimited check history', { text: 'SEO metrics (DA/PA)', highlight: true },
'Expiration date tracking', { text: 'Unlimited portfolio', highlight: true },
'REST API access', { text: 'Bulk import/export', highlight: true },
'Webhook integrations', { text: 'White-label reports', highlight: false },
], ],
cta: 'Get Enterprise', cta: 'Go Tycoon',
highlighted: false, 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 = [ const faqs = [
{ {
q: 'How does domain monitoring work?', 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?', q: 'What is domain valuation?',
a: 'Yes. You can change your plan at any time. When upgrading, you\'ll get immediate access to additional features.', 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?', q: 'Can I track domains I already own?',
a: 'We accept all major credit cards and PayPal. All transactions are secured with 256-bit SSL encryption.', 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?', 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() { export default function PricingPage() {
const { checkAuth, isLoading, isAuthenticated } = useStore() const { checkAuth, isLoading, isAuthenticated } = useStore()
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly')
useEffect(() => { useEffect(() => {
checkAuth() checkAuth()
}, [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) { if (isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
@ -101,106 +139,222 @@ export default function PricingPage() {
<Header /> <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"> <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-5xl mx-auto"> <div className="max-w-6xl mx-auto">
{/* Header */} {/* Header */}
<div className="text-center mb-12 sm:mb-16 md:mb-20"> <div className="text-center mb-10 sm:mb-12">
<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="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">
<div className="w-1.5 h-1.5 rounded-full bg-accent animate-glow-pulse" /> <Briefcase className="w-4 h-4 text-accent" />
<span className="text-ui-sm sm:text-ui text-foreground-muted">Simple Pricing</span> <span className="text-ui-sm text-foreground-muted">Pricing</span>
</div> </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"> <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">
<span className="text-foreground">Choose your plan</span> Invest in your domain strategy
</h1> </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"> <p className="text-body-lg text-foreground-muted max-w-2xl mx-auto mb-8 animate-slide-up">
Start free and upgrade as your domain portfolio grows. From hobbyist to professional domainer. Choose the plan that matches your ambition.
All plans include core monitoring features.
</p> </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> </div>
{/* Pricing Cards */} {/* 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) => ( {tiers.map((tier, i) => (
<div <div
key={tier.name} 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 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' : '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"> <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"> <span className={clsx(
Popular "px-3 py-1 text-ui-xs font-medium rounded-full",
tier.highlighted
? "bg-accent text-background"
: "bg-foreground text-background"
)}>
{tier.badge}
</span> </span>
</div> </div>
)} )}
<div className="mb-5 sm:mb-6"> {/* Icon & Name */}
<h3 className="text-body sm:text-body-md font-medium text-foreground mb-1">{tier.name}</h3> <div className="flex items-center gap-3 mb-4">
<p className="text-ui-sm sm:text-ui text-foreground-subtle mb-4 sm:mb-5 min-h-[40px]">{tier.description}</p> <div className={clsx(
<div className="flex items-baseline gap-1"> "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>
<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' ? ( {tier.price === '0' ? (
<span className="text-heading-md sm:text-heading-lg font-display text-foreground">Free</span> <span className="text-[2.5rem] 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-ui text-foreground-subtle"></span>
<span className="text-body-sm text-foreground-subtle">{tier.period}</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> </div>
</div>
<ul className="space-y-2.5 sm:space-y-3 mb-6 sm:mb-8"> {/* Features */}
<ul className="space-y-3 mb-8">
{tier.features.map((feature) => ( {tier.features.map((feature) => (
<li key={feature} className="flex items-start gap-2.5 sm:gap-3 text-body-xs sm:text-body-sm"> <li key={feature.text} className="flex items-start gap-3 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} /> <Check className={clsx(
<span className="text-foreground-muted">{feature}</span> "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> </li>
))} ))}
</ul> </ul>
{/* CTA */}
<Link <Link
href={isAuthenticated ? '/dashboard' : '/register'} 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 tier.highlighted
? 'bg-accent text-background hover:bg-accent-hover' ? '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} {tier.cta}
<ArrowRight className="w-3.5 sm:w-4 h-3.5 sm:h-4" /> <ArrowRight className="w-4 h-4" />
</Link> </Link>
</div> </div>
))} ))}
</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 */} {/* FAQ Section */}
<div className="max-w-3xl mx-auto"> <div className="max-w-3xl mx-auto">
<div className="text-center mb-8 sm:mb-10 md:mb-12"> <h2 className="text-heading-md font-medium text-foreground text-center mb-8">
<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
Frequently asked questions
</h2> </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>
<div className="space-y-3 sm:space-y-4"> <div className="space-y-3">
{faqs.map((faq, i) => ( {faqs.map((faq, i) => (
<div <div
key={i} key={i}
className="p-5 sm:p-6 bg-background-secondary/50 border border-border rounded-2xl className="p-5 sm:p-6 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover transition-all"
hover:border-border-hover transition-all duration-300"
> >
<h3 className="text-body sm:text-body-md font-medium text-foreground mb-2 sm:mb-3">{faq.q}</h3> <h3 className="text-body font-medium text-foreground mb-2">{faq.q}</h3>
<p className="text-body-xs sm:text-body-sm text-foreground-muted leading-relaxed">{faq.a}</p> <p className="text-body-sm text-foreground-muted leading-relaxed">{faq.a}</p>
</div> </div>
))} ))}
</div> </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> </div>
</main> </main>

View File

@ -74,7 +74,8 @@ interface DomainCheckResult {
expiration_date?: string 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> = { const REGISTRAR_URLS: Record<string, string> = {
'Namecheap': 'https://www.namecheap.com/domains/registration/results/?domain=', 'Namecheap': 'https://www.namecheap.com/domains/registration/results/?domain=',
'Porkbun': 'https://porkbun.com/checkout/search?q=', '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=', 'Google Domains': 'https://domains.google.com/registrar/search?searchTerm=',
'GoDaddy': 'https://www.godaddy.com/domainsearch/find?domainToCheck=', 'GoDaddy': 'https://www.godaddy.com/domainsearch/find?domainToCheck=',
'porkbun': 'https://porkbun.com/checkout/search?q=', '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 // Related TLDs

View File

@ -296,6 +296,148 @@ class ApiClient {
}> }>
}>('/tld-prices/trending') }>('/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() export const api = new ApiClient()