From 6e84103a5b38b04d9d84d83490994fa8bd1e6fae Mon Sep 17 00:00:00 2001 From: "yves.gugger" Date: Mon, 8 Dec 2025 11:08:18 +0100 Subject: [PATCH] feat: Complete business model expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 20 +- .../versions/003_add_portfolio_tables.py | 76 ++ backend/app/api/__init__.py | 2 + backend/app/api/portfolio.py | 542 +++++++++ backend/app/models/__init__.py | 12 +- backend/app/models/portfolio.py | 113 ++ backend/app/services/valuation.py | 334 ++++++ frontend/src/app/dashboard/page.tsx | 1066 ++++++++++++----- frontend/src/app/page.tsx | 26 +- frontend/src/app/pricing/page.tsx | 334 ++++-- frontend/src/app/tld-pricing/[tld]/page.tsx | 5 +- frontend/src/lib/api.ts | 142 +++ 12 files changed, 2263 insertions(+), 409 deletions(-) create mode 100644 backend/alembic/versions/003_add_portfolio_tables.py create mode 100644 backend/app/api/portfolio.py create mode 100644 backend/app/models/portfolio.py create mode 100644 backend/app/services/valuation.py diff --git a/README.md b/README.md index 796cb83..2f4e0b6 100644 --- a/README.md +++ b/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 | — | — | ✓ | --- diff --git a/backend/alembic/versions/003_add_portfolio_tables.py b/backend/alembic/versions/003_add_portfolio_tables.py new file mode 100644 index 0000000..2125840 --- /dev/null +++ b/backend/alembic/versions/003_add_portfolio_tables.py @@ -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') + diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index a9ecfd2..5310219 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -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"]) diff --git a/backend/app/api/portfolio.py b/backend/app/api/portfolio.py new file mode 100644 index 0000000..d9e33c6 --- /dev/null +++ b/backend/app/api/portfolio.py @@ -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) + diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5199672..7f6aae0 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/portfolio.py b/backend/app/models/portfolio.py new file mode 100644 index 0000000..2ad061e --- /dev/null +++ b/backend/app/models/portfolio.py @@ -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"" + + @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"" + diff --git a/backend/app/services/valuation.py b/backend/app/services/valuation.py new file mode 100644 index 0000000..9279c25 --- /dev/null +++ b/backend/app/services/valuation.py @@ -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() + diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index cbbe30f..f87e518 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import { useStore } from '@/lib/store' -import { api } from '@/lib/api' +import { api, PortfolioDomain, PortfolioSummary, DomainValuation } from '@/lib/api' import { Header } from '@/components/Header' import { Footer } from '@/components/Footer' import { @@ -23,10 +23,22 @@ import { Zap, Crown, TrendingUp, + TrendingDown, + Briefcase, + Eye, + DollarSign, + Tag, + MoreVertical, + Edit2, + ExternalLink, + Sparkles, + BarChart3, } from 'lucide-react' import clsx from 'clsx' import Link from 'next/link' +type TabType = 'watchlist' | 'portfolio' + interface DomainHistory { id: number status: string @@ -47,6 +59,10 @@ export default function DashboardPage() { refreshDomain, } = useStore() + // Tab state + const [activeTab, setActiveTab] = useState('watchlist') + + // Watchlist state const [newDomain, setNewDomain] = useState('') const [adding, setAdding] = useState(false) const [refreshingId, setRefreshingId] = useState(null) @@ -55,6 +71,28 @@ export default function DashboardPage() { const [domainHistory, setDomainHistory] = useState(null) const [loadingHistory, setLoadingHistory] = useState(false) + // Portfolio state + const [portfolio, setPortfolio] = useState([]) + const [portfolioSummary, setPortfolioSummary] = useState(null) + const [loadingPortfolio, setLoadingPortfolio] = useState(false) + const [showAddPortfolioModal, setShowAddPortfolioModal] = useState(false) + const [showValuationModal, setShowValuationModal] = useState(false) + const [valuationResult, setValuationResult] = useState(null) + const [valuatingDomain, setValuatingDomain] = useState('') + const [refreshingPortfolioId, setRefreshingPortfolioId] = useState(null) + + // Portfolio form state + const [portfolioForm, setPortfolioForm] = useState({ + domain: '', + purchase_price: '', + purchase_date: '', + registrar: '', + renewal_date: '', + renewal_cost: '', + notes: '', + }) + const [addingPortfolio, setAddingPortfolio] = useState(false) + useEffect(() => { checkAuth() }, [checkAuth]) @@ -65,6 +103,28 @@ export default function DashboardPage() { } }, [isLoading, isAuthenticated, router]) + useEffect(() => { + if (isAuthenticated && activeTab === 'portfolio') { + loadPortfolio() + } + }, [isAuthenticated, activeTab]) + + const loadPortfolio = async () => { + setLoadingPortfolio(true) + try { + const [portfolioData, summaryData] = await Promise.all([ + api.getPortfolio(), + api.getPortfolioSummary(), + ]) + setPortfolio(portfolioData) + setPortfolioSummary(summaryData) + } catch (err) { + console.error('Failed to load portfolio:', err) + } finally { + setLoadingPortfolio(false) + } + } + const handleAddDomain = async (e: React.FormEvent) => { e.preventDefault() if (!newDomain.trim()) return @@ -114,6 +174,75 @@ export default function DashboardPage() { } } + const handleAddPortfolioDomain = async (e: React.FormEvent) => { + e.preventDefault() + if (!portfolioForm.domain.trim()) return + + setAddingPortfolio(true) + try { + await api.addPortfolioDomain({ + domain: portfolioForm.domain, + purchase_price: portfolioForm.purchase_price ? parseFloat(portfolioForm.purchase_price) : undefined, + purchase_date: portfolioForm.purchase_date || undefined, + registrar: portfolioForm.registrar || undefined, + renewal_date: portfolioForm.renewal_date || undefined, + renewal_cost: portfolioForm.renewal_cost ? parseFloat(portfolioForm.renewal_cost) : undefined, + notes: portfolioForm.notes || undefined, + }) + setPortfolioForm({ + domain: '', + purchase_price: '', + purchase_date: '', + registrar: '', + renewal_date: '', + renewal_cost: '', + notes: '', + }) + setShowAddPortfolioModal(false) + loadPortfolio() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add domain to portfolio') + } finally { + setAddingPortfolio(false) + } + } + + const handleDeletePortfolioDomain = async (id: number) => { + if (!confirm('Remove this domain from your portfolio?')) return + try { + await api.deletePortfolioDomain(id) + loadPortfolio() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete domain') + } + } + + const handleRefreshPortfolioValue = async (id: number) => { + setRefreshingPortfolioId(id) + try { + await api.refreshDomainValue(id) + loadPortfolio() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to refresh value') + } finally { + setRefreshingPortfolioId(null) + } + } + + const handleGetValuation = async (domain: string) => { + setValuatingDomain(domain) + setShowValuationModal(true) + try { + const result = await api.getDomainValuation(domain) + setValuationResult(result) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to get valuation') + setShowValuationModal(false) + } finally { + setValuatingDomain('') + } + } + const formatDate = (dateStr: string | null) => { if (!dateStr) return 'Not checked yet' const date = new Date(dateStr) @@ -137,6 +266,16 @@ export default function DashboardPage() { return { text: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), urgent: false } } + const formatCurrency = (value: number | null) => { + if (value === null) return '—' + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value) + } + if (isLoading) { return (
@@ -161,11 +300,11 @@ export default function DashboardPage() { }).length const tierName = subscription?.tier_name || subscription?.tier || 'Starter' - const isProOrHigher = tierName === 'Professional' || tierName === 'Enterprise' - const isEnterprise = tierName === 'Enterprise' + const isProOrHigher = tierName === 'Professional' || tierName === 'Enterprise' || tierName === 'Trader' || tierName === 'Tycoon' + const isEnterprise = tierName === 'Enterprise' || tierName === 'Tycoon' return ( -
+
{/* Ambient glow */}
@@ -173,310 +312,441 @@ export default function DashboardPage() {
-
-
- {/* Header */} -
-
-

Your Watchlist

-

- {subscription?.domains_used || 0} of {subscription?.domain_limit || 3} domains - {availableCount > 0 && ( - · {availableCount} available - )} - {expiringCount > 0 && ( - · {expiringCount} expiring soon - )} -

+
+
+ {/* Header with Tabs */} +
+
+
+

+ Dashboard +

+

+ Manage your domains and track investments +

+
+ +
+ + {isEnterprise && } + {tierName} Plan + +
-
- - {isEnterprise && } - {tierName} Plan - - {!canAddMore && ( - - Upgrade - - - )} + {/* Tabs */} +
+ +
- {/* Stats Cards - For Pro+ users */} - {isProOrHigher && ( -
-
-
- - Tracked -
-

{domains.length}

-
-
-
- - Available -
-

{availableCount}

-
-
-
- - Expiring -
-

0 ? "text-warning" : "text-foreground" - )}>{expiringCount}

-
-
-
- - Check Freq -
-

- {subscription?.check_frequency || 'Daily'} -

-
-
- )} - - {/* Add Domain Form */} -
-
-
-
- -
- setNewDomain(e.target.value)} - placeholder="Add domain to watchlist (e.g., example.com)" - disabled={!canAddMore} - className="w-full pl-11 sm:pl-14 pr-4 sm:pr-5 py-3 sm:py-4 bg-background-secondary border border-border rounded-xl sm:rounded-2xl - text-body-sm sm:text-body text-foreground placeholder:text-foreground-subtle - focus:outline-none focus:border-border-hover - disabled:opacity-50 disabled:cursor-not-allowed - transition-all duration-300" - /> -
-
+ )} - {error && ( -

- - {error} - -

- )} -
+ {/* Watchlist Tab */} + {activeTab === 'watchlist' && ( +
+ {/* Stats */} + {isProOrHigher && ( +
+
+
+ + Tracked +
+

{domains.length}

+
+
+
+ + Available +
+

{availableCount}

+
+
+
+ + Expiring +
+

0 ? "text-warning" : "text-foreground" + )}>{expiringCount}

+
+
+
+ + Frequency +
+

+ {subscription?.check_frequency || 'Daily'} +

+
+
+ )} - {/* Domain List */} - {domains.length === 0 ? ( -
-
- -
-

Your watchlist is empty

-

- Add your first domain above to start monitoring -

-
- ) : ( -
- - - - - - {isProOrHigher && ( - - )} - - - - - - {domains.map((domain) => { - const expiration = formatExpirationDate(domain.expiration_date) - return ( - - - + {/* Add Domain Form */} + +
+
+
+ +
+ setNewDomain(e.target.value)} + placeholder="Add domain to watchlist (e.g., example.com)" + disabled={!canAddMore} + className="w-full pl-12 pr-4 py-3.5 bg-background-secondary border border-border rounded-xl + text-body text-foreground placeholder:text-foreground-subtle + focus:outline-none focus:border-border-hover + disabled:opacity-50 disabled:cursor-not-allowed + transition-all" + /> +
+ +
+ + + {/* Domain List */} + {domains.length === 0 ? ( +
+
+ +
+

Your watchlist is empty

+

+ Add your first domain above to start monitoring +

+
+ ) : ( +
+
DomainStatusExpirationLast CheckActions
-
-
- {domain.name} - {domain.is_available && ( - - Available - - )} -
-
- - {domain.is_available ? 'Available' : 'Registered'} - -
+ + + + {isProOrHigher && ( - + )} - - + + + + + {domains.map((domain) => { + const expiration = formatExpirationDate(domain.expiration_date) + return ( + + + + {isProOrHigher && ( + + )} + + + + ) + })} + +
DomainStatus - {expiration ? ( - - - {expiration.text} - - ) : ( - - )} - Expiration - - - {formatDate(domain.last_checked)} - - -
- {isProOrHigher && ( - - )} - - -
-
Last CheckActions
+
+
+ {domain.name} +
+
+ + {domain.is_available ? 'Available' : 'Registered'} + + + {expiration ? ( + + + {expiration.text} + + ) : ( + + )} + + + + {formatDate(domain.last_checked)} + + +
+ {isProOrHigher && ( + + )} + + + +
+
+
+ )} +
+ )} + + {/* Portfolio Tab */} + {activeTab === 'portfolio' && ( +
+ {/* Portfolio Summary */} + {portfolioSummary && ( +
+
+
+ + Total Value +
+

+ {formatCurrency(portfolioSummary.total_value)} +

+
+
+
+ + Invested +
+

+ {formatCurrency(portfolioSummary.total_invested)} +

+
+
+
+ + Unrealized P/L +
+

= 0 ? "text-accent" : "text-danger" + )}> + {portfolioSummary.unrealized_profit >= 0 ? '+' : ''}{formatCurrency(portfolioSummary.unrealized_profit)} +

+
+
+
+ + ROI +
+

= 0 ? "text-accent" : "text-danger" + )}> + {portfolioSummary.overall_roi >= 0 ? '+' : ''}{portfolioSummary.overall_roi.toFixed(1)}% +

+
+
+ )} + + {/* Add Domain Button */} +
+ +
+ + {/* Portfolio List */} + {loadingPortfolio ? ( +
+ +
+ ) : portfolio.length === 0 ? ( +
+
+ +
+

Your portfolio is empty

+

+ Add domains you own to track their value +

+
+ ) : ( +
+ {portfolio.map((domain) => { + const roi = domain.roi + const renewal = formatExpirationDate(domain.renewal_date) + return ( +
+
+
+
+

+ {domain.domain} +

+ {domain.status === 'sold' && ( + Sold + )} +
+
+ {domain.purchase_price && ( + + + Bought: {formatCurrency(domain.purchase_price)} + + )} + {domain.estimated_value && ( + + + Value: {formatCurrency(domain.estimated_value)} + + )} + {renewal && ( + + + Renewal: {renewal.text} + + )} + {domain.registrar && ( + + + {domain.registrar} + + )} +
+
+
+ {roi !== null && ( +
= 0 ? "bg-accent-muted" : "bg-danger-muted" + )}> +

ROI

+

= 0 ? "text-accent" : "text-danger" + )}> + {roi >= 0 ? '+' : ''}{roi.toFixed(1)}% +

+
+ )} +
+ + +
+
+
+ {domain.notes && ( +

+ {domain.notes} +

+ )} +
) })} - - +
+ )}
)} - {/* Features based on plan */} - {subscription && ( -
- {/* Check Frequency */} -
-
- - Check Frequency - {isEnterprise && ( - Hourly - )} -
-

- {subscription.check_frequency || 'Daily'} availability checks -

-
- - {/* History */} -
-
- - Check History - {!isProOrHigher && ( - Pro+ - )} -
-

- {isProOrHigher - ? `${subscription.history_days === -1 ? 'Unlimited' : `${subscription.history_days} days`} history` - : 'Upgrade for history access' - } -

-
- - {/* API Access */} -
-
- - API Access - {!isEnterprise && ( - Enterprise - )} -
-

- {isEnterprise - ? 'Full API access enabled' - : 'Upgrade for API access' - } -

-
-
- )} - - {/* TLD Pricing CTA */} -
+ {/* CTA */} +
@@ -494,14 +764,218 @@ export default function DashboardPage() {
- - {/* Info */} -

- Domains are checked automatically {subscription?.check_frequency === 'hourly' ? 'every hour' : 'every day at 06:00 UTC'} -

+ {/* Add Portfolio Domain Modal */} + {showAddPortfolioModal && ( +
+
+
+
+

Add Domain to Portfolio

+ +
+ +
+
+ + setPortfolioForm({ ...portfolioForm, domain: e.target.value })} + placeholder="example.com" + required + className="w-full px-4 py-3 bg-background border border-border rounded-xl text-body text-foreground + placeholder:text-foreground-subtle focus:outline-none focus:border-border-hover transition-all" + /> +
+ +
+
+ + setPortfolioForm({ ...portfolioForm, purchase_price: e.target.value })} + placeholder="$0.00" + className="w-full px-4 py-3 bg-background border border-border rounded-xl text-body text-foreground + placeholder:text-foreground-subtle focus:outline-none focus:border-border-hover transition-all" + /> +
+
+ + setPortfolioForm({ ...portfolioForm, purchase_date: e.target.value })} + className="w-full px-4 py-3 bg-background border border-border rounded-xl text-body text-foreground + focus:outline-none focus:border-border-hover transition-all" + /> +
+
+ +
+ + setPortfolioForm({ ...portfolioForm, registrar: e.target.value })} + placeholder="e.g., Porkbun, Namecheap" + className="w-full px-4 py-3 bg-background border border-border rounded-xl text-body text-foreground + placeholder:text-foreground-subtle focus:outline-none focus:border-border-hover transition-all" + /> +
+ +
+
+ + setPortfolioForm({ ...portfolioForm, renewal_date: e.target.value })} + className="w-full px-4 py-3 bg-background border border-border rounded-xl text-body text-foreground + focus:outline-none focus:border-border-hover transition-all" + /> +
+
+ + setPortfolioForm({ ...portfolioForm, renewal_cost: e.target.value })} + placeholder="$0.00" + className="w-full px-4 py-3 bg-background border border-border rounded-xl text-body text-foreground + placeholder:text-foreground-subtle focus:outline-none focus:border-border-hover transition-all" + /> +
+
+ +
+ +