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.admin import router as admin_router
|
||||||
from app.api.tld_prices import router as tld_prices_router
|
from app.api.tld_prices import router as tld_prices_router
|
||||||
from app.api.portfolio import router as portfolio_router
|
from app.api.portfolio import router as portfolio_router
|
||||||
|
from app.api.auctions import router as auctions_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
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(subscription_router, prefix="/subscription", tags=["Subscription"])
|
||||||
api_router.include_router(tld_prices_router, prefix="/tld-prices", tags=["TLD Prices"])
|
api_router.include_router(tld_prices_router, prefix="/tld-prices", tags=["TLD Prices"])
|
||||||
api_router.include_router(portfolio_router, prefix="/portfolio", tags=["Portfolio"])
|
api_router.include_router(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"])
|
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 logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, List
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.portfolio import DomainValuation
|
from app.models.portfolio import DomainValuation
|
||||||
@ -12,66 +12,135 @@ from app.models.tld_price import TLDPrice
|
|||||||
logger = logging.getLogger(__name__)
|
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 = {
|
TLD_VALUES = {
|
||||||
# Premium TLDs
|
# Premium Generic TLDs - High aftermarket demand
|
||||||
"com": 1.0,
|
"com": 1.0, # Gold standard, baseline
|
||||||
"net": 0.7,
|
"net": 0.65, # ~65% of .com value
|
||||||
"org": 0.65,
|
"org": 0.60, # ~60% of .com value
|
||||||
"io": 0.8,
|
|
||||||
"ai": 1.2,
|
|
||||||
"co": 0.6,
|
|
||||||
|
|
||||||
# Tech TLDs
|
# Tech/Startup TLDs - High demand in specific sectors
|
||||||
"dev": 0.5,
|
"io": 0.75, # Popular with startups, premium pricing
|
||||||
"app": 0.5,
|
"ai": 1.20, # AI boom, extremely high demand
|
||||||
"tech": 0.4,
|
"co": 0.55, # Company alternative
|
||||||
"software": 0.3,
|
"dev": 0.45, # Developer focused
|
||||||
|
"app": 0.45, # App ecosystem
|
||||||
|
"tech": 0.35, # Technology sector
|
||||||
|
|
||||||
# Country codes
|
# Geographic TLDs - Regional value
|
||||||
"de": 0.5,
|
"de": 0.50, # Germany - largest European ccTLD
|
||||||
"uk": 0.5,
|
"uk": 0.45, # United Kingdom
|
||||||
"ch": 0.45,
|
"ch": 0.40, # Switzerland - premium market
|
||||||
"fr": 0.4,
|
"fr": 0.35, # France
|
||||||
"eu": 0.35,
|
"eu": 0.30, # European Union
|
||||||
|
"nl": 0.35, # Netherlands
|
||||||
|
|
||||||
# New gTLDs
|
# New gTLDs - Generally lower aftermarket value
|
||||||
"xyz": 0.15,
|
"xyz": 0.15, # Budget option
|
||||||
"online": 0.2,
|
"online": 0.18,
|
||||||
"site": 0.2,
|
"site": 0.15,
|
||||||
"store": 0.25,
|
"store": 0.22,
|
||||||
"shop": 0.25,
|
"shop": 0.22,
|
||||||
|
"club": 0.15,
|
||||||
|
"info": 0.20,
|
||||||
|
"biz": 0.25,
|
||||||
|
"me": 0.30, # Personal branding
|
||||||
|
|
||||||
# Default
|
# Default for unknown TLDs
|
||||||
"_default": 0.2,
|
"_default": 0.15,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Common high-value keywords
|
# High-value keywords that increase domain value
|
||||||
HIGH_VALUE_KEYWORDS = {
|
HIGH_VALUE_KEYWORDS = {
|
||||||
"crypto", "bitcoin", "btc", "eth", "nft", "web3", "defi",
|
# Crypto/Web3 - Very high value
|
||||||
"ai", "ml", "gpt", "chat", "bot",
|
"crypto": 2.0, "bitcoin": 2.0, "btc": 1.8, "eth": 1.8, "nft": 1.5,
|
||||||
"cloud", "saas", "api", "app", "tech",
|
"web3": 1.8, "defi": 1.5, "blockchain": 1.5,
|
||||||
"finance", "fintech", "bank", "pay", "money",
|
|
||||||
"health", "med", "care", "fit",
|
# AI/Tech - High value
|
||||||
"game", "gaming", "play", "esport",
|
"ai": 2.0, "gpt": 1.8, "ml": 1.5, "chat": 1.3, "bot": 1.2,
|
||||||
"shop", "buy", "sell", "deal", "store",
|
"cloud": 1.3, "saas": 1.4, "api": 1.3, "data": 1.2,
|
||||||
"travel", "trip", "hotel", "fly",
|
|
||||||
"food", "eat", "chef", "recipe",
|
# Finance - High value
|
||||||
"auto", "car", "drive", "ev",
|
"finance": 1.5, "fintech": 1.5, "bank": 1.6, "pay": 1.4,
|
||||||
"home", "house", "real", "estate",
|
"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:
|
class DomainValuationService:
|
||||||
"""
|
"""
|
||||||
Service for estimating domain values.
|
Professional domain valuation service with transparent methodology.
|
||||||
|
|
||||||
Uses a multi-factor algorithm considering:
|
VALUATION FORMULA:
|
||||||
- Domain length
|
------------------
|
||||||
- TLD value
|
Base Value = $10
|
||||||
- Keyword relevance
|
|
||||||
- Brandability
|
Estimated Value = Base × Length_Factor × TLD_Factor × Keyword_Factor × Brand_Factor
|
||||||
- Character composition
|
|
||||||
|
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):
|
def __init__(self):
|
||||||
@ -84,83 +153,107 @@ class DomainValuationService:
|
|||||||
save_result: bool = True,
|
save_result: bool = True,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Estimate the value of a domain.
|
Estimate the market value of a domain with full transparency.
|
||||||
|
|
||||||
Args:
|
Returns a detailed breakdown of how the value was calculated.
|
||||||
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()
|
domain = domain.lower().strip()
|
||||||
|
|
||||||
# Split domain and TLD
|
# Parse domain
|
||||||
parts = domain.rsplit(".", 1)
|
parts = domain.rsplit(".", 1)
|
||||||
if len(parts) != 2:
|
if len(parts) != 2:
|
||||||
return {"error": "Invalid domain format"}
|
return {"error": "Invalid domain format. Use: name.tld"}
|
||||||
|
|
||||||
name, tld = parts
|
name, tld = parts
|
||||||
|
|
||||||
# Calculate scores
|
# Get real TLD registration cost if available
|
||||||
length_score = self._calculate_length_score(name)
|
tld_registration_cost = await self._get_tld_cost(db, tld) if db else None
|
||||||
tld_score = self._calculate_tld_score(tld)
|
|
||||||
keyword_score = self._calculate_keyword_score(name)
|
|
||||||
brandability_score = self._calculate_brandability_score(name)
|
|
||||||
|
|
||||||
# Calculate base value
|
# Calculate individual factors
|
||||||
# Formula: base * length_mult * tld_mult * (1 + keyword_bonus + brand_bonus)
|
length_analysis = self._analyze_length(name)
|
||||||
length_mult = length_score / 50 # 0.0 - 2.0
|
tld_analysis = self._analyze_tld(tld, tld_registration_cost)
|
||||||
tld_mult = TLD_VALUES.get(tld, TLD_VALUES["_default"])
|
keyword_analysis = self._analyze_keywords(name)
|
||||||
keyword_bonus = keyword_score / 200 # 0.0 - 0.5
|
brand_analysis = self._analyze_brandability(name)
|
||||||
brand_bonus = brandability_score / 200 # 0.0 - 0.5
|
|
||||||
|
|
||||||
# Short premium domains get exponential boost
|
# Calculate final value
|
||||||
if len(name) <= 3:
|
raw_value = (
|
||||||
length_mult *= 5
|
self.base_value
|
||||||
elif len(name) <= 4:
|
* length_analysis["factor"]
|
||||||
length_mult *= 3
|
* tld_analysis["factor"]
|
||||||
elif len(name) <= 5:
|
* keyword_analysis["factor"]
|
||||||
length_mult *= 2
|
* brand_analysis["factor"]
|
||||||
|
)
|
||||||
|
|
||||||
# Calculate estimated value
|
# Apply reasonable bounds
|
||||||
estimated_value = self.base_value * length_mult * tld_mult * (1 + keyword_bonus + brand_bonus)
|
estimated_value = self._round_value(max(5, min(raw_value, 1000000)))
|
||||||
|
|
||||||
# Apply caps
|
# Determine confidence
|
||||||
estimated_value = max(5, min(estimated_value, 1000000)) # $5 - $1M
|
confidence = self._calculate_confidence(
|
||||||
|
length_analysis["score"],
|
||||||
# Round to reasonable precision
|
tld_analysis["score"],
|
||||||
if estimated_value < 100:
|
keyword_analysis["score"],
|
||||||
estimated_value = round(estimated_value)
|
brand_analysis["score"],
|
||||||
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 = {
|
result = {
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"estimated_value": estimated_value,
|
"estimated_value": estimated_value,
|
||||||
"currency": "USD",
|
"currency": "USD",
|
||||||
|
"confidence": confidence,
|
||||||
|
|
||||||
|
# Transparent score breakdown
|
||||||
"scores": {
|
"scores": {
|
||||||
"length": length_score,
|
"length": length_analysis["score"],
|
||||||
"tld": tld_score,
|
"tld": tld_analysis["score"],
|
||||||
"keyword": keyword_score,
|
"keyword": keyword_analysis["score"],
|
||||||
"brandability": brandability_score,
|
"brandability": brand_analysis["score"],
|
||||||
"overall": round((length_score + tld_score + keyword_score + brandability_score) / 4),
|
"overall": round((
|
||||||
|
length_analysis["score"] +
|
||||||
|
tld_analysis["score"] +
|
||||||
|
keyword_analysis["score"] +
|
||||||
|
brand_analysis["score"]
|
||||||
|
) / 4),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# Detailed factor explanations
|
||||||
"factors": {
|
"factors": {
|
||||||
"length": len(name),
|
"length": len(name),
|
||||||
"tld": tld,
|
"tld": tld,
|
||||||
"has_numbers": bool(re.search(r"\d", name)),
|
"has_numbers": bool(re.search(r"\d", name)),
|
||||||
"has_hyphens": "-" in 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(),
|
"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
|
# Save to database if requested
|
||||||
@ -169,11 +262,11 @@ class DomainValuationService:
|
|||||||
valuation = DomainValuation(
|
valuation = DomainValuation(
|
||||||
domain=domain,
|
domain=domain,
|
||||||
estimated_value=estimated_value,
|
estimated_value=estimated_value,
|
||||||
length_score=length_score,
|
length_score=length_analysis["score"],
|
||||||
tld_score=tld_score,
|
tld_score=tld_analysis["score"],
|
||||||
keyword_score=keyword_score,
|
keyword_score=keyword_analysis["score"],
|
||||||
brandability_score=brandability_score,
|
brandability_score=brand_analysis["score"],
|
||||||
source="internal",
|
source="pounce_algorithm_v1",
|
||||||
)
|
)
|
||||||
db.add(valuation)
|
db.add(valuation)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@ -182,133 +275,226 @@ class DomainValuationService:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _calculate_length_score(self, name: str) -> int:
|
async def _get_tld_cost(self, db: AsyncSession, tld: str) -> Optional[float]:
|
||||||
"""Calculate score based on domain length (shorter = better)."""
|
"""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 = len(name)
|
||||||
|
|
||||||
|
# Length-based multipliers (exponential for short domains)
|
||||||
if length <= 2:
|
if length <= 2:
|
||||||
return 100
|
factor = 15.0
|
||||||
elif length <= 3:
|
score = 100
|
||||||
return 95
|
reason = f"Ultra-premium 2-letter domain (×{factor})"
|
||||||
elif length <= 4:
|
elif length == 3:
|
||||||
return 85
|
factor = 10.0
|
||||||
elif length <= 5:
|
score = 95
|
||||||
return 75
|
reason = f"Premium 3-letter domain (×{factor})"
|
||||||
elif length <= 6:
|
elif length == 4:
|
||||||
return 65
|
factor = 5.0
|
||||||
elif length <= 7:
|
score = 85
|
||||||
return 55
|
reason = f"Highly valuable 4-letter domain (×{factor})"
|
||||||
elif length <= 8:
|
elif length == 5:
|
||||||
return 45
|
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:
|
elif length <= 10:
|
||||||
return 35
|
factor = 1.0
|
||||||
|
score = 45
|
||||||
|
reason = f"Average length domain (×{factor})"
|
||||||
elif length <= 15:
|
elif length <= 15:
|
||||||
return 25
|
factor = 0.6
|
||||||
|
score = 30
|
||||||
|
reason = f"Longer domain, reduced value (×{factor})"
|
||||||
elif length <= 20:
|
elif length <= 20:
|
||||||
return 15
|
factor = 0.3
|
||||||
|
score = 15
|
||||||
|
reason = f"Very long domain (×{factor})"
|
||||||
else:
|
else:
|
||||||
return 5
|
factor = 0.1
|
||||||
|
score = 5
|
||||||
|
reason = f"Extremely long domain (×{factor})"
|
||||||
|
|
||||||
def _calculate_tld_score(self, tld: str) -> int:
|
return {"factor": factor, "score": score, "reason": reason}
|
||||||
"""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:
|
def _analyze_tld(self, tld: str, registration_cost: Optional[float]) -> Dict[str, Any]:
|
||||||
"""Calculate score based on keyword value."""
|
"""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 _analyze_keywords(self, name: str) -> Dict[str, Any]:
|
||||||
|
"""Analyze keyword value in domain name."""
|
||||||
name_lower = name.lower()
|
name_lower = name.lower()
|
||||||
score = 0
|
factor = 1.0
|
||||||
|
detected = []
|
||||||
|
reasons = []
|
||||||
|
|
||||||
# Check for high-value keywords
|
# Check for high-value keywords
|
||||||
for keyword in HIGH_VALUE_KEYWORDS:
|
for keyword, multiplier in HIGH_VALUE_KEYWORDS.items():
|
||||||
if keyword in name_lower:
|
if keyword in name_lower:
|
||||||
score += 30
|
if multiplier > factor:
|
||||||
break
|
factor = multiplier
|
||||||
|
detected.append(f"{keyword} (×{multiplier})")
|
||||||
|
|
||||||
# Bonus for exact keyword match
|
# Exact match bonus
|
||||||
if name_lower in HIGH_VALUE_KEYWORDS:
|
if name_lower in HIGH_VALUE_KEYWORDS:
|
||||||
score += 50
|
factor *= 1.5
|
||||||
|
detected.append("Exact keyword match (+50%)")
|
||||||
|
|
||||||
# Penalty for numbers
|
# Common word bonus
|
||||||
if re.search(r"\d", name):
|
if name_lower in COMMON_BRANDABLE_WORDS:
|
||||||
score -= 20
|
factor *= 1.3
|
||||||
|
detected.append("Common brandable word (+30%)")
|
||||||
|
|
||||||
# Penalty for hyphens
|
# Build reason
|
||||||
if "-" in name:
|
if detected:
|
||||||
score -= 30
|
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
|
return {
|
||||||
if self._is_common_word(name):
|
"factor": factor,
|
||||||
score += 40
|
"score": score,
|
||||||
|
"reason": reason,
|
||||||
|
"detected_keywords": detected
|
||||||
|
}
|
||||||
|
|
||||||
return max(0, min(100, score))
|
def _analyze_brandability(self, name: str) -> Dict[str, Any]:
|
||||||
|
"""Analyze brandability and memorability."""
|
||||||
|
factor = 1.0
|
||||||
|
adjustments = []
|
||||||
|
|
||||||
def _calculate_brandability_score(self, name: str) -> int:
|
# Positive factors
|
||||||
"""Calculate brandability score."""
|
|
||||||
score = 50 # Start neutral
|
|
||||||
|
|
||||||
# Bonus for pronounceable names
|
|
||||||
if self._is_pronounceable(name):
|
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():
|
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()):
|
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:
|
def _is_pronounceable(self, name: str) -> bool:
|
||||||
"""Check if a name is likely pronounceable."""
|
"""Check if name is likely pronounceable."""
|
||||||
vowels = set("aeiou")
|
vowels = set("aeiou")
|
||||||
name_lower = name.lower()
|
name_lower = name.lower()
|
||||||
|
|
||||||
# Must have at least one vowel
|
|
||||||
if not any(c in vowels for c in name_lower):
|
if not any(c in vowels for c in name_lower):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check vowel distribution
|
|
||||||
vowel_count = sum(1 for c in name_lower if c in vowels)
|
vowel_count = sum(1 for c in name_lower if c in vowels)
|
||||||
vowel_ratio = vowel_count / len(name) if name else 0
|
vowel_ratio = vowel_count / len(name) if name else 0
|
||||||
|
|
||||||
return 0.2 <= vowel_ratio <= 0.6
|
return 0.2 <= vowel_ratio <= 0.6
|
||||||
|
|
||||||
def _is_common_word(self, name: str) -> bool:
|
def _round_value(self, value: float) -> int:
|
||||||
"""Check if name is a common English word."""
|
"""Round value to reasonable precision based on magnitude."""
|
||||||
# Simplified check - in production, use a dictionary API
|
if value < 50:
|
||||||
common_words = {
|
return round(value / 5) * 5 # Round to nearest 5
|
||||||
"app", "web", "net", "dev", "code", "tech", "data", "cloud",
|
elif value < 100:
|
||||||
"shop", "store", "buy", "sell", "pay", "cash", "money",
|
return round(value / 10) * 10 # Round to nearest 10
|
||||||
"game", "play", "fun", "cool", "best", "top", "pro",
|
elif value < 500:
|
||||||
"home", "life", "love", "care", "help", "work", "job",
|
return round(value / 25) * 25 # Round to nearest 25
|
||||||
"news", "blog", "post", "chat", "talk", "meet", "link",
|
elif value < 1000:
|
||||||
"fast", "quick", "smart", "easy", "simple", "free",
|
return round(value / 50) * 50 # Round to nearest 50
|
||||||
}
|
elif value < 10000:
|
||||||
return name.lower() in common_words
|
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:
|
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)
|
avg = sum(scores) / len(scores)
|
||||||
|
min_score = min(scores)
|
||||||
variance = sum((s - avg) ** 2 for s in scores) / len(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"
|
return "high"
|
||||||
elif variance < 200 and avg > 40:
|
elif min_score >= 30 and avg >= 45 and variance < 300:
|
||||||
return "medium"
|
return "medium"
|
||||||
else:
|
else:
|
||||||
return "low"
|
return "low"
|
||||||
@ -318,17 +504,25 @@ class DomainValuationService:
|
|||||||
domain: str,
|
domain: str,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
) -> list:
|
) -> List[Dict]:
|
||||||
"""Get historical valuations for a domain."""
|
"""Get historical valuations for tracking value changes."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(DomainValuation)
|
select(DomainValuation)
|
||||||
.where(DomainValuation.domain == domain.lower())
|
.where(DomainValuation.domain == domain.lower())
|
||||||
.order_by(DomainValuation.created_at.desc())
|
.order_by(DomainValuation.created_at.desc())
|
||||||
.limit(limit)
|
.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
|
# Singleton instance
|
||||||
valuation_service = DomainValuationService()
|
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
|
TLD
|
||||||
</Link>
|
</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
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
className="px-3 py-1.5 text-ui text-foreground-muted hover:text-foreground
|
className="px-3 py-1.5 text-ui text-foreground-muted hover:text-foreground
|
||||||
@ -169,6 +176,15 @@ export function Header() {
|
|||||||
>
|
>
|
||||||
TLD
|
TLD
|
||||||
</Link>
|
</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
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
className="block px-4 py-3 text-body-sm text-foreground-muted
|
className="block px-4 py-3 text-body-sm text-foreground-muted
|
||||||
|
|||||||
@ -381,6 +381,134 @@ class ApiClient {
|
|||||||
async getDomainValuation(domain: string) {
|
async getDomainValuation(domain: string) {
|
||||||
return this.request<DomainValuation>(`/portfolio/valuation/${domain}`)
|
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 ==============
|
// ============== Types ==============
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user