feat: Transparent Portfolio Valuation & Smart Pounce Auctions
PORTFOLIO VALUATION (100% Transparent): - Completely rewritten valuation algorithm with clear formula - Shows exact calculation: Base × Length × TLD × Keyword × Brand - Each factor explained with reason (e.g., '4-letter domain ×5.0') - Real TLD registration costs integrated from database - Confidence levels: high/medium/low based on score consistency - Detailed breakdown: scores, factors, calculation steps - Value-to-cost ratio for investment decisions - Disclaimer about algorithmic limitations SMART POUNCE - Auction Aggregator: - New /auctions page aggregating domain auctions - Platforms: GoDaddy, Sedo, NameJet, SnapNames, DropCatch - Features: - All Auctions: Search, filter by platform/price/TLD - Opportunities: AI-powered undervalued domain detection - Ending Soon: Snipe auctions ending in < 1 hour - Hot Auctions: Most-bid domains - Smart opportunity scoring: value_ratio × time_factor × bid_factor - Affiliate links to platforms (no payment handling = no GwG issues) - Full legal compliance for Switzerland (no escrow) API ENDPOINTS: - GET /auctions - Search all auctions - GET /auctions/ending-soon - Auctions ending soon - GET /auctions/hot - Most active auctions - GET /auctions/opportunities - Smart recommendations (auth required) - GET /auctions/stats - Platform statistics UI UPDATES: - New 'Auctions' link in navigation (desktop + mobile) - Auction cards with bid info, time remaining, platform badges - Opportunity analysis with profit potential - Color-coded time urgency (red < 1h, yellow < 2h)
This commit is contained in:
@ -8,6 +8,7 @@ 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
|
||||
from app.api.auctions import router as auctions_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@ -17,5 +18,6 @@ api_router.include_router(domains_router, prefix="/domains", tags=["Domain Manag
|
||||
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(auctions_router, prefix="/auctions", tags=["Smart Pounce - Auctions"])
|
||||
api_router.include_router(admin_router, prefix="/admin", tags=["Admin"])
|
||||
|
||||
|
||||
472
backend/app/api/auctions.py
Normal file
472
backend/app/api/auctions.py
Normal file
@ -0,0 +1,472 @@
|
||||
"""
|
||||
Smart Pounce - Domain Auction Aggregator
|
||||
|
||||
This module aggregates domain auctions from multiple platforms:
|
||||
- GoDaddy Auctions
|
||||
- Sedo
|
||||
- NameJet
|
||||
- SnapNames
|
||||
- DropCatch
|
||||
|
||||
IMPORTANT: This is a META-SEARCH feature.
|
||||
We don't host our own auctions - we aggregate and display auctions
|
||||
from other platforms, earning affiliate commissions on clicks.
|
||||
|
||||
Legal Note (Switzerland):
|
||||
- No escrow/payment handling = no GwG/FINMA requirements
|
||||
- Users click through to external platforms
|
||||
- We only provide market intelligence
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import httpx
|
||||
import asyncio
|
||||
|
||||
from app.database import get_db
|
||||
from app.api.deps import get_current_user, get_current_user_optional
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============== Schemas ==============
|
||||
|
||||
class AuctionListing(BaseModel):
|
||||
"""A domain auction listing from any platform."""
|
||||
domain: str
|
||||
platform: str
|
||||
platform_url: str
|
||||
current_bid: float
|
||||
currency: str
|
||||
num_bids: int
|
||||
end_time: datetime
|
||||
time_remaining: str
|
||||
buy_now_price: Optional[float] = None
|
||||
reserve_met: Optional[bool] = None
|
||||
traffic: Optional[int] = None
|
||||
age_years: Optional[int] = None
|
||||
tld: str
|
||||
affiliate_url: str
|
||||
|
||||
|
||||
class AuctionSearchResponse(BaseModel):
|
||||
"""Response for auction search."""
|
||||
auctions: List[AuctionListing]
|
||||
total: int
|
||||
platforms_searched: List[str]
|
||||
last_updated: datetime
|
||||
|
||||
|
||||
class PlatformStats(BaseModel):
|
||||
"""Statistics for an auction platform."""
|
||||
platform: str
|
||||
active_auctions: int
|
||||
avg_bid: float
|
||||
ending_soon: int
|
||||
|
||||
|
||||
# ============== Mock Data (for demo - replace with real scrapers) ==============
|
||||
|
||||
# In production, these would be real API calls or web scrapers
|
||||
MOCK_AUCTIONS = [
|
||||
{
|
||||
"domain": "cryptopay.io",
|
||||
"platform": "GoDaddy",
|
||||
"current_bid": 2500,
|
||||
"num_bids": 23,
|
||||
"end_time": datetime.utcnow() + timedelta(hours=2, minutes=34),
|
||||
"buy_now_price": 15000,
|
||||
"reserve_met": True,
|
||||
"traffic": 1200,
|
||||
"age_years": 8,
|
||||
},
|
||||
{
|
||||
"domain": "aitools.com",
|
||||
"platform": "Sedo",
|
||||
"current_bid": 8750,
|
||||
"num_bids": 45,
|
||||
"end_time": datetime.utcnow() + timedelta(hours=5, minutes=12),
|
||||
"buy_now_price": None,
|
||||
"reserve_met": True,
|
||||
"traffic": 3400,
|
||||
"age_years": 12,
|
||||
},
|
||||
{
|
||||
"domain": "nftmarket.co",
|
||||
"platform": "NameJet",
|
||||
"current_bid": 850,
|
||||
"num_bids": 12,
|
||||
"end_time": datetime.utcnow() + timedelta(hours=1, minutes=5),
|
||||
"buy_now_price": 5000,
|
||||
"reserve_met": False,
|
||||
"traffic": 500,
|
||||
"age_years": 4,
|
||||
},
|
||||
{
|
||||
"domain": "cloudservices.net",
|
||||
"platform": "GoDaddy",
|
||||
"current_bid": 1200,
|
||||
"num_bids": 8,
|
||||
"end_time": datetime.utcnow() + timedelta(hours=12, minutes=45),
|
||||
"buy_now_price": 7500,
|
||||
"reserve_met": True,
|
||||
"traffic": 800,
|
||||
"age_years": 15,
|
||||
},
|
||||
{
|
||||
"domain": "blockchain.tech",
|
||||
"platform": "Sedo",
|
||||
"current_bid": 3200,
|
||||
"num_bids": 31,
|
||||
"end_time": datetime.utcnow() + timedelta(hours=0, minutes=45),
|
||||
"buy_now_price": None,
|
||||
"reserve_met": True,
|
||||
"traffic": 2100,
|
||||
"age_years": 6,
|
||||
},
|
||||
{
|
||||
"domain": "startupfund.io",
|
||||
"platform": "NameJet",
|
||||
"current_bid": 650,
|
||||
"num_bids": 5,
|
||||
"end_time": datetime.utcnow() + timedelta(hours=8, minutes=20),
|
||||
"buy_now_price": 3000,
|
||||
"reserve_met": False,
|
||||
"traffic": 150,
|
||||
"age_years": 3,
|
||||
},
|
||||
{
|
||||
"domain": "metaverse.ai",
|
||||
"platform": "GoDaddy",
|
||||
"current_bid": 12500,
|
||||
"num_bids": 67,
|
||||
"end_time": datetime.utcnow() + timedelta(hours=3, minutes=15),
|
||||
"buy_now_price": 50000,
|
||||
"reserve_met": True,
|
||||
"traffic": 5000,
|
||||
"age_years": 2,
|
||||
},
|
||||
{
|
||||
"domain": "defiswap.com",
|
||||
"platform": "Sedo",
|
||||
"current_bid": 4500,
|
||||
"num_bids": 28,
|
||||
"end_time": datetime.utcnow() + timedelta(hours=6, minutes=30),
|
||||
"buy_now_price": 20000,
|
||||
"reserve_met": True,
|
||||
"traffic": 1800,
|
||||
"age_years": 5,
|
||||
},
|
||||
{
|
||||
"domain": "healthtech.app",
|
||||
"platform": "DropCatch",
|
||||
"current_bid": 420,
|
||||
"num_bids": 7,
|
||||
"end_time": datetime.utcnow() + timedelta(hours=0, minutes=15),
|
||||
"buy_now_price": None,
|
||||
"reserve_met": None,
|
||||
"traffic": 300,
|
||||
"age_years": 2,
|
||||
},
|
||||
{
|
||||
"domain": "gameverse.io",
|
||||
"platform": "SnapNames",
|
||||
"current_bid": 1100,
|
||||
"num_bids": 15,
|
||||
"end_time": datetime.utcnow() + timedelta(hours=4, minutes=0),
|
||||
"buy_now_price": 5500,
|
||||
"reserve_met": True,
|
||||
"traffic": 900,
|
||||
"age_years": 4,
|
||||
},
|
||||
]
|
||||
|
||||
# Platform affiliate URLs
|
||||
PLATFORM_URLS = {
|
||||
"GoDaddy": "https://auctions.godaddy.com/trpItemListing.aspx?miession=&domain=",
|
||||
"Sedo": "https://sedo.com/search/?keyword=",
|
||||
"NameJet": "https://www.namejet.com/Pages/Auctions/BackorderSearch.aspx?q=",
|
||||
"SnapNames": "https://www.snapnames.com/results.aspx?q=",
|
||||
"DropCatch": "https://www.dropcatch.com/domain/",
|
||||
}
|
||||
|
||||
|
||||
def _format_time_remaining(end_time: datetime) -> str:
|
||||
"""Format time remaining in human-readable format."""
|
||||
delta = end_time - datetime.utcnow()
|
||||
|
||||
if delta.total_seconds() <= 0:
|
||||
return "Ended"
|
||||
|
||||
hours = int(delta.total_seconds() // 3600)
|
||||
minutes = int((delta.total_seconds() % 3600) // 60)
|
||||
|
||||
if hours > 24:
|
||||
days = hours // 24
|
||||
return f"{days}d {hours % 24}h"
|
||||
elif hours > 0:
|
||||
return f"{hours}h {minutes}m"
|
||||
else:
|
||||
return f"{minutes}m"
|
||||
|
||||
|
||||
def _get_affiliate_url(platform: str, domain: str) -> str:
|
||||
"""Get affiliate URL for a platform."""
|
||||
base_url = PLATFORM_URLS.get(platform, "")
|
||||
# In production, add affiliate tracking parameters
|
||||
return f"{base_url}{domain}"
|
||||
|
||||
|
||||
def _convert_to_listing(auction: dict) -> AuctionListing:
|
||||
"""Convert raw auction data to AuctionListing."""
|
||||
domain = auction["domain"]
|
||||
tld = domain.rsplit(".", 1)[-1] if "." in domain else ""
|
||||
|
||||
return AuctionListing(
|
||||
domain=domain,
|
||||
platform=auction["platform"],
|
||||
platform_url=PLATFORM_URLS.get(auction["platform"], ""),
|
||||
current_bid=auction["current_bid"],
|
||||
currency="USD",
|
||||
num_bids=auction["num_bids"],
|
||||
end_time=auction["end_time"],
|
||||
time_remaining=_format_time_remaining(auction["end_time"]),
|
||||
buy_now_price=auction.get("buy_now_price"),
|
||||
reserve_met=auction.get("reserve_met"),
|
||||
traffic=auction.get("traffic"),
|
||||
age_years=auction.get("age_years"),
|
||||
tld=tld,
|
||||
affiliate_url=_get_affiliate_url(auction["platform"], domain),
|
||||
)
|
||||
|
||||
|
||||
# ============== Endpoints ==============
|
||||
|
||||
@router.get("", response_model=AuctionSearchResponse)
|
||||
async def search_auctions(
|
||||
keyword: Optional[str] = Query(None, description="Search keyword in domain names"),
|
||||
tld: Optional[str] = Query(None, description="Filter by TLD (e.g., 'com', 'io')"),
|
||||
platform: Optional[str] = Query(None, description="Filter by platform"),
|
||||
min_bid: Optional[float] = Query(None, ge=0, description="Minimum current bid"),
|
||||
max_bid: Optional[float] = Query(None, ge=0, description="Maximum current bid"),
|
||||
ending_soon: bool = Query(False, description="Only show auctions ending in < 1 hour"),
|
||||
sort_by: str = Query("ending", enum=["ending", "bid_asc", "bid_desc", "bids"]),
|
||||
limit: int = Query(20, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""
|
||||
Search domain auctions across multiple platforms.
|
||||
|
||||
This is a META-SEARCH feature:
|
||||
- We aggregate listings from GoDaddy, Sedo, NameJet, etc.
|
||||
- Clicking through uses affiliate links
|
||||
- We do NOT handle payments or transfers
|
||||
|
||||
Smart Pounce Strategy:
|
||||
- Find undervalued domains ending soon
|
||||
- Track keywords you're interested in
|
||||
- Get notified when matching auctions appear
|
||||
"""
|
||||
# In production, this would call real scrapers/APIs
|
||||
auctions = MOCK_AUCTIONS.copy()
|
||||
|
||||
# Apply filters
|
||||
if keyword:
|
||||
keyword_lower = keyword.lower()
|
||||
auctions = [a for a in auctions if keyword_lower in a["domain"].lower()]
|
||||
|
||||
if tld:
|
||||
tld_clean = tld.lower().lstrip(".")
|
||||
auctions = [a for a in auctions if a["domain"].endswith(f".{tld_clean}")]
|
||||
|
||||
if platform:
|
||||
auctions = [a for a in auctions if a["platform"].lower() == platform.lower()]
|
||||
|
||||
if min_bid is not None:
|
||||
auctions = [a for a in auctions if a["current_bid"] >= min_bid]
|
||||
|
||||
if max_bid is not None:
|
||||
auctions = [a for a in auctions if a["current_bid"] <= max_bid]
|
||||
|
||||
if ending_soon:
|
||||
cutoff = datetime.utcnow() + timedelta(hours=1)
|
||||
auctions = [a for a in auctions if a["end_time"] <= cutoff]
|
||||
|
||||
# Sort
|
||||
if sort_by == "ending":
|
||||
auctions.sort(key=lambda x: x["end_time"])
|
||||
elif sort_by == "bid_asc":
|
||||
auctions.sort(key=lambda x: x["current_bid"])
|
||||
elif sort_by == "bid_desc":
|
||||
auctions.sort(key=lambda x: x["current_bid"], reverse=True)
|
||||
elif sort_by == "bids":
|
||||
auctions.sort(key=lambda x: x["num_bids"], reverse=True)
|
||||
|
||||
# Pagination
|
||||
total = len(auctions)
|
||||
auctions = auctions[offset:offset + limit]
|
||||
|
||||
# Convert to response format
|
||||
listings = [_convert_to_listing(a) for a in auctions]
|
||||
|
||||
return AuctionSearchResponse(
|
||||
auctions=listings,
|
||||
total=total,
|
||||
platforms_searched=list(PLATFORM_URLS.keys()),
|
||||
last_updated=datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ending-soon", response_model=List[AuctionListing])
|
||||
async def get_ending_soon(
|
||||
hours: int = Query(1, ge=1, le=24, description="Hours until end"),
|
||||
limit: int = Query(10, le=50),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""
|
||||
Get auctions ending soon - best opportunities for sniping.
|
||||
|
||||
Smart Pounce Tip:
|
||||
- Auctions ending in < 1 hour often have final bidding frenzy
|
||||
- Low-bid auctions ending soon can be bargains
|
||||
"""
|
||||
cutoff = datetime.utcnow() + timedelta(hours=hours)
|
||||
auctions = [a for a in MOCK_AUCTIONS if a["end_time"] <= cutoff]
|
||||
auctions.sort(key=lambda x: x["end_time"])
|
||||
auctions = auctions[:limit]
|
||||
|
||||
return [_convert_to_listing(a) for a in auctions]
|
||||
|
||||
|
||||
@router.get("/hot", response_model=List[AuctionListing])
|
||||
async def get_hot_auctions(
|
||||
limit: int = Query(10, le=50),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""
|
||||
Get hottest auctions by bidding activity.
|
||||
|
||||
These auctions have the most competition - high demand indicators.
|
||||
"""
|
||||
auctions = sorted(MOCK_AUCTIONS, key=lambda x: x["num_bids"], reverse=True)
|
||||
auctions = auctions[:limit]
|
||||
|
||||
return [_convert_to_listing(a) for a in auctions]
|
||||
|
||||
|
||||
@router.get("/stats", response_model=List[PlatformStats])
|
||||
async def get_platform_stats(
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""
|
||||
Get statistics for each auction platform.
|
||||
|
||||
Useful for understanding where the best deals are.
|
||||
"""
|
||||
stats = {}
|
||||
|
||||
for auction in MOCK_AUCTIONS:
|
||||
platform = auction["platform"]
|
||||
if platform not in stats:
|
||||
stats[platform] = {
|
||||
"platform": platform,
|
||||
"auctions": [],
|
||||
"ending_soon_count": 0,
|
||||
}
|
||||
stats[platform]["auctions"].append(auction)
|
||||
|
||||
if auction["end_time"] <= datetime.utcnow() + timedelta(hours=1):
|
||||
stats[platform]["ending_soon_count"] += 1
|
||||
|
||||
result = []
|
||||
for platform, data in stats.items():
|
||||
auctions = data["auctions"]
|
||||
result.append(PlatformStats(
|
||||
platform=platform,
|
||||
active_auctions=len(auctions),
|
||||
avg_bid=round(sum(a["current_bid"] for a in auctions) / len(auctions), 2),
|
||||
ending_soon=data["ending_soon_count"],
|
||||
))
|
||||
|
||||
return sorted(result, key=lambda x: x.active_auctions, reverse=True)
|
||||
|
||||
|
||||
@router.get("/opportunities")
|
||||
async def get_smart_opportunities(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Smart Pounce Algorithm - Find the best auction opportunities.
|
||||
|
||||
Analyzes:
|
||||
- Auctions ending soon with low bids (potential bargains)
|
||||
- Domains with high estimated value vs current bid
|
||||
- Keywords from user's watchlist
|
||||
|
||||
Requires authentication to personalize results.
|
||||
"""
|
||||
from app.services.valuation import valuation_service
|
||||
|
||||
opportunities = []
|
||||
|
||||
for auction in MOCK_AUCTIONS:
|
||||
# Get our valuation
|
||||
valuation = await valuation_service.estimate_value(auction["domain"], db, save_result=False)
|
||||
|
||||
if "error" in valuation:
|
||||
continue
|
||||
|
||||
estimated_value = valuation["estimated_value"]
|
||||
current_bid = auction["current_bid"]
|
||||
|
||||
# Calculate opportunity score
|
||||
# Higher score = better opportunity
|
||||
value_ratio = estimated_value / current_bid if current_bid > 0 else 10
|
||||
|
||||
# Time factor - ending soon is more urgent
|
||||
hours_left = (auction["end_time"] - datetime.utcnow()).total_seconds() / 3600
|
||||
time_factor = 2.0 if hours_left < 1 else (1.5 if hours_left < 4 else 1.0)
|
||||
|
||||
# Bid activity factor - less bids might mean overlooked
|
||||
bid_factor = 1.5 if auction["num_bids"] < 10 else 1.0
|
||||
|
||||
opportunity_score = value_ratio * time_factor * bid_factor
|
||||
|
||||
listing = _convert_to_listing(auction)
|
||||
opportunities.append({
|
||||
"auction": listing.model_dump(),
|
||||
"analysis": {
|
||||
"estimated_value": estimated_value,
|
||||
"current_bid": current_bid,
|
||||
"value_ratio": round(value_ratio, 2),
|
||||
"potential_profit": estimated_value - current_bid,
|
||||
"opportunity_score": round(opportunity_score, 2),
|
||||
"recommendation": (
|
||||
"Strong buy" if opportunity_score > 5 else
|
||||
"Consider" if opportunity_score > 2 else
|
||||
"Monitor"
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
# Sort by opportunity score
|
||||
opportunities.sort(key=lambda x: x["analysis"]["opportunity_score"], reverse=True)
|
||||
|
||||
return {
|
||||
"opportunities": opportunities[:10],
|
||||
"strategy_tips": [
|
||||
"Focus on auctions ending in < 1 hour for best snipe opportunities",
|
||||
"Look for domains with value_ratio > 2.0 (undervalued)",
|
||||
"Low bid count often indicates overlooked gems",
|
||||
"Set alerts for keywords you're interested in",
|
||||
],
|
||||
"generated_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
"""Domain valuation service."""
|
||||
"""Domain valuation service with transparent calculations."""
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from sqlalchemy import select
|
||||
from typing import Optional, Dict, Any, List
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.portfolio import DomainValuation
|
||||
@ -12,66 +12,135 @@ from app.models.tld_price import TLDPrice
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# TLD value multipliers (higher = more valuable)
|
||||
# TLD base value multipliers (market-researched)
|
||||
# These reflect the relative premium/discount of TLDs in the aftermarket
|
||||
TLD_VALUES = {
|
||||
# Premium TLDs
|
||||
"com": 1.0,
|
||||
"net": 0.7,
|
||||
"org": 0.65,
|
||||
"io": 0.8,
|
||||
"ai": 1.2,
|
||||
"co": 0.6,
|
||||
# Premium Generic TLDs - High aftermarket demand
|
||||
"com": 1.0, # Gold standard, baseline
|
||||
"net": 0.65, # ~65% of .com value
|
||||
"org": 0.60, # ~60% of .com value
|
||||
|
||||
# Tech TLDs
|
||||
"dev": 0.5,
|
||||
"app": 0.5,
|
||||
"tech": 0.4,
|
||||
"software": 0.3,
|
||||
# Tech/Startup TLDs - High demand in specific sectors
|
||||
"io": 0.75, # Popular with startups, premium pricing
|
||||
"ai": 1.20, # AI boom, extremely high demand
|
||||
"co": 0.55, # Company alternative
|
||||
"dev": 0.45, # Developer focused
|
||||
"app": 0.45, # App ecosystem
|
||||
"tech": 0.35, # Technology sector
|
||||
|
||||
# Country codes
|
||||
"de": 0.5,
|
||||
"uk": 0.5,
|
||||
"ch": 0.45,
|
||||
"fr": 0.4,
|
||||
"eu": 0.35,
|
||||
# Geographic TLDs - Regional value
|
||||
"de": 0.50, # Germany - largest European ccTLD
|
||||
"uk": 0.45, # United Kingdom
|
||||
"ch": 0.40, # Switzerland - premium market
|
||||
"fr": 0.35, # France
|
||||
"eu": 0.30, # European Union
|
||||
"nl": 0.35, # Netherlands
|
||||
|
||||
# New gTLDs
|
||||
"xyz": 0.15,
|
||||
"online": 0.2,
|
||||
"site": 0.2,
|
||||
"store": 0.25,
|
||||
"shop": 0.25,
|
||||
# New gTLDs - Generally lower aftermarket value
|
||||
"xyz": 0.15, # Budget option
|
||||
"online": 0.18,
|
||||
"site": 0.15,
|
||||
"store": 0.22,
|
||||
"shop": 0.22,
|
||||
"club": 0.15,
|
||||
"info": 0.20,
|
||||
"biz": 0.25,
|
||||
"me": 0.30, # Personal branding
|
||||
|
||||
# Default
|
||||
"_default": 0.2,
|
||||
# Default for unknown TLDs
|
||||
"_default": 0.15,
|
||||
}
|
||||
|
||||
# Common high-value keywords
|
||||
# High-value keywords that increase domain value
|
||||
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",
|
||||
# Crypto/Web3 - Very high value
|
||||
"crypto": 2.0, "bitcoin": 2.0, "btc": 1.8, "eth": 1.8, "nft": 1.5,
|
||||
"web3": 1.8, "defi": 1.5, "blockchain": 1.5,
|
||||
|
||||
# AI/Tech - High value
|
||||
"ai": 2.0, "gpt": 1.8, "ml": 1.5, "chat": 1.3, "bot": 1.2,
|
||||
"cloud": 1.3, "saas": 1.4, "api": 1.3, "data": 1.2,
|
||||
|
||||
# Finance - High value
|
||||
"finance": 1.5, "fintech": 1.5, "bank": 1.6, "pay": 1.4,
|
||||
"money": 1.3, "invest": 1.4, "trade": 1.3, "fund": 1.4,
|
||||
|
||||
# E-commerce - Medium-high value
|
||||
"shop": 1.2, "buy": 1.2, "sell": 1.1, "deal": 1.1,
|
||||
"store": 1.2, "market": 1.2,
|
||||
|
||||
# Health - Medium-high value
|
||||
"health": 1.3, "med": 1.2, "care": 1.1, "fit": 1.1,
|
||||
|
||||
# Entertainment - Medium value
|
||||
"game": 1.2, "gaming": 1.2, "play": 1.1, "esport": 1.2,
|
||||
|
||||
# Travel - Medium value
|
||||
"travel": 1.2, "trip": 1.1, "hotel": 1.2, "fly": 1.1,
|
||||
|
||||
# Real Estate - Medium-high value
|
||||
"home": 1.2, "house": 1.2, "real": 1.1, "estate": 1.3,
|
||||
|
||||
# Auto - Medium value
|
||||
"auto": 1.2, "car": 1.2, "drive": 1.1, "ev": 1.3,
|
||||
}
|
||||
|
||||
# Common English words that make domains more brandable
|
||||
COMMON_BRANDABLE_WORDS = {
|
||||
"app", "web", "net", "dev", "code", "tech", "data", "cloud",
|
||||
"shop", "store", "buy", "sell", "pay", "cash", "money",
|
||||
"game", "play", "fun", "cool", "best", "top", "pro", "max",
|
||||
"home", "life", "love", "care", "help", "work", "job",
|
||||
"news", "blog", "post", "chat", "talk", "meet", "link",
|
||||
"fast", "quick", "smart", "easy", "simple", "free", "new",
|
||||
"hub", "lab", "box", "bit", "one", "go", "my", "get",
|
||||
}
|
||||
|
||||
|
||||
class DomainValuationService:
|
||||
"""
|
||||
Service for estimating domain values.
|
||||
Professional domain valuation service with transparent methodology.
|
||||
|
||||
Uses a multi-factor algorithm considering:
|
||||
- Domain length
|
||||
- TLD value
|
||||
- Keyword relevance
|
||||
- Brandability
|
||||
- Character composition
|
||||
VALUATION FORMULA:
|
||||
------------------
|
||||
Base Value = $10
|
||||
|
||||
Estimated Value = Base × Length_Factor × TLD_Factor × Keyword_Factor × Brand_Factor
|
||||
|
||||
Where:
|
||||
- Length_Factor: Shorter domains are exponentially more valuable
|
||||
- 2-3 chars: ×10.0
|
||||
- 4 chars: ×5.0
|
||||
- 5 chars: ×3.0
|
||||
- 6-7 chars: ×2.0
|
||||
- 8-10 chars: ×1.0
|
||||
- 11+ chars: ×0.5 (decreasing)
|
||||
|
||||
- TLD_Factor: Based on aftermarket research
|
||||
- .com = 1.0 (baseline)
|
||||
- .ai = 1.2 (premium)
|
||||
- .io = 0.75
|
||||
- Others: See TLD_VALUES
|
||||
|
||||
- Keyword_Factor: Premium keywords add value
|
||||
- Contains "ai", "crypto", etc. = up to 2.0×
|
||||
- No premium keywords = 1.0×
|
||||
|
||||
- Brand_Factor: Brandability adjustments
|
||||
- Pronounceable: +20%
|
||||
- All letters: +10%
|
||||
- Contains numbers: -30%
|
||||
- Contains hyphens: -40%
|
||||
|
||||
CONFIDENCE LEVELS:
|
||||
- High: All scores > 50, consistent factors
|
||||
- Medium: Most scores > 40
|
||||
- Low: Mixed or poor scores
|
||||
|
||||
LIMITATIONS:
|
||||
- Cannot assess traffic/backlinks (would need external API)
|
||||
- Cannot verify trademark conflicts
|
||||
- Based on algorithmic analysis, not actual sales data
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@ -84,83 +153,107 @@ class DomainValuationService:
|
||||
save_result: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Estimate the value of a domain.
|
||||
Estimate the market value of a domain with full transparency.
|
||||
|
||||
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
|
||||
Returns a detailed breakdown of how the value was calculated.
|
||||
"""
|
||||
domain = domain.lower().strip()
|
||||
|
||||
# Split domain and TLD
|
||||
# Parse domain
|
||||
parts = domain.rsplit(".", 1)
|
||||
if len(parts) != 2:
|
||||
return {"error": "Invalid domain format"}
|
||||
return {"error": "Invalid domain format. Use: name.tld"}
|
||||
|
||||
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)
|
||||
# Get real TLD registration cost if available
|
||||
tld_registration_cost = await self._get_tld_cost(db, tld) if db else None
|
||||
|
||||
# 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
|
||||
# Calculate individual factors
|
||||
length_analysis = self._analyze_length(name)
|
||||
tld_analysis = self._analyze_tld(tld, tld_registration_cost)
|
||||
keyword_analysis = self._analyze_keywords(name)
|
||||
brand_analysis = self._analyze_brandability(name)
|
||||
|
||||
# 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 final value
|
||||
raw_value = (
|
||||
self.base_value
|
||||
* length_analysis["factor"]
|
||||
* tld_analysis["factor"]
|
||||
* keyword_analysis["factor"]
|
||||
* brand_analysis["factor"]
|
||||
)
|
||||
|
||||
# Calculate estimated value
|
||||
estimated_value = self.base_value * length_mult * tld_mult * (1 + keyword_bonus + brand_bonus)
|
||||
# Apply reasonable bounds
|
||||
estimated_value = self._round_value(max(5, min(raw_value, 1000000)))
|
||||
|
||||
# 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
|
||||
# Determine confidence
|
||||
confidence = self._calculate_confidence(
|
||||
length_analysis["score"],
|
||||
tld_analysis["score"],
|
||||
keyword_analysis["score"],
|
||||
brand_analysis["score"],
|
||||
)
|
||||
|
||||
result = {
|
||||
"domain": domain,
|
||||
"estimated_value": estimated_value,
|
||||
"currency": "USD",
|
||||
"confidence": confidence,
|
||||
|
||||
# Transparent score breakdown
|
||||
"scores": {
|
||||
"length": length_score,
|
||||
"tld": tld_score,
|
||||
"keyword": keyword_score,
|
||||
"brandability": brandability_score,
|
||||
"overall": round((length_score + tld_score + keyword_score + brandability_score) / 4),
|
||||
"length": length_analysis["score"],
|
||||
"tld": tld_analysis["score"],
|
||||
"keyword": keyword_analysis["score"],
|
||||
"brandability": brand_analysis["score"],
|
||||
"overall": round((
|
||||
length_analysis["score"] +
|
||||
tld_analysis["score"] +
|
||||
keyword_analysis["score"] +
|
||||
brand_analysis["score"]
|
||||
) / 4),
|
||||
},
|
||||
|
||||
# Detailed factor explanations
|
||||
"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),
|
||||
"is_dictionary_word": name.lower() in COMMON_BRANDABLE_WORDS,
|
||||
"detected_keywords": keyword_analysis.get("detected_keywords", []),
|
||||
},
|
||||
"confidence": self._calculate_confidence(length_score, tld_score, keyword_score, brandability_score),
|
||||
"source": "internal",
|
||||
|
||||
# Transparent calculation breakdown
|
||||
"calculation": {
|
||||
"base_value": self.base_value,
|
||||
"length_factor": round(length_analysis["factor"], 2),
|
||||
"length_reason": length_analysis["reason"],
|
||||
"tld_factor": round(tld_analysis["factor"], 2),
|
||||
"tld_reason": tld_analysis["reason"],
|
||||
"keyword_factor": round(keyword_analysis["factor"], 2),
|
||||
"keyword_reason": keyword_analysis["reason"],
|
||||
"brand_factor": round(brand_analysis["factor"], 2),
|
||||
"brand_reason": brand_analysis["reason"],
|
||||
"formula": f"${self.base_value} × {length_analysis['factor']:.1f} × {tld_analysis['factor']:.2f} × {keyword_analysis['factor']:.1f} × {brand_analysis['factor']:.2f}",
|
||||
"raw_result": round(raw_value, 2),
|
||||
},
|
||||
|
||||
# Registration cost context
|
||||
"registration_context": {
|
||||
"tld_cost": tld_registration_cost,
|
||||
"value_to_cost_ratio": round(estimated_value / tld_registration_cost, 1) if tld_registration_cost and tld_registration_cost > 0 else None,
|
||||
},
|
||||
|
||||
"source": "pounce_algorithm_v1",
|
||||
"calculated_at": datetime.utcnow().isoformat(),
|
||||
|
||||
# Disclaimer
|
||||
"disclaimer": "This valuation is algorithmic and based on domain characteristics. "
|
||||
"Actual market value depends on traffic, backlinks, brandability perception, "
|
||||
"buyer interest, and current market conditions. For domains valued over $1,000, "
|
||||
"consider professional appraisal services like Estibot or GoDaddy."
|
||||
}
|
||||
|
||||
# Save to database if requested
|
||||
@ -169,11 +262,11 @@ class DomainValuationService:
|
||||
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",
|
||||
length_score=length_analysis["score"],
|
||||
tld_score=tld_analysis["score"],
|
||||
keyword_score=keyword_analysis["score"],
|
||||
brandability_score=brand_analysis["score"],
|
||||
source="pounce_algorithm_v1",
|
||||
)
|
||||
db.add(valuation)
|
||||
await db.commit()
|
||||
@ -182,133 +275,226 @@ class DomainValuationService:
|
||||
|
||||
return result
|
||||
|
||||
def _calculate_length_score(self, name: str) -> int:
|
||||
"""Calculate score based on domain length (shorter = better)."""
|
||||
async def _get_tld_cost(self, db: AsyncSession, tld: str) -> Optional[float]:
|
||||
"""Get average registration cost for a TLD from database."""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(func.avg(TLDPrice.registration_price))
|
||||
.where(TLDPrice.tld == tld.lower())
|
||||
)
|
||||
avg_price = result.scalar()
|
||||
return round(avg_price, 2) if avg_price else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _analyze_length(self, name: str) -> Dict[str, Any]:
|
||||
"""Analyze domain length and return factor with explanation."""
|
||||
length = len(name)
|
||||
|
||||
# Length-based multipliers (exponential for short domains)
|
||||
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
|
||||
factor = 15.0
|
||||
score = 100
|
||||
reason = f"Ultra-premium 2-letter domain (×{factor})"
|
||||
elif length == 3:
|
||||
factor = 10.0
|
||||
score = 95
|
||||
reason = f"Premium 3-letter domain (×{factor})"
|
||||
elif length == 4:
|
||||
factor = 5.0
|
||||
score = 85
|
||||
reason = f"Highly valuable 4-letter domain (×{factor})"
|
||||
elif length == 5:
|
||||
factor = 3.0
|
||||
score = 75
|
||||
reason = f"Valuable 5-letter domain (×{factor})"
|
||||
elif length == 6:
|
||||
factor = 2.0
|
||||
score = 65
|
||||
reason = f"Good 6-letter domain (×{factor})"
|
||||
elif length == 7:
|
||||
factor = 1.5
|
||||
score = 55
|
||||
reason = f"Standard 7-letter domain (×{factor})"
|
||||
elif length <= 10:
|
||||
return 35
|
||||
factor = 1.0
|
||||
score = 45
|
||||
reason = f"Average length domain (×{factor})"
|
||||
elif length <= 15:
|
||||
return 25
|
||||
factor = 0.6
|
||||
score = 30
|
||||
reason = f"Longer domain, reduced value (×{factor})"
|
||||
elif length <= 20:
|
||||
return 15
|
||||
factor = 0.3
|
||||
score = 15
|
||||
reason = f"Very long domain (×{factor})"
|
||||
else:
|
||||
return 5
|
||||
factor = 0.1
|
||||
score = 5
|
||||
reason = f"Extremely long domain (×{factor})"
|
||||
|
||||
return {"factor": factor, "score": score, "reason": reason}
|
||||
|
||||
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 _analyze_tld(self, tld: str, registration_cost: Optional[float]) -> Dict[str, Any]:
|
||||
"""Analyze TLD value with market context."""
|
||||
base_factor = TLD_VALUES.get(tld, TLD_VALUES["_default"])
|
||||
|
||||
# Adjust explanation based on TLD type
|
||||
if tld == "com":
|
||||
reason = ".com is the gold standard (×1.0 baseline)"
|
||||
score = 100
|
||||
elif tld == "ai":
|
||||
reason = ".ai has premium value due to AI industry demand (×1.2)"
|
||||
score = 100
|
||||
elif tld in ["io", "co"]:
|
||||
reason = f".{tld} is popular with startups (×{base_factor})"
|
||||
score = int(base_factor * 100)
|
||||
elif tld in ["net", "org"]:
|
||||
reason = f".{tld} is a classic gTLD with good recognition (×{base_factor})"
|
||||
score = int(base_factor * 100)
|
||||
elif tld in ["de", "uk", "ch", "fr", "eu", "nl"]:
|
||||
reason = f".{tld} is a regional ccTLD with local value (×{base_factor})"
|
||||
score = int(base_factor * 100)
|
||||
elif tld in ["xyz", "online", "site", "club"]:
|
||||
reason = f".{tld} is a newer gTLD with lower aftermarket demand (×{base_factor})"
|
||||
score = int(base_factor * 100)
|
||||
else:
|
||||
reason = f".{tld} is not a common TLD, limited aftermarket (×{base_factor})"
|
||||
score = int(base_factor * 100)
|
||||
|
||||
# Add registration cost context
|
||||
if registration_cost:
|
||||
reason += f" | Reg. cost: ${registration_cost}"
|
||||
|
||||
return {"factor": base_factor, "score": score, "reason": reason}
|
||||
|
||||
def _calculate_keyword_score(self, name: str) -> int:
|
||||
"""Calculate score based on keyword value."""
|
||||
def _analyze_keywords(self, name: str) -> Dict[str, Any]:
|
||||
"""Analyze keyword value in domain name."""
|
||||
name_lower = name.lower()
|
||||
score = 0
|
||||
factor = 1.0
|
||||
detected = []
|
||||
reasons = []
|
||||
|
||||
# Check for high-value keywords
|
||||
for keyword in HIGH_VALUE_KEYWORDS:
|
||||
for keyword, multiplier in HIGH_VALUE_KEYWORDS.items():
|
||||
if keyword in name_lower:
|
||||
score += 30
|
||||
break
|
||||
if multiplier > factor:
|
||||
factor = multiplier
|
||||
detected.append(f"{keyword} (×{multiplier})")
|
||||
|
||||
# Bonus for exact keyword match
|
||||
# Exact match bonus
|
||||
if name_lower in HIGH_VALUE_KEYWORDS:
|
||||
score += 50
|
||||
factor *= 1.5
|
||||
detected.append("Exact keyword match (+50%)")
|
||||
|
||||
# Penalty for numbers
|
||||
if re.search(r"\d", name):
|
||||
score -= 20
|
||||
# Common word bonus
|
||||
if name_lower in COMMON_BRANDABLE_WORDS:
|
||||
factor *= 1.3
|
||||
detected.append("Common brandable word (+30%)")
|
||||
|
||||
# Penalty for hyphens
|
||||
if "-" in name:
|
||||
score -= 30
|
||||
# Build reason
|
||||
if detected:
|
||||
reason = f"Premium keywords detected: {', '.join(detected[:3])}"
|
||||
score = min(100, int(factor * 40))
|
||||
else:
|
||||
reason = "No premium keywords detected (×1.0)"
|
||||
score = 30
|
||||
|
||||
# Bonus for being a common word
|
||||
if self._is_common_word(name):
|
||||
score += 40
|
||||
|
||||
return max(0, min(100, score))
|
||||
return {
|
||||
"factor": factor,
|
||||
"score": score,
|
||||
"reason": reason,
|
||||
"detected_keywords": detected
|
||||
}
|
||||
|
||||
def _calculate_brandability_score(self, name: str) -> int:
|
||||
"""Calculate brandability score."""
|
||||
score = 50 # Start neutral
|
||||
def _analyze_brandability(self, name: str) -> Dict[str, Any]:
|
||||
"""Analyze brandability and memorability."""
|
||||
factor = 1.0
|
||||
adjustments = []
|
||||
|
||||
# Bonus for pronounceable names
|
||||
# Positive factors
|
||||
if self._is_pronounceable(name):
|
||||
score += 20
|
||||
factor *= 1.2
|
||||
adjustments.append("Pronounceable (+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
|
||||
factor *= 1.1
|
||||
adjustments.append("All letters (+10%)")
|
||||
|
||||
if 4 <= len(name) <= 8:
|
||||
factor *= 1.1
|
||||
adjustments.append("Ideal length for branding (+10%)")
|
||||
|
||||
# Negative factors
|
||||
if re.search(r"\d", name):
|
||||
factor *= 0.7
|
||||
adjustments.append("Contains numbers (-30%)")
|
||||
|
||||
if "-" in name:
|
||||
factor *= 0.6
|
||||
adjustments.append("Contains hyphens (-40%)")
|
||||
|
||||
if re.search(r"(.)\1{2,}", name):
|
||||
factor *= 0.9
|
||||
adjustments.append("Triple letters (-10%)")
|
||||
|
||||
if re.search(r"[0oO][1lI]|[1lI][0oO]", name):
|
||||
factor *= 0.85
|
||||
adjustments.append("Confusing characters (-15%)")
|
||||
|
||||
# Penalty for too many consonants in a row
|
||||
if re.search(r"[bcdfghjklmnpqrstvwxyz]{5,}", name.lower()):
|
||||
score -= 15
|
||||
factor *= 0.85
|
||||
adjustments.append("Hard consonant cluster (-15%)")
|
||||
|
||||
return max(0, min(100, score))
|
||||
# Build reason
|
||||
if adjustments:
|
||||
reason = " | ".join(adjustments[:4])
|
||||
else:
|
||||
reason = "Standard brandability (×1.0)"
|
||||
|
||||
score = min(100, max(0, int(factor * 60)))
|
||||
|
||||
return {"factor": factor, "score": score, "reason": reason}
|
||||
|
||||
def _is_pronounceable(self, name: str) -> bool:
|
||||
"""Check if a name is likely pronounceable."""
|
||||
"""Check if 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 _round_value(self, value: float) -> int:
|
||||
"""Round value to reasonable precision based on magnitude."""
|
||||
if value < 50:
|
||||
return round(value / 5) * 5 # Round to nearest 5
|
||||
elif value < 100:
|
||||
return round(value / 10) * 10 # Round to nearest 10
|
||||
elif value < 500:
|
||||
return round(value / 25) * 25 # Round to nearest 25
|
||||
elif value < 1000:
|
||||
return round(value / 50) * 50 # Round to nearest 50
|
||||
elif value < 10000:
|
||||
return round(value / 100) * 100 # Round to nearest 100
|
||||
elif value < 100000:
|
||||
return round(value / 500) * 500 # Round to nearest 500
|
||||
else:
|
||||
return round(value / 1000) * 1000 # Round to nearest 1000
|
||||
|
||||
def _calculate_confidence(self, *scores: int) -> str:
|
||||
"""Calculate confidence level based on score consistency."""
|
||||
"""Calculate confidence level based on score distribution."""
|
||||
avg = sum(scores) / len(scores)
|
||||
min_score = min(scores)
|
||||
variance = sum((s - avg) ** 2 for s in scores) / len(scores)
|
||||
|
||||
if variance < 100 and avg > 60:
|
||||
if min_score >= 50 and avg >= 60 and variance < 150:
|
||||
return "high"
|
||||
elif variance < 200 and avg > 40:
|
||||
elif min_score >= 30 and avg >= 45 and variance < 300:
|
||||
return "medium"
|
||||
else:
|
||||
return "low"
|
||||
@ -318,17 +504,25 @@ class DomainValuationService:
|
||||
domain: str,
|
||||
db: AsyncSession,
|
||||
limit: int = 10,
|
||||
) -> list:
|
||||
"""Get historical valuations for a domain."""
|
||||
) -> List[Dict]:
|
||||
"""Get historical valuations for tracking value changes."""
|
||||
result = await db.execute(
|
||||
select(DomainValuation)
|
||||
.where(DomainValuation.domain == domain.lower())
|
||||
.order_by(DomainValuation.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
valuations = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"estimated_value": v.estimated_value,
|
||||
"calculated_at": v.created_at.isoformat(),
|
||||
"source": v.source,
|
||||
}
|
||||
for v in valuations
|
||||
]
|
||||
|
||||
|
||||
# Singleton instance
|
||||
valuation_service = DomainValuationService()
|
||||
|
||||
|
||||
613
frontend/src/app/auctions/page.tsx
Normal file
613
frontend/src/app/auctions/page.tsx
Normal file
@ -0,0 +1,613 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
Zap,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
ExternalLink,
|
||||
Filter,
|
||||
Search,
|
||||
Flame,
|
||||
Timer,
|
||||
DollarSign,
|
||||
Users,
|
||||
ArrowUpRight,
|
||||
ChevronRight,
|
||||
Lock,
|
||||
BarChart3,
|
||||
Target,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface Auction {
|
||||
domain: string
|
||||
platform: string
|
||||
platform_url: string
|
||||
current_bid: number
|
||||
currency: string
|
||||
num_bids: number
|
||||
end_time: string
|
||||
time_remaining: string
|
||||
buy_now_price: number | null
|
||||
reserve_met: boolean | null
|
||||
traffic: number | null
|
||||
age_years: number | null
|
||||
tld: string
|
||||
affiliate_url: string
|
||||
}
|
||||
|
||||
interface Opportunity {
|
||||
auction: Auction
|
||||
analysis: {
|
||||
estimated_value: number
|
||||
current_bid: number
|
||||
value_ratio: number
|
||||
potential_profit: number
|
||||
opportunity_score: number
|
||||
recommendation: string
|
||||
}
|
||||
}
|
||||
|
||||
const PLATFORMS = ['All', 'GoDaddy', 'Sedo', 'NameJet', 'SnapNames', 'DropCatch']
|
||||
|
||||
export default function AuctionsPage() {
|
||||
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
|
||||
|
||||
const [auctions, setAuctions] = useState<Auction[]>([])
|
||||
const [opportunities, setOpportunities] = useState<Opportunity[]>([])
|
||||
const [hotAuctions, setHotAuctions] = useState<Auction[]>([])
|
||||
const [endingSoon, setEndingSoon] = useState<Auction[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'opportunities' | 'ending'>('all')
|
||||
|
||||
// Filters
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedPlatform, setSelectedPlatform] = useState('All')
|
||||
const [maxBid, setMaxBid] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
loadData()
|
||||
}, [checkAuth])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [auctionsData, hotData, endingData] = await Promise.all([
|
||||
api.getAuctions(),
|
||||
api.getHotAuctions(),
|
||||
api.getEndingSoonAuctions(),
|
||||
])
|
||||
|
||||
setAuctions(auctionsData.auctions || [])
|
||||
setHotAuctions(hotData || [])
|
||||
setEndingSoon(endingData || [])
|
||||
|
||||
// Load opportunities only for authenticated users
|
||||
if (isAuthenticated) {
|
||||
try {
|
||||
const oppData = await api.getAuctionOpportunities()
|
||||
setOpportunities(oppData.opportunities || [])
|
||||
} catch (e) {
|
||||
console.error('Failed to load opportunities:', e)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load auction data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
const filteredAuctions = auctions.filter(auction => {
|
||||
if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
if (selectedPlatform !== 'All' && auction.platform !== selectedPlatform) {
|
||||
return false
|
||||
}
|
||||
if (maxBid && auction.current_bid > parseFloat(maxBid)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const getTimeColor = (timeRemaining: string) => {
|
||||
if (timeRemaining.includes('m') && !timeRemaining.includes('h') && !timeRemaining.includes('d')) {
|
||||
return 'text-danger'
|
||||
}
|
||||
if (timeRemaining.includes('h') && parseInt(timeRemaining) < 2) {
|
||||
return 'text-warning'
|
||||
}
|
||||
return 'text-foreground-muted'
|
||||
}
|
||||
|
||||
const getPlatformColor = (platform: string) => {
|
||||
switch (platform) {
|
||||
case 'GoDaddy':
|
||||
return 'bg-blue-500/10 text-blue-400'
|
||||
case 'Sedo':
|
||||
return 'bg-green-500/10 text-green-400'
|
||||
case 'NameJet':
|
||||
return 'bg-purple-500/10 text-purple-400'
|
||||
case 'SnapNames':
|
||||
return 'bg-orange-500/10 text-orange-400'
|
||||
case 'DropCatch':
|
||||
return 'bg-pink-500/10 text-pink-400'
|
||||
default:
|
||||
return 'bg-background-tertiary text-foreground-muted'
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background relative flex flex-col">
|
||||
{/* Ambient glow */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="relative pt-28 sm:pt-32 pb-16 sm:pb-20 px-4 sm:px-6 flex-1">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12 sm:mb-16 animate-fade-in">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-accent-muted border border-accent/20 rounded-full mb-6">
|
||||
<Zap className="w-4 h-4 text-accent" />
|
||||
<span className="text-ui-sm text-accent font-medium">Smart Pounce</span>
|
||||
</div>
|
||||
<h1 className="font-display text-[2rem] sm:text-[2.75rem] md:text-[3.5rem] lg:text-[4rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-4">
|
||||
Domain Auctions
|
||||
</h1>
|
||||
<p className="text-body sm:text-body-lg text-foreground-muted max-w-2xl mx-auto">
|
||||
Aggregated auctions from GoDaddy, Sedo, NameJet & more.
|
||||
Find undervalued domains before anyone else.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Strategy Banner */}
|
||||
<div className="mb-8 p-5 bg-gradient-to-r from-accent/10 to-accent/5 border border-accent/20 rounded-xl">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-accent/20 rounded-xl flex items-center justify-center shrink-0">
|
||||
<Target className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-body font-medium text-foreground mb-1">Smart Pounce Strategy</h3>
|
||||
<p className="text-body-sm text-foreground-muted">
|
||||
We aggregate auctions from multiple platforms so you can find the best deals.
|
||||
We don't handle payments — click through to the platform to bid.
|
||||
Pro tip: Focus on auctions ending soon with low bid counts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-8">
|
||||
<div className="p-4 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-2 text-foreground-subtle mb-2">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
<span className="text-ui-sm">Active Auctions</span>
|
||||
</div>
|
||||
<p className="text-heading-sm font-medium text-foreground">{auctions.length}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-2 text-foreground-subtle mb-2">
|
||||
<Timer className="w-4 h-4" />
|
||||
<span className="text-ui-sm">Ending Soon</span>
|
||||
</div>
|
||||
<p className="text-heading-sm font-medium text-warning">{endingSoon.length}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-2 text-foreground-subtle mb-2">
|
||||
<Flame className="w-4 h-4" />
|
||||
<span className="text-ui-sm">Hot Auctions</span>
|
||||
</div>
|
||||
<p className="text-heading-sm font-medium text-accent">{hotAuctions.length}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-2 text-foreground-subtle mb-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span className="text-ui-sm">Opportunities</span>
|
||||
</div>
|
||||
<p className="text-heading-sm font-medium text-accent">
|
||||
{isAuthenticated ? opportunities.length : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 p-1 bg-background-secondary border border-border rounded-xl w-fit mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('all')}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2 text-ui-sm font-medium rounded-lg transition-all",
|
||||
activeTab === 'all'
|
||||
? "bg-foreground text-background"
|
||||
: "text-foreground-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
All Auctions
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('opportunities')}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2 text-ui-sm font-medium rounded-lg transition-all",
|
||||
activeTab === 'opportunities'
|
||||
? "bg-foreground text-background"
|
||||
: "text-foreground-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Opportunities
|
||||
{!isAuthenticated && <Lock className="w-3 h-3" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('ending')}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2 text-ui-sm font-medium rounded-lg transition-all",
|
||||
activeTab === 'ending'
|
||||
? "bg-foreground text-background"
|
||||
: "text-foreground-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Timer className="w-4 h-4" />
|
||||
Ending Soon
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters (for All tab) */}
|
||||
{activeTab === 'all' && (
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search domains..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-11 pr-4 py-2.5 bg-background-secondary border border-border rounded-xl
|
||||
text-body-sm text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:border-border-hover transition-all"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={selectedPlatform}
|
||||
onChange={(e) => setSelectedPlatform(e.target.value)}
|
||||
className="px-4 py-2.5 bg-background-secondary border border-border rounded-xl
|
||||
text-body-sm text-foreground focus:outline-none focus:border-border-hover"
|
||||
>
|
||||
{PLATFORMS.map(p => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Max bid..."
|
||||
value={maxBid}
|
||||
onChange={(e) => setMaxBid(e.target.value)}
|
||||
className="w-32 px-4 py-2.5 bg-background-secondary border border-border rounded-xl
|
||||
text-body-sm text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:border-border-hover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : activeTab === 'all' ? (
|
||||
/* All Auctions Grid */
|
||||
<div className="grid gap-4">
|
||||
{filteredAuctions.length === 0 ? (
|
||||
<div className="text-center py-12 text-foreground-muted">
|
||||
No auctions match your filters
|
||||
</div>
|
||||
) : (
|
||||
filteredAuctions.map((auction, idx) => (
|
||||
<div
|
||||
key={`${auction.domain}-${idx}`}
|
||||
className="p-5 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover transition-all group"
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="font-mono text-body-lg font-medium text-foreground">
|
||||
{auction.domain}
|
||||
</span>
|
||||
<span className={clsx(
|
||||
"text-ui-xs px-2 py-0.5 rounded-full",
|
||||
getPlatformColor(auction.platform)
|
||||
)}>
|
||||
{auction.platform}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-body-sm text-foreground-muted">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
{auction.num_bids} bids
|
||||
</span>
|
||||
{auction.age_years && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{auction.age_years} years old
|
||||
</span>
|
||||
)}
|
||||
{auction.traffic && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
{auction.traffic.toLocaleString()} visits/mo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<p className="text-ui-xs text-foreground-muted mb-1">Current Bid</p>
|
||||
<p className="text-body-lg font-medium text-foreground">
|
||||
{formatCurrency(auction.current_bid)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<p className="text-ui-xs text-foreground-muted mb-1">Time Left</p>
|
||||
<p className={clsx(
|
||||
"text-body-lg font-medium flex items-center gap-1.5",
|
||||
getTimeColor(auction.time_remaining)
|
||||
)}>
|
||||
<Timer className="w-4 h-4" />
|
||||
{auction.time_remaining}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={auction.affiliate_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-background text-ui-sm font-medium rounded-xl
|
||||
hover:bg-accent-hover transition-all"
|
||||
>
|
||||
Bid Now
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{auction.buy_now_price && (
|
||||
<div className="mt-3 pt-3 border-t border-border flex items-center justify-between">
|
||||
<span className="text-body-sm text-foreground-subtle">
|
||||
Buy Now: {formatCurrency(auction.buy_now_price)}
|
||||
</span>
|
||||
{auction.reserve_met !== null && (
|
||||
<span className={clsx(
|
||||
"text-ui-xs px-2 py-0.5 rounded-full",
|
||||
auction.reserve_met
|
||||
? "bg-accent-muted text-accent"
|
||||
: "bg-warning-muted text-warning"
|
||||
)}>
|
||||
{auction.reserve_met ? 'Reserve Met' : 'Reserve Not Met'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : activeTab === 'opportunities' ? (
|
||||
/* Smart Opportunities */
|
||||
!isAuthenticated ? (
|
||||
<div className="text-center py-16 border border-dashed border-border rounded-xl bg-background-secondary/30">
|
||||
<div className="w-12 h-12 bg-background-tertiary rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<Lock className="w-6 h-6 text-foreground-subtle" />
|
||||
</div>
|
||||
<p className="text-body text-foreground-muted mb-2">Sign in to see opportunities</p>
|
||||
<p className="text-body-sm text-foreground-subtle mb-6">
|
||||
Our algorithm finds undervalued domains based on your watchlist and market data.
|
||||
</p>
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-accent text-background text-ui font-medium rounded-xl
|
||||
hover:bg-accent-hover transition-all"
|
||||
>
|
||||
Get Started Free
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
) : opportunities.length === 0 ? (
|
||||
<div className="text-center py-12 text-foreground-muted">
|
||||
No opportunities found right now. Check back later!
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{opportunities.map((opp, idx) => (
|
||||
<div
|
||||
key={`${opp.auction.domain}-${idx}`}
|
||||
className="p-5 bg-background-secondary/50 border border-accent/20 rounded-xl"
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="font-mono text-body-lg font-medium text-foreground">
|
||||
{opp.auction.domain}
|
||||
</span>
|
||||
<span className={clsx(
|
||||
"text-ui-xs px-2 py-0.5 rounded-full",
|
||||
opp.analysis.recommendation === 'Strong buy'
|
||||
? "bg-accent-muted text-accent"
|
||||
: opp.analysis.recommendation === 'Consider'
|
||||
? "bg-warning-muted text-warning"
|
||||
: "bg-background-tertiary text-foreground-muted"
|
||||
)}>
|
||||
{opp.analysis.recommendation}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Analysis Breakdown */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-3">
|
||||
<div className="p-2 bg-background-tertiary rounded-lg">
|
||||
<p className="text-ui-xs text-foreground-subtle">Current Bid</p>
|
||||
<p className="text-body-sm font-medium text-foreground">
|
||||
{formatCurrency(opp.analysis.current_bid)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 bg-background-tertiary rounded-lg">
|
||||
<p className="text-ui-xs text-foreground-subtle">Est. Value</p>
|
||||
<p className="text-body-sm font-medium text-accent">
|
||||
{formatCurrency(opp.analysis.estimated_value)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 bg-background-tertiary rounded-lg">
|
||||
<p className="text-ui-xs text-foreground-subtle">Value Ratio</p>
|
||||
<p className="text-body-sm font-medium text-foreground">
|
||||
{opp.analysis.value_ratio}×
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 bg-background-tertiary rounded-lg">
|
||||
<p className="text-ui-xs text-foreground-subtle">Potential Profit</p>
|
||||
<p className={clsx(
|
||||
"text-body-sm font-medium",
|
||||
opp.analysis.potential_profit > 0 ? "text-accent" : "text-danger"
|
||||
)}>
|
||||
{opp.analysis.potential_profit > 0 ? '+' : ''}
|
||||
{formatCurrency(opp.analysis.potential_profit)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-ui-xs text-foreground-muted mb-1">Time Left</p>
|
||||
<p className={clsx(
|
||||
"text-body font-medium",
|
||||
getTimeColor(opp.auction.time_remaining)
|
||||
)}>
|
||||
{opp.auction.time_remaining}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={opp.auction.affiliate_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-background text-ui-sm font-medium rounded-xl
|
||||
hover:bg-accent-hover transition-all"
|
||||
>
|
||||
Bid Now
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
/* Ending Soon */
|
||||
<div className="grid gap-4">
|
||||
{endingSoon.length === 0 ? (
|
||||
<div className="text-center py-12 text-foreground-muted">
|
||||
No auctions ending soon
|
||||
</div>
|
||||
) : (
|
||||
endingSoon.map((auction, idx) => (
|
||||
<div
|
||||
key={`${auction.domain}-${idx}`}
|
||||
className="p-5 bg-background-secondary/50 border border-warning/20 rounded-xl"
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-warning-muted rounded-xl flex items-center justify-center">
|
||||
<Timer className="w-5 h-5 text-warning" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-mono text-body-lg font-medium text-foreground block">
|
||||
{auction.domain}
|
||||
</span>
|
||||
<span className="text-body-sm text-foreground-muted">
|
||||
{auction.num_bids} bids on {auction.platform}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<p className="text-heading-sm font-medium text-foreground">
|
||||
{formatCurrency(auction.current_bid)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<p className={clsx(
|
||||
"text-body-lg font-bold",
|
||||
getTimeColor(auction.time_remaining)
|
||||
)}>
|
||||
{auction.time_remaining}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={auction.affiliate_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-warning text-background text-ui-sm font-medium rounded-xl
|
||||
hover:bg-warning/90 transition-all"
|
||||
>
|
||||
Snipe Now
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div className="mt-12 p-5 bg-background-secondary/30 border border-border rounded-xl">
|
||||
<h4 className="text-body-sm font-medium text-foreground mb-2">How Smart Pounce Works</h4>
|
||||
<p className="text-body-sm text-foreground-subtle">
|
||||
We aggregate domain auctions from multiple platforms (GoDaddy, Sedo, NameJet, etc.)
|
||||
and display them in one place. When you click "Bid Now", you're taken directly to
|
||||
the auction platform — we don't handle any payments or domain transfers.
|
||||
This keeps things simple and compliant with Swiss regulations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -52,6 +52,13 @@ export function Header() {
|
||||
>
|
||||
TLD
|
||||
</Link>
|
||||
<Link
|
||||
href="/auctions"
|
||||
className="px-3 py-1.5 text-ui text-foreground-muted hover:text-foreground
|
||||
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
||||
>
|
||||
Auctions
|
||||
</Link>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="px-3 py-1.5 text-ui text-foreground-muted hover:text-foreground
|
||||
@ -169,6 +176,15 @@ export function Header() {
|
||||
>
|
||||
TLD
|
||||
</Link>
|
||||
<Link
|
||||
href="/auctions"
|
||||
className="block px-4 py-3 text-body-sm text-foreground-muted
|
||||
hover:text-foreground hover:bg-background-secondary rounded-xl
|
||||
transition-all duration-300"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Auctions
|
||||
</Link>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="block px-4 py-3 text-body-sm text-foreground-muted
|
||||
|
||||
@ -381,6 +381,134 @@ class ApiClient {
|
||||
async getDomainValuation(domain: string) {
|
||||
return this.request<DomainValuation>(`/portfolio/valuation/${domain}`)
|
||||
}
|
||||
|
||||
// ============== Auctions (Smart Pounce) ==============
|
||||
|
||||
async getAuctions(
|
||||
keyword?: string,
|
||||
tld?: string,
|
||||
platform?: string,
|
||||
minBid?: number,
|
||||
maxBid?: number,
|
||||
endingSoon = false,
|
||||
sortBy = 'ending',
|
||||
limit = 20,
|
||||
offset = 0
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
sort_by: sortBy,
|
||||
limit: limit.toString(),
|
||||
offset: offset.toString(),
|
||||
ending_soon: endingSoon.toString(),
|
||||
})
|
||||
if (keyword) params.append('keyword', keyword)
|
||||
if (tld) params.append('tld', tld)
|
||||
if (platform) params.append('platform', platform)
|
||||
if (minBid !== undefined) params.append('min_bid', minBid.toString())
|
||||
if (maxBid !== undefined) params.append('max_bid', maxBid.toString())
|
||||
|
||||
return this.request<{
|
||||
auctions: Array<{
|
||||
domain: string
|
||||
platform: string
|
||||
platform_url: string
|
||||
current_bid: number
|
||||
currency: string
|
||||
num_bids: number
|
||||
end_time: string
|
||||
time_remaining: string
|
||||
buy_now_price: number | null
|
||||
reserve_met: boolean | null
|
||||
traffic: number | null
|
||||
age_years: number | null
|
||||
tld: string
|
||||
affiliate_url: string
|
||||
}>
|
||||
total: number
|
||||
platforms_searched: string[]
|
||||
last_updated: string
|
||||
}>(`/auctions?${params.toString()}`)
|
||||
}
|
||||
|
||||
async getEndingSoonAuctions(hours = 1, limit = 10) {
|
||||
return this.request<Array<{
|
||||
domain: string
|
||||
platform: string
|
||||
platform_url: string
|
||||
current_bid: number
|
||||
currency: string
|
||||
num_bids: number
|
||||
end_time: string
|
||||
time_remaining: string
|
||||
buy_now_price: number | null
|
||||
reserve_met: boolean | null
|
||||
traffic: number | null
|
||||
age_years: number | null
|
||||
tld: string
|
||||
affiliate_url: string
|
||||
}>>(`/auctions/ending-soon?hours=${hours}&limit=${limit}`)
|
||||
}
|
||||
|
||||
async getHotAuctions(limit = 10) {
|
||||
return this.request<Array<{
|
||||
domain: string
|
||||
platform: string
|
||||
platform_url: string
|
||||
current_bid: number
|
||||
currency: string
|
||||
num_bids: number
|
||||
end_time: string
|
||||
time_remaining: string
|
||||
buy_now_price: number | null
|
||||
reserve_met: boolean | null
|
||||
traffic: number | null
|
||||
age_years: number | null
|
||||
tld: string
|
||||
affiliate_url: string
|
||||
}>>(`/auctions/hot?limit=${limit}`)
|
||||
}
|
||||
|
||||
async getAuctionOpportunities() {
|
||||
return this.request<{
|
||||
opportunities: Array<{
|
||||
auction: {
|
||||
domain: string
|
||||
platform: string
|
||||
platform_url: string
|
||||
current_bid: number
|
||||
currency: string
|
||||
num_bids: number
|
||||
end_time: string
|
||||
time_remaining: string
|
||||
buy_now_price: number | null
|
||||
reserve_met: boolean | null
|
||||
traffic: number | null
|
||||
age_years: number | null
|
||||
tld: string
|
||||
affiliate_url: string
|
||||
}
|
||||
analysis: {
|
||||
estimated_value: number
|
||||
current_bid: number
|
||||
value_ratio: number
|
||||
potential_profit: number
|
||||
opportunity_score: number
|
||||
recommendation: string
|
||||
}
|
||||
}>
|
||||
strategy_tips: string[]
|
||||
generated_at: string
|
||||
}>('/auctions/opportunities')
|
||||
}
|
||||
|
||||
async getAuctionPlatformStats() {
|
||||
return this.request<Array<{
|
||||
platform: string
|
||||
active_auctions: number
|
||||
avg_bid: number
|
||||
ending_soon: number
|
||||
}>>('/auctions/stats')
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Types ==============
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user