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:
20
README.md
20
README.md
@ -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 | — | — | ✓ |
|
||||
|
||||
---
|
||||
|
||||
|
||||
76
backend/alembic/versions/003_add_portfolio_tables.py
Normal file
76
backend/alembic/versions/003_add_portfolio_tables.py
Normal 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')
|
||||
|
||||
@ -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"])
|
||||
|
||||
|
||||
542
backend/app/api/portfolio.py
Normal file
542
backend/app/api/portfolio.py
Normal 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)
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
113
backend/app/models/portfolio.py
Normal file
113
backend/app/models/portfolio.py
Normal 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}>"
|
||||
|
||||
334
backend/app/services/valuation.py
Normal file
334
backend/app/services/valuation.py
Normal 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
@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
Reference in New Issue
Block a user