From 3485668b5e55d8f690276f815389e0a15478b575 Mon Sep 17 00:00:00 2001 From: Yves Gugger Date: Mon, 15 Dec 2025 16:15:58 +0100 Subject: [PATCH] feat: add Alpha Terminal HUNT/CFO modules and Analyze framework Adds HUNT (Sniper/Trend/Forge), CFO dashboard (burn rate + kill list), and a plugin-based Analyze side panel with caching and SSRF hardening. --- backend/app/api/__init__.py | 6 + backend/app/api/analyze.py | 36 ++ backend/app/api/cfo.py | 197 +++++++++++ backend/app/api/hunt.py | 242 +++++++++++++ backend/app/models/__init__.py | 3 + backend/app/models/domain_analysis_cache.py | 25 ++ backend/app/schemas/analyze.py | 35 ++ backend/app/schemas/cfo.py | 51 +++ backend/app/schemas/hunt.py | 93 +++++ backend/app/services/analyze/__init__.py | 2 + .../services/analyze/analyzers/__init__.py | 2 + .../services/analyze/analyzers/basic_risk.py | 102 ++++++ .../analyze/analyzers/domain_facts.py | 56 +++ .../services/analyze/analyzers/radio_test.py | 30 ++ .../services/analyze/analyzers/tld_matrix.py | 23 ++ .../services/analyze/analyzers/tld_pricing.py | 73 ++++ backend/app/services/analyze/base.py | 41 +++ backend/app/services/analyze/radio_test.py | 91 +++++ backend/app/services/analyze/registry.py | 21 ++ backend/app/services/analyze/renewal_cost.py | 93 +++++ backend/app/services/analyze/service.py | 128 +++++++ backend/app/services/analyze/tld_matrix.py | 66 ++++ backend/app/services/domain_health.py | 73 +++- backend/app/services/hunt/__init__.py | 2 + backend/app/services/hunt/brandables.py | 76 +++++ backend/app/services/hunt/trends.py | 53 +++ backend/app/services/hunt/typos.py | 89 +++++ deploy.sh | 9 + frontend/src/app/terminal/cfo/page.tsx | 176 ++++++++++ frontend/src/app/terminal/hunt/page.tsx | 59 ++++ frontend/src/app/terminal/layout.tsx | 3 +- frontend/src/app/terminal/market/page.tsx | 34 +- frontend/src/app/terminal/portfolio/page.tsx | 112 +++++- frontend/src/app/terminal/watchlist/page.tsx | 33 +- frontend/src/app/terminal/yield/page.tsx | 36 +- frontend/src/components/Sidebar.tsx | 13 + .../src/components/analyze/AnalyzePanel.tsx | 321 ++++++++++++++++++ .../analyze/AnalyzePanelProvider.tsx | 48 +++ frontend/src/components/analyze/types.ts | 22 ++ .../src/components/cfo/BurnRateTimeline.tsx | 45 +++ frontend/src/components/cfo/KillList.tsx | 116 +++++++ .../src/components/hunt/BrandableForgeTab.tsx | 175 ++++++++++ .../src/components/hunt/HuntStrategyChips.tsx | 41 +++ frontend/src/components/hunt/SniperTab.tsx | 221 ++++++++++++ .../src/components/hunt/TrendSurferTab.tsx | 299 ++++++++++++++++ frontend/src/lib/analyze-store.ts | 39 +++ frontend/src/lib/api.ts | 119 +++++++ 47 files changed, 3612 insertions(+), 18 deletions(-) create mode 100644 backend/app/api/analyze.py create mode 100644 backend/app/api/cfo.py create mode 100644 backend/app/api/hunt.py create mode 100644 backend/app/models/domain_analysis_cache.py create mode 100644 backend/app/schemas/analyze.py create mode 100644 backend/app/schemas/cfo.py create mode 100644 backend/app/schemas/hunt.py create mode 100644 backend/app/services/analyze/__init__.py create mode 100644 backend/app/services/analyze/analyzers/__init__.py create mode 100644 backend/app/services/analyze/analyzers/basic_risk.py create mode 100644 backend/app/services/analyze/analyzers/domain_facts.py create mode 100644 backend/app/services/analyze/analyzers/radio_test.py create mode 100644 backend/app/services/analyze/analyzers/tld_matrix.py create mode 100644 backend/app/services/analyze/analyzers/tld_pricing.py create mode 100644 backend/app/services/analyze/base.py create mode 100644 backend/app/services/analyze/radio_test.py create mode 100644 backend/app/services/analyze/registry.py create mode 100644 backend/app/services/analyze/renewal_cost.py create mode 100644 backend/app/services/analyze/service.py create mode 100644 backend/app/services/analyze/tld_matrix.py create mode 100644 backend/app/services/hunt/__init__.py create mode 100644 backend/app/services/hunt/brandables.py create mode 100644 backend/app/services/hunt/trends.py create mode 100644 backend/app/services/hunt/typos.py create mode 100644 frontend/src/app/terminal/cfo/page.tsx create mode 100644 frontend/src/app/terminal/hunt/page.tsx create mode 100644 frontend/src/components/analyze/AnalyzePanel.tsx create mode 100644 frontend/src/components/analyze/AnalyzePanelProvider.tsx create mode 100644 frontend/src/components/analyze/types.ts create mode 100644 frontend/src/components/cfo/BurnRateTimeline.tsx create mode 100644 frontend/src/components/cfo/KillList.tsx create mode 100644 frontend/src/components/hunt/BrandableForgeTab.tsx create mode 100644 frontend/src/components/hunt/HuntStrategyChips.tsx create mode 100644 frontend/src/components/hunt/SniperTab.tsx create mode 100644 frontend/src/components/hunt/TrendSurferTab.tsx create mode 100644 frontend/src/lib/analyze-store.ts diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index c243207..9499986 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -23,6 +23,9 @@ from app.api.yield_webhooks import router as yield_webhooks_router from app.api.yield_routing import router as yield_routing_router from app.api.yield_payout_admin import router as yield_payout_admin_router from app.api.telemetry import router as telemetry_router +from app.api.analyze import router as analyze_router +from app.api.hunt import router as hunt_router +from app.api.cfo import router as cfo_router api_router = APIRouter() @@ -37,6 +40,9 @@ api_router.include_router(price_alerts_router, prefix="/price-alerts", tags=["Pr 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(dashboard_router, prefix="/dashboard", tags=["Dashboard"]) +api_router.include_router(analyze_router, prefix="/analyze", tags=["Analyze"]) +api_router.include_router(hunt_router, prefix="/hunt", tags=["Hunt"]) +api_router.include_router(cfo_router, prefix="/cfo", tags=["CFO"]) # Marketplace (For Sale) - from analysis_3.md api_router.include_router(listings_router, prefix="/listings", tags=["Marketplace - For Sale"]) diff --git a/backend/app/api/analyze.py b/backend/app/api/analyze.py new file mode 100644 index 0000000..c215308 --- /dev/null +++ b/backend/app/api/analyze.py @@ -0,0 +1,36 @@ +"""Analyze API endpoints (Alpha Terminal - Diligence).""" + +from __future__ import annotations + +from fastapi import APIRouter, Query, Request +from slowapi import Limiter +from slowapi.util import get_remote_address + +from app.api.deps import CurrentUser, Database +from app.schemas.analyze import AnalyzeResponse +from app.services.analyze.service import get_domain_analysis + +router = APIRouter() +limiter = Limiter(key_func=get_remote_address) + + +@router.get("/{domain}", response_model=AnalyzeResponse) +@limiter.limit("60/minute") +async def analyze_domain( + request: Request, + domain: str, + current_user: CurrentUser, + db: Database, + fast: bool = Query(False, description="Skip slower HTTP/SSL checks"), + refresh: bool = Query(False, description="Bypass cache and recompute"), +): + """ + Analyze a domain with open-data-first signals. + + Requires authentication (Terminal feature). + """ + _ = current_user # enforce auth + res = await get_domain_analysis(db, domain, fast=fast, refresh=refresh) + await db.commit() # persist cache upsert + return res + diff --git a/backend/app/api/cfo.py b/backend/app/api/cfo.py new file mode 100644 index 0000000..fc4f85d --- /dev/null +++ b/backend/app/api/cfo.py @@ -0,0 +1,197 @@ +"""CFO (Management) endpoints.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from slowapi import Limiter +from slowapi.util import get_remote_address +from sqlalchemy import and_, case, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user +from app.database import get_db +from app.models.portfolio import PortfolioDomain +from app.models.user import User +from app.models.yield_domain import YieldDomain, YieldTransaction +from app.schemas.cfo import ( + CfoKillListRow, + CfoMonthlyBucket, + CfoSummaryResponse, + CfoUpcomingCostRow, + SetToDropResponse, +) +from app.services.analyze.renewal_cost import get_tld_price_snapshot + +router = APIRouter() +limiter = Limiter(key_func=get_remote_address) + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _month_key(dt: datetime) -> str: + return f"{dt.year:04d}-{dt.month:02d}" + + +async def _estimate_renewal_cost_usd(db: AsyncSession, domain: str) -> tuple[float | None, str]: + # If the user stored renewal_cost, we treat it as the source of truth. + # Else we estimate using our own collected `tld_prices` DB. + tld = domain.split(".")[-1].lower() + snap = await get_tld_price_snapshot(db, tld) + if snap.min_renew_usd is None: + return None, "unknown" + return float(snap.min_renew_usd), "tld_prices" + + +@router.get("/summary", response_model=CfoSummaryResponse) +@limiter.limit("30/minute") +async def cfo_summary( + request: Request, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + CFO dashboard summary: + - Burn rate timeline (renewal costs) + - Upcoming costs (30d) + - Kill list (renewal soon + no yield signals) + """ + now = _utcnow() + now_naive = now.replace(tzinfo=None) + + domains = ( + await db.execute(select(PortfolioDomain).where(PortfolioDomain.user_id == current_user.id)) + ).scalars().all() + + # Yield stats (last 60d) by domain + since_60d = now_naive - timedelta(days=60) + yd_rows = ( + await db.execute( + select( + YieldDomain.domain, + func.coalesce(func.sum(YieldTransaction.net_amount), 0).label("net_sum"), + func.coalesce(func.sum(case((YieldTransaction.event_type == "click", 1), else_=0)), 0).label("clicks"), + ) + .join( + YieldTransaction, + and_(YieldTransaction.yield_domain_id == YieldDomain.id, YieldTransaction.created_at >= since_60d), + isouter=True, + ) + .where(YieldDomain.user_id == current_user.id) + .group_by(YieldDomain.domain) + ) + ).all() + yield_by_domain = {str(d).lower(): {"net": float(n or 0), "clicks": int(c or 0)} for d, n, c in yd_rows} + + # Monthly buckets next 12 months + buckets: dict[str, CfoMonthlyBucket] = {} + for i in range(0, 12): + d = (now + timedelta(days=30 * i)).replace(day=1) + buckets[_month_key(d)] = CfoMonthlyBucket(month=_month_key(d), total_cost_usd=0.0, domains=0) + + upcoming_rows: list[CfoUpcomingCostRow] = [] + kill_list: list[CfoKillListRow] = [] + + cutoff_30d = now_naive + timedelta(days=30) + + for pd in domains: + if pd.is_sold: + continue + + renewal_dt = pd.renewal_date + if not renewal_dt: + continue + + if renewal_dt.tzinfo is not None: + renewal_dt_naive = renewal_dt.astimezone(timezone.utc).replace(tzinfo=None) + else: + renewal_dt_naive = renewal_dt + + # cost source: portfolio overrides + if pd.renewal_cost is not None: + cost = float(pd.renewal_cost) + source = "portfolio" + else: + cost, source = await _estimate_renewal_cost_usd(db, pd.domain) + + # Monthly burn timeline + month = _month_key(renewal_dt_naive) + if month not in buckets: + buckets[month] = CfoMonthlyBucket(month=month, total_cost_usd=0.0, domains=0) + if cost is not None: + buckets[month].total_cost_usd = float(buckets[month].total_cost_usd) + float(cost) + buckets[month].domains = int(buckets[month].domains) + 1 + + # Upcoming 30d + if now_naive <= renewal_dt_naive <= cutoff_30d: + upcoming_rows.append( + CfoUpcomingCostRow( + domain_id=pd.id, + domain=pd.domain, + renewal_date=renewal_dt, + renewal_cost_usd=cost, + cost_source=source, + is_sold=bool(pd.is_sold), + ) + ) + + y = yield_by_domain.get(pd.domain.lower(), {"net": 0.0, "clicks": 0}) + if float(y["net"]) <= 0.0 and int(y["clicks"]) <= 0: + kill_list.append( + CfoKillListRow( + domain_id=pd.id, + domain=pd.domain, + renewal_date=renewal_dt, + renewal_cost_usd=cost, + cost_source=source, + auto_renew=bool(pd.auto_renew), + is_dns_verified=bool(getattr(pd, "is_dns_verified", False) or False), + yield_net_60d=float(y["net"]), + yield_clicks_60d=int(y["clicks"]), + reason="No yield signals tracked in the last 60 days and renewal is due within 30 days.", + ) + ) + + # Sort rows + upcoming_rows.sort(key=lambda r: (r.renewal_date or now_naive)) + kill_list.sort(key=lambda r: (r.renewal_date or now_naive)) + + upcoming_total = sum((r.renewal_cost_usd or 0) for r in upcoming_rows) + monthly_sorted = [buckets[k] for k in sorted(buckets.keys())] + + return CfoSummaryResponse( + computed_at=now, + upcoming_30d_total_usd=float(round(upcoming_total, 2)), + upcoming_30d_rows=upcoming_rows, + monthly=monthly_sorted, + kill_list=kill_list[:50], + ) + + +@router.post("/domains/{domain_id}/set-to-drop", response_model=SetToDropResponse) +@limiter.limit("30/minute") +async def set_to_drop( + request: Request, + domain_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Mark portfolio domain as 'to drop' by turning off local auto-renew flag. + (We cannot disable auto-renew at the registrar automatically.) + """ + pd = ( + await db.execute( + select(PortfolioDomain).where(and_(PortfolioDomain.id == domain_id, PortfolioDomain.user_id == current_user.id)) + ) + ).scalar_one_or_none() + if not pd: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Portfolio domain not found") + pd.auto_renew = False + pd.updated_at = datetime.utcnow() + await db.commit() + return SetToDropResponse(domain_id=pd.id, auto_renew=bool(pd.auto_renew), updated_at=pd.updated_at.replace(tzinfo=timezone.utc)) + diff --git a/backend/app/api/hunt.py b/backend/app/api/hunt.py new file mode 100644 index 0000000..0d1a223 --- /dev/null +++ b/backend/app/api/hunt.py @@ -0,0 +1,242 @@ +"""HUNT (Discovery) endpoints.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, Query, Request +from slowapi import Limiter +from slowapi.util import get_remote_address +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user +from app.database import get_db +from app.models.auction import DomainAuction +from app.models.user import User +from app.schemas.hunt import ( + BrandableRequest, + BrandableResponse, + HuntSniperItem, + HuntSniperResponse, + KeywordAvailabilityRequest, + KeywordAvailabilityResponse, + KeywordAvailabilityRow, + TrendsResponse, + TrendItem, + TypoCheckRequest, + TypoCheckResponse, + TypoCandidate, +) +from app.services.domain_checker import domain_checker +from app.services.hunt.brandables import check_domains, generate_cvcvc, generate_cvccv, generate_human +from app.services.hunt.trends import fetch_google_trends_daily_rss +from app.services.hunt.typos import generate_typos + +router = APIRouter() +limiter = Limiter(key_func=get_remote_address) + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +@router.get("/bargain-bin", response_model=HuntSniperResponse) +@limiter.limit("60/minute") +async def bargain_bin( + request: Request, + _user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + limit: int = Query(100, ge=1, le=500), +): + """ + Closeout Sniper (Chris logic): + price < $10 AND age_years >= 5 AND backlinks > 0 + + Uses ONLY real scraped auction data (DomainAuction.age_years/backlinks). + Items without required fields are excluded. + """ + now = _utcnow().replace(tzinfo=None) + base = and_(DomainAuction.is_active == True, DomainAuction.end_time > now) # noqa: E712 + + rows = ( + await db.execute( + select(DomainAuction) + .where(base) + .where(DomainAuction.current_bid < 10) + .order_by(DomainAuction.end_time.asc()) + .limit(limit * 3) # allow filtering + ) + ).scalars().all() + + filtered_out = 0 + items: list[HuntSniperItem] = [] + for a in rows: + if a.age_years is None or a.backlinks is None: + filtered_out += 1 + continue + if int(a.age_years) < 5 or int(a.backlinks) <= 0: + continue + items.append( + HuntSniperItem( + domain=a.domain, + platform=a.platform, + auction_url=a.auction_url, + current_bid=float(a.current_bid), + currency=a.currency, + end_time=a.end_time.replace(tzinfo=timezone.utc) if a.end_time and a.end_time.tzinfo is None else a.end_time, + age_years=int(a.age_years) if a.age_years is not None else None, + backlinks=int(a.backlinks) if a.backlinks is not None else None, + pounce_score=int(a.pounce_score) if a.pounce_score is not None else None, + ) + ) + if len(items) >= limit: + break + + last_updated = ( + await db.execute(select(func.max(DomainAuction.updated_at)).where(DomainAuction.is_active == True)) # noqa: E712 + ).scalar() + + return HuntSniperResponse( + items=items, + total=len(items), + filtered_out_missing_data=int(filtered_out), + last_updated=last_updated.replace(tzinfo=timezone.utc) if last_updated and last_updated.tzinfo is None else last_updated, + ) + + +@router.get("/trends", response_model=TrendsResponse) +@limiter.limit("30/minute") +async def trends( + request: Request, + _user: User = Depends(get_current_user), + geo: str = Query("US", min_length=2, max_length=2), +): + items_raw = await fetch_google_trends_daily_rss(geo=geo) + items = [ + TrendItem( + title=i["title"], + approx_traffic=i.get("approx_traffic"), + published_at=i.get("published_at"), + link=i.get("link"), + ) + for i in items_raw[:50] + ] + return TrendsResponse(geo=geo.upper(), items=items, fetched_at=_utcnow()) + + +@router.post("/keywords", response_model=KeywordAvailabilityResponse) +@limiter.limit("30/minute") +async def keyword_availability( + request: Request, + _user: User = Depends(get_current_user), + payload: KeywordAvailabilityRequest, +): + # Normalize + cap work for UX/perf + keywords = [] + for kw in payload.keywords[:25]: + k = kw.strip().lower().replace(" ", "") + if k: + keywords.append(kw) + + tlds = [t.lower().lstrip(".") for t in payload.tlds[:20] if t.strip()] + if not tlds: + tlds = ["com"] + + # Build candidate domains + candidates: list[tuple[str, str, str]] = [] + domain_list: list[str] = [] + for kw in keywords: + k = kw.strip().lower().replace(" ", "") + if not k: + continue + for t in tlds: + d = f"{k}.{t}" + candidates.append((kw, t, d)) + domain_list.append(d) + + checked = await check_domains(domain_list, concurrency=40) + by_domain = {c.domain: c for c in checked} + + rows: list[KeywordAvailabilityRow] = [] + for kw, t, d in candidates: + c = by_domain.get(d) + if not c: + rows.append(KeywordAvailabilityRow(keyword=kw, domain=d, tld=t, is_available=None, status="unknown")) + else: + rows.append(KeywordAvailabilityRow(keyword=kw, domain=d, tld=t, is_available=c.is_available, status=c.status)) + return KeywordAvailabilityResponse(items=rows) + + +@router.post("/typos", response_model=TypoCheckResponse) +@limiter.limit("20/minute") +async def typo_check( + request: Request, + _user: User = Depends(get_current_user), + payload: TypoCheckRequest, +): + brand = payload.brand.strip() + typos = generate_typos(brand, limit=min(int(payload.limit) * 4, 400)) + + # Build domain list (dedup) + tlds = [t.lower().lstrip(".") for t in payload.tlds if t.strip()] + candidates: list[str] = [] + seen = set() + for typo in typos: + for t in tlds: + d = f"{typo}.{t}" + if d not in seen: + candidates.append(d) + seen.add(d) + if len(candidates) >= payload.limit * 4: + break + if len(candidates) >= payload.limit * 4: + break + + checked = await check_domains(candidates, concurrency=30) + available = [c for c in checked if c.status == "available"] + items = [TypoCandidate(domain=c.domain, is_available=c.is_available, status=c.status) for c in available[: payload.limit]] + return TypoCheckResponse(brand=brand, items=items) + + +@router.post("/brandables", response_model=BrandableResponse) +@limiter.limit("15/minute") +async def brandables( + request: Request, + _user: User = Depends(get_current_user), + payload: BrandableRequest, +): + pattern = payload.pattern.strip().lower() + if pattern not in ("cvcvc", "cvccv", "human"): + pattern = "cvcvc" + + tlds = [t.lower().lstrip(".") for t in payload.tlds if t.strip()] + if not tlds: + tlds = ["com"] + + # Generate + check up to max_checks; return only available + candidates: list[str] = [] + for _ in range(int(payload.max_checks)): + if pattern == "cvcvc": + sld = generate_cvcvc() + elif pattern == "cvccv": + sld = generate_cvccv() + else: + sld = generate_human() + for t in tlds: + candidates.append(f"{sld}.{t}") + + checked = await check_domains(candidates, concurrency=40) + available = [c for c in checked if c.status == "available"] + # De-dup by domain + seen = set() + out = [] + for c in available: + if c.domain not in seen: + seen.add(c.domain) + out.append(BrandableCandidate(domain=c.domain, is_available=c.is_available, status=c.status)) + if len(out) >= payload.limit: + break + + return BrandableResponse(pattern=payload.pattern, items=out) + diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 7bce2fb..081c5eb 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -15,6 +15,7 @@ from app.models.seo_data import DomainSEOData from app.models.yield_domain import YieldDomain, YieldTransaction, YieldPayout, AffiliatePartner from app.models.telemetry import TelemetryEvent from app.models.ops_alert import OpsAlertEvent +from app.models.domain_analysis_cache import DomainAnalysisCache __all__ = [ "User", @@ -48,4 +49,6 @@ __all__ = [ # New: Telemetry (events) "TelemetryEvent", "OpsAlertEvent", + # New: Analyze cache + "DomainAnalysisCache", ] diff --git a/backend/app/models/domain_analysis_cache.py b/backend/app/models/domain_analysis_cache.py new file mode 100644 index 0000000..13c9a2b --- /dev/null +++ b/backend/app/models/domain_analysis_cache.py @@ -0,0 +1,25 @@ +""" +Domain analysis cache (Phase 2 Diligence). + +We store computed JSON to avoid repeated RDAP/DNS/HTTP checks on each click. +""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class DomainAnalysisCache(Base): + __tablename__ = "domain_analysis_cache" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + domain: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + payload_json: Mapped[str] = mapped_column(Text, nullable=False) + computed_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + ttl_seconds: Mapped[int] = mapped_column(Integer, default=3600) + diff --git a/backend/app/schemas/analyze.py b/backend/app/schemas/analyze.py new file mode 100644 index 0000000..c9fb224 --- /dev/null +++ b/backend/app/schemas/analyze.py @@ -0,0 +1,35 @@ +""" +Analyze schemas (Alpha Terminal - Phase 2 Diligence). + +Open-data-first: we return null + reason when data isn't available. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +class AnalyzeItem(BaseModel): + key: str + label: str + value: Optional[Any] = None + status: str = Field(default="info", description="pass|warn|fail|info|na") + source: str = Field(default="internal", description="internal|rdap|whois|dns|http|ssl|db|open_data") + details: dict[str, Any] = Field(default_factory=dict) + + +class AnalyzeSection(BaseModel): + key: str + title: str + items: list[AnalyzeItem] = Field(default_factory=list) + + +class AnalyzeResponse(BaseModel): + domain: str + computed_at: datetime + cached: bool = False + sections: list[AnalyzeSection] + diff --git a/backend/app/schemas/cfo.py b/backend/app/schemas/cfo.py new file mode 100644 index 0000000..730146c --- /dev/null +++ b/backend/app/schemas/cfo.py @@ -0,0 +1,51 @@ +"""CFO (Management) schemas.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +class CfoMonthlyBucket(BaseModel): + month: str # YYYY-MM + total_cost_usd: float = 0.0 + domains: int = 0 + + +class CfoUpcomingCostRow(BaseModel): + domain_id: int + domain: str + renewal_date: Optional[datetime] = None + renewal_cost_usd: Optional[float] = None + cost_source: str = Field(default="unknown", description="portfolio|tld_prices|unknown") + is_sold: bool = False + + +class CfoKillListRow(BaseModel): + domain_id: int + domain: str + renewal_date: Optional[datetime] = None + renewal_cost_usd: Optional[float] = None + cost_source: str = "unknown" + auto_renew: bool = True + is_dns_verified: bool = False + yield_net_60d: float = 0.0 + yield_clicks_60d: int = 0 + reason: str + + +class CfoSummaryResponse(BaseModel): + computed_at: datetime + upcoming_30d_total_usd: float = 0.0 + upcoming_30d_rows: list[CfoUpcomingCostRow] = [] + monthly: list[CfoMonthlyBucket] = [] + kill_list: list[CfoKillListRow] = [] + + +class SetToDropResponse(BaseModel): + domain_id: int + auto_renew: bool + updated_at: datetime + diff --git a/backend/app/schemas/hunt.py b/backend/app/schemas/hunt.py new file mode 100644 index 0000000..48d2ebb --- /dev/null +++ b/backend/app/schemas/hunt.py @@ -0,0 +1,93 @@ +"""HUNT (Discovery) schemas.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +class HuntSniperItem(BaseModel): + domain: str + platform: str + auction_url: str + current_bid: float + currency: str + end_time: datetime + age_years: Optional[int] = None + backlinks: Optional[int] = None + pounce_score: Optional[int] = None + + +class HuntSniperResponse(BaseModel): + items: list[HuntSniperItem] + total: int + filtered_out_missing_data: int = 0 + last_updated: Optional[datetime] = None + + +class TrendItem(BaseModel): + title: str + approx_traffic: Optional[str] = None + published_at: Optional[datetime] = None + link: Optional[str] = None + + +class TrendsResponse(BaseModel): + geo: str = "US" + items: list[TrendItem] + fetched_at: datetime + + +class KeywordAvailabilityRequest(BaseModel): + keywords: list[str] = Field(min_length=1, max_length=25) + tlds: list[str] = Field(default_factory=lambda: ["com", "io", "ai", "net", "org"], max_length=20) + + +class KeywordAvailabilityRow(BaseModel): + keyword: str + domain: str + tld: str + is_available: Optional[bool] = None + status: str # available|taken|unknown + + +class KeywordAvailabilityResponse(BaseModel): + items: list[KeywordAvailabilityRow] + + +class TypoCheckRequest(BaseModel): + brand: str = Field(min_length=2, max_length=50) + tlds: list[str] = Field(default_factory=lambda: ["com"], max_length=10) + limit: int = Field(default=50, ge=1, le=200) + + +class TypoCandidate(BaseModel): + domain: str + is_available: Optional[bool] = None + status: str # available|taken|unknown + + +class TypoCheckResponse(BaseModel): + brand: str + items: list[TypoCandidate] + + +class BrandableRequest(BaseModel): + pattern: str = Field(description="cvcvc|cvccv|human", examples=["cvcvc"]) + tlds: list[str] = Field(default_factory=lambda: ["com"], max_length=10) + limit: int = Field(default=30, ge=1, le=100) + max_checks: int = Field(default=400, ge=50, le=2000) + + +class BrandableCandidate(BaseModel): + domain: str + is_available: Optional[bool] = None + status: str # available|taken|unknown + + +class BrandableResponse(BaseModel): + pattern: str + items: list[BrandableCandidate] + diff --git a/backend/app/services/analyze/__init__.py b/backend/app/services/analyze/__init__.py new file mode 100644 index 0000000..046ee68 --- /dev/null +++ b/backend/app/services/analyze/__init__.py @@ -0,0 +1,2 @@ +"""Analyze services package (Alpha Terminal).""" + diff --git a/backend/app/services/analyze/analyzers/__init__.py b/backend/app/services/analyze/analyzers/__init__.py new file mode 100644 index 0000000..a09fe02 --- /dev/null +++ b/backend/app/services/analyze/analyzers/__init__.py @@ -0,0 +1,2 @@ +"""Analyzer implementations.""" + diff --git a/backend/app/services/analyze/analyzers/basic_risk.py b/backend/app/services/analyze/analyzers/basic_risk.py new file mode 100644 index 0000000..b1532c0 --- /dev/null +++ b/backend/app/services/analyze/analyzers/basic_risk.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from app.schemas.analyze import AnalyzeItem +from app.services.analyze.base import AnalyzerContribution, AnalyzeContext +from app.services.domain_health import get_health_checker + + +class BasicRiskAnalyzer: + key = "basic_risk" + ttl_seconds = 60 * 10 # 10m (HTTP/SSL/DNS can change quickly) + + async def analyze(self, ctx: AnalyzeContext) -> list[AnalyzerContribution]: + if ctx.fast: + return [ + AnalyzerContribution( + quadrant="risk", + items=[ + AnalyzeItem( + key="risk_skipped_fast_mode", + label="Risk Signals", + value=None, + status="na", + source="internal", + details={"reason": "Fast mode enabled: skip HTTP/SSL checks."}, + ) + ], + ) + ] + + health = ctx.health + if health is None: + health = await get_health_checker().check_domain(ctx.domain) + + # health object has attributes; keep access defensive + score = int(getattr(health, "score", 0) or 0) + status = getattr(getattr(health, "status", None), "value", None) or str(getattr(health, "status", "unknown")) + signals = getattr(health, "signals", []) or [] + + http = getattr(health, "http", None) + ssl = getattr(health, "ssl", None) + dns = getattr(health, "dns", None) + + http_status_code = getattr(http, "status_code", None) if http else None + http_reachable = bool(getattr(http, "is_reachable", False)) if http else False + http_parked = bool(getattr(http, "is_parked", False)) if http else False + redirect_url = getattr(http, "redirect_url", None) if http else None + parking_signals = getattr(http, "parking_signals", []) if http else [] + http_error = getattr(http, "error", None) if http else None + + ssl_has = bool(getattr(ssl, "has_ssl", False)) if ssl else False + ssl_valid = bool(getattr(ssl, "is_valid", False)) if ssl else False + ssl_days = getattr(ssl, "days_until_expiry", None) if ssl else None + ssl_issuer = getattr(ssl, "issuer", None) if ssl else None + ssl_error = getattr(ssl, "error", None) if ssl else None + + dns_has_ns = bool(getattr(dns, "has_nameservers", False)) if dns else False + dns_has_a = bool(getattr(dns, "has_a_record", False)) if dns else False + dns_parking_ns = bool(getattr(dns, "is_parking_ns", False)) if dns else False + + items = [ + AnalyzeItem( + key="health_score", + label="Health Score", + value=score, + status="pass" if score >= 80 else "warn" if score >= 50 else "fail", + source="internal", + details={"status": status, "signals": signals}, + ), + AnalyzeItem( + key="dns_infra", + label="DNS Infra", + value={"has_ns": dns_has_ns, "has_a": dns_has_a}, + status="pass" if (dns_has_ns and dns_has_a and not dns_parking_ns) else "warn", + source="dns", + details={"parking_ns": dns_parking_ns}, + ), + AnalyzeItem( + key="http", + label="HTTP", + value=http_status_code, + status="pass" if http_reachable and (http_status_code or 0) < 400 else "warn", + source="http", + details={ + "reachable": http_reachable, + "is_parked": http_parked, + "redirect_url": redirect_url, + "parking_signals": parking_signals, + "error": http_error, + }, + ), + AnalyzeItem( + key="ssl", + label="SSL", + value=ssl_days if ssl_has else None, + status="pass" if ssl_has and ssl_valid else "warn", + source="ssl", + details={"has_certificate": ssl_has, "is_valid": ssl_valid, "issuer": ssl_issuer, "error": ssl_error}, + ), + ] + + return [AnalyzerContribution(quadrant="risk", items=items)] + diff --git a/backend/app/services/analyze/analyzers/domain_facts.py b/backend/app/services/analyze/analyzers/domain_facts.py new file mode 100644 index 0000000..40bfbe0 --- /dev/null +++ b/backend/app/services/analyze/analyzers/domain_facts.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from app.schemas.analyze import AnalyzeItem +from app.services.analyze.base import AnalyzerContribution, AnalyzeContext + + +class DomainFactsAnalyzer: + key = "domain_facts" + ttl_seconds = 60 * 60 * 12 # 12h (RDAP/WHOIS changes slowly) + + async def analyze(self, ctx: AnalyzeContext) -> list[AnalyzerContribution]: + check = ctx.check + items = [ + AnalyzeItem( + key="availability", + label="Availability", + value="available" if check.is_available else "taken", + status="pass" if check.is_available else "warn", + source=str(check.check_method or "internal"), + details={"status": str(check.status.value)}, + ), + AnalyzeItem( + key="created_at", + label="Creation Date", + value=check.creation_date.isoformat() if check.creation_date else None, + status="pass" if check.creation_date else "na", + source=str(check.check_method or "internal"), + details={"reason": None if check.creation_date else "Not provided by RDAP/WHOIS for this TLD."}, + ), + AnalyzeItem( + key="expires_at", + label="Expiry Date", + value=check.expiration_date.isoformat() if check.expiration_date else None, + status="pass" if check.expiration_date else "na", + source=str(check.check_method or "internal"), + details={"reason": None if check.expiration_date else "Not provided by RDAP/WHOIS for this TLD."}, + ), + AnalyzeItem( + key="registrar", + label="Registrar", + value=check.registrar, + status="info" if check.registrar else "na", + source=str(check.check_method or "internal"), + details={}, + ), + AnalyzeItem( + key="nameservers", + label="Nameservers", + value=check.name_servers or [], + status="info" if check.name_servers else "na", + source="dns", + details={}, + ), + ] + return [AnalyzerContribution(quadrant="authority", items=items)] + diff --git a/backend/app/services/analyze/analyzers/radio_test.py b/backend/app/services/analyze/analyzers/radio_test.py new file mode 100644 index 0000000..c2afa1e --- /dev/null +++ b/backend/app/services/analyze/analyzers/radio_test.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from app.schemas.analyze import AnalyzeItem +from app.services.analyze.base import AnalyzerContribution, AnalyzeContext +from app.services.analyze.radio_test import run_radio_test + + +class RadioTestAnalyzer: + key = "radio_test" + ttl_seconds = 60 * 60 * 24 * 7 # deterministic, effectively stable + + async def analyze(self, ctx: AnalyzeContext) -> list[AnalyzerContribution]: + radio = run_radio_test(ctx.domain) + item = AnalyzeItem( + key="radio_test", + label="Radio Test", + value=radio.status, + status=radio.status, + source="internal", + details={ + "sld": radio.sld, + "syllables": radio.syllables, + "length": radio.length, + "has_hyphen": radio.has_hyphen, + "has_digits": radio.has_digits, + "rationale": radio.rationale, + }, + ) + return [AnalyzerContribution(quadrant="authority", items=[item])] + diff --git a/backend/app/services/analyze/analyzers/tld_matrix.py b/backend/app/services/analyze/analyzers/tld_matrix.py new file mode 100644 index 0000000..b171f12 --- /dev/null +++ b/backend/app/services/analyze/analyzers/tld_matrix.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from app.schemas.analyze import AnalyzeItem +from app.services.analyze.base import AnalyzerContribution, AnalyzeContext +from app.services.analyze.tld_matrix import run_tld_matrix + + +class TldMatrixAnalyzer: + key = "tld_matrix" + ttl_seconds = 60 * 30 # 30m (availability can change) + + async def analyze(self, ctx: AnalyzeContext) -> list[AnalyzerContribution]: + rows = await run_tld_matrix(ctx.domain) + item = AnalyzeItem( + key="tld_matrix", + label="TLD Matrix", + value=[row.__dict__ for row in rows], + status="info", + source="dns", + details={"tlds": [r.tld for r in rows]}, + ) + return [AnalyzerContribution(quadrant="market", items=[item])] + diff --git a/backend/app/services/analyze/analyzers/tld_pricing.py b/backend/app/services/analyze/analyzers/tld_pricing.py new file mode 100644 index 0000000..b142d4a --- /dev/null +++ b/backend/app/services/analyze/analyzers/tld_pricing.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from app.schemas.analyze import AnalyzeItem +from app.services.analyze.base import AnalyzerContribution, AnalyzeContext +from app.services.analyze.renewal_cost import get_tld_price_snapshot + + +class TldPricingAnalyzer: + key = "tld_pricing" + ttl_seconds = 60 * 60 * 6 # 6h (DB updates periodically) + + async def analyze(self, ctx: AnalyzeContext) -> list[AnalyzerContribution]: + tld = ctx.domain.split(".")[-1].lower() + snap = await get_tld_price_snapshot(ctx.db, tld) + + market_items = [ + AnalyzeItem( + key="tld_cheapest_register_usd", + label="Cheapest registration (USD)", + value=snap.min_register_usd, + status="info" if snap.min_register_usd is not None else "na", + source="db", + details={ + "registrar": snap.min_register_registrar, + "latest_recorded_at": snap.latest_recorded_at.isoformat() if snap.latest_recorded_at else None, + "reason": None if snap.min_register_usd is not None else "No TLD price data collected yet.", + }, + ), + AnalyzeItem( + key="tld_cheapest_renew_usd", + label="Cheapest renewal (USD)", + value=snap.min_renew_usd, + status="info" if snap.min_renew_usd is not None else "na", + source="db", + details={ + "registrar": snap.min_renew_registrar, + "latest_recorded_at": snap.latest_recorded_at.isoformat() if snap.latest_recorded_at else None, + "reason": None if snap.min_renew_usd is not None else "No TLD price data collected yet.", + }, + ), + AnalyzeItem( + key="tld_cheapest_transfer_usd", + label="Cheapest transfer (USD)", + value=snap.min_transfer_usd, + status="info" if snap.min_transfer_usd is not None else "na", + source="db", + details={ + "registrar": snap.min_transfer_registrar, + "latest_recorded_at": snap.latest_recorded_at.isoformat() if snap.latest_recorded_at else None, + "reason": None if snap.min_transfer_usd is not None else "No TLD price data collected yet.", + }, + ), + ] + + value_items = [ + AnalyzeItem( + key="renewal_burn_usd_per_year", + label="Renewal burn (USD/year)", + value=snap.min_renew_usd, + status="info" if snap.min_renew_usd is not None else "na", + source="db", + details={ + "assumption": "Cheapest renewal among tracked registrars (your DB).", + "registrar": snap.min_renew_registrar, + }, + ) + ] + + return [ + AnalyzerContribution(quadrant="market", items=market_items), + AnalyzerContribution(quadrant="value", items=value_items), + ] + diff --git a/backend/app/services/analyze/base.py b/backend/app/services/analyze/base.py new file mode 100644 index 0000000..ddd6719 --- /dev/null +++ b/backend/app/services/analyze/base.py @@ -0,0 +1,41 @@ +""" +Analyzer plugin interface (Alpha Terminal - Diligence). + +Each analyzer contributes items to one or more quadrants: +authority | market | risk | value +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Protocol + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.schemas.analyze import AnalyzeItem +from app.services.domain_checker import DomainCheckResult + + +@dataclass(frozen=True) +class AnalyzeContext: + db: AsyncSession + domain: str + computed_at: datetime + fast: bool + check: DomainCheckResult + health: object | None # DomainHealthReport or None (kept as object to avoid import cycles) + + +@dataclass(frozen=True) +class AnalyzerContribution: + quadrant: str # authority|market|risk|value + items: list[AnalyzeItem] + + +class Analyzer(Protocol): + key: str + ttl_seconds: int + + async def analyze(self, ctx: AnalyzeContext) -> list[AnalyzerContribution]: ... + diff --git a/backend/app/services/analyze/radio_test.py b/backend/app/services/analyze/radio_test.py new file mode 100644 index 0000000..001eda2 --- /dev/null +++ b/backend/app/services/analyze/radio_test.py @@ -0,0 +1,91 @@ +""" +Radio Test analyzer (open-data, deterministic). + +No external API, no LLM. This is a heuristic that explains its decision. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + + +VOWELS = set("aeiou") + + +def _count_syllables(word: str) -> int: + """ + Approximate syllable count for brandability heuristics. + Uses vowel-group counting with a few basic adjustments. + """ + w = re.sub(r"[^a-z]", "", word.lower()) + if not w: + return 0 + + groups = 0 + prev_vowel = False + for i, ch in enumerate(w): + is_vowel = ch in VOWELS or (ch == "y" and i > 0) + if is_vowel and not prev_vowel: + groups += 1 + prev_vowel = is_vowel + + # common silent-e adjustment + if w.endswith("e") and groups > 1: + groups -= 1 + + return max(1, groups) + + +@dataclass(frozen=True) +class RadioTestResult: + sld: str + syllables: int + length: int + has_hyphen: bool + has_digits: bool + status: str # pass|warn|fail + rationale: str + + +def run_radio_test(domain: str) -> RadioTestResult: + sld = (domain or "").split(".")[0].lower() + length = len(sld) + has_hyphen = "-" in sld + has_digits = bool(re.search(r"\d", sld)) + syllables = _count_syllables(sld) + + # Hard fails: ugly for radio + high typo risk + if length >= 20 or has_hyphen or (has_digits and length > 4): + return RadioTestResult( + sld=sld, + syllables=syllables, + length=length, + has_hyphen=has_hyphen, + has_digits=has_digits, + status="fail", + rationale="Hard to say/spell on radio (length/hyphen/digits).", + ) + + # Ideal: 2–3 syllables, 4–12 chars, no digits + if 2 <= syllables <= 3 and 4 <= length <= 12 and not has_digits: + return RadioTestResult( + sld=sld, + syllables=syllables, + length=length, + has_hyphen=has_hyphen, + has_digits=has_digits, + status="pass", + rationale="Short, pronounceable, low spelling friction.", + ) + + return RadioTestResult( + sld=sld, + syllables=syllables, + length=length, + has_hyphen=has_hyphen, + has_digits=has_digits, + status="warn", + rationale="Usable, but not ideal (syllables/length/digits).", + ) + diff --git a/backend/app/services/analyze/registry.py b/backend/app/services/analyze/registry.py new file mode 100644 index 0000000..9c61624 --- /dev/null +++ b/backend/app/services/analyze/registry.py @@ -0,0 +1,21 @@ +"""Analyzer registry (Alpha Terminal - Diligence).""" + +from __future__ import annotations + +from app.services.analyze.analyzers.domain_facts import DomainFactsAnalyzer +from app.services.analyze.analyzers.radio_test import RadioTestAnalyzer +from app.services.analyze.analyzers.tld_matrix import TldMatrixAnalyzer +from app.services.analyze.analyzers.tld_pricing import TldPricingAnalyzer +from app.services.analyze.analyzers.basic_risk import BasicRiskAnalyzer + + +def get_default_analyzers(): + # Order matters (UX) + return [ + DomainFactsAnalyzer(), + RadioTestAnalyzer(), + TldMatrixAnalyzer(), + TldPricingAnalyzer(), + BasicRiskAnalyzer(), + ] + diff --git a/backend/app/services/analyze/renewal_cost.py b/backend/app/services/analyze/renewal_cost.py new file mode 100644 index 0000000..c6188f6 --- /dev/null +++ b/backend/app/services/analyze/renewal_cost.py @@ -0,0 +1,93 @@ +""" +TLD pricing snapshot (open-data via internal DB). + +Uses our own collected TLD price history (no external API calls here). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.tld_price import TLDPrice + + +@dataclass(frozen=True) +class TldPriceSnapshot: + tld: str + min_register_usd: Optional[float] + min_register_registrar: Optional[str] + min_renew_usd: Optional[float] + min_renew_registrar: Optional[str] + min_transfer_usd: Optional[float] + min_transfer_registrar: Optional[str] + latest_recorded_at: Optional[datetime] + + +async def get_tld_price_snapshot(db: AsyncSession, tld: str) -> TldPriceSnapshot: + tld = (tld or "").lower().lstrip(".") + + # Latest record per registrar for this TLD, then take min renew. + subq = ( + select( + TLDPrice.registrar, + func.max(TLDPrice.recorded_at).label("max_date"), + ) + .where(TLDPrice.tld == tld) + .group_by(TLDPrice.registrar) + .subquery() + ) + + rows = ( + await db.execute( + select(TLDPrice) + .join( + subq, + (TLDPrice.registrar == subq.c.registrar) + & (TLDPrice.recorded_at == subq.c.max_date), + ) + .where(TLDPrice.tld == tld) + ) + ).scalars().all() + + if not rows: + return TldPriceSnapshot( + tld=tld, + min_register_usd=None, + min_register_registrar=None, + min_renew_usd=None, + min_renew_registrar=None, + min_transfer_usd=None, + min_transfer_registrar=None, + latest_recorded_at=None, + ) + + def _reg_price(r) -> float: + return float(r.registration_price or 1e12) + + def _renew_price(r) -> float: + return float(r.renewal_price or r.registration_price or 1e12) + + def _transfer_price(r) -> float: + return float(r.transfer_price or r.registration_price or 1e12) + + best_reg = min(rows, key=_reg_price) + best_renew = min(rows, key=_renew_price) + best_transfer = min(rows, key=_transfer_price) + latest = max((r.recorded_at for r in rows if r.recorded_at), default=None) + + return TldPriceSnapshot( + tld=tld, + min_register_usd=float(best_reg.registration_price), + min_register_registrar=str(best_reg.registrar), + min_renew_usd=float(best_renew.renewal_price or best_renew.registration_price), + min_renew_registrar=str(best_renew.registrar), + min_transfer_usd=float(best_transfer.transfer_price or best_transfer.registration_price), + min_transfer_registrar=str(best_transfer.registrar), + latest_recorded_at=latest, + ) + diff --git a/backend/app/services/analyze/service.py b/backend/app/services/analyze/service.py new file mode 100644 index 0000000..3bbce73 --- /dev/null +++ b/backend/app/services/analyze/service.py @@ -0,0 +1,128 @@ +""" +Analyze service orchestrator (Alpha Terminal). + +Implements the plan: +- Quadrants: authority | market | risk | value +- Analyzer registry (plugin-like) +- Open-data-first (null + reason) +""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.domain_analysis_cache import DomainAnalysisCache +from app.schemas.analyze import AnalyzeResponse, AnalyzeSection +from app.services.analyze.base import AnalyzeContext +from app.services.analyze.registry import get_default_analyzers +from app.services.domain_checker import domain_checker +from app.services.domain_health import get_health_checker + + +DEFAULT_CACHE_TTL_SECONDS = 60 * 10 # conservative fallback (10m) + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _is_cache_valid(row: DomainAnalysisCache) -> bool: + ttl = int(row.ttl_seconds or 0) + if ttl <= 0: + return False + computed = row.computed_at + if computed is None: + return False + if computed.tzinfo is None: + # stored as naive UTC typically + computed = computed.replace(tzinfo=timezone.utc) + return computed + timedelta(seconds=ttl) > _utcnow() + + +async def get_domain_analysis( + db: AsyncSession, + domain: str, + *, + fast: bool = False, + refresh: bool = False, +) -> AnalyzeResponse: + is_valid, error = domain_checker.validate_domain(domain) + if not is_valid: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error) + + norm = domain_checker._normalize_domain(domain) # internal normalize + + # Cache lookup + if not refresh: + row = ( + await db.execute(select(DomainAnalysisCache).where(DomainAnalysisCache.domain == norm)) + ).scalar_one_or_none() + if row and _is_cache_valid(row): + payload = json.loads(row.payload_json) + payload["cached"] = True + return AnalyzeResponse.model_validate(payload) + + computed_at = _utcnow() + + # Core domain facts via RDAP/DNS/WHOIS (shared input for analyzers) + check = await domain_checker.check_domain(norm, quick=False) + + # Health is expensive; compute once only when needed + health = None + if not fast: + health = await get_health_checker().check_domain(norm) + + ctx = AnalyzeContext(db=db, domain=norm, computed_at=computed_at, fast=fast, check=check, health=health) + + analyzers = get_default_analyzers() + ttl = DEFAULT_CACHE_TTL_SECONDS + + # Quadrants per plan (stable ordering) + quadrants: dict[str, AnalyzeSection] = { + "authority": AnalyzeSection(key="authority", title="Authority", items=[]), + "market": AnalyzeSection(key="market", title="Market", items=[]), + "risk": AnalyzeSection(key="risk", title="Risk", items=[]), + "value": AnalyzeSection(key="value", title="Value", items=[]), + } + + for a in analyzers: + ttl = min(ttl, int(getattr(a, "ttl_seconds", DEFAULT_CACHE_TTL_SECONDS) or DEFAULT_CACHE_TTL_SECONDS)) + contributions = await a.analyze(ctx) + for c in contributions: + if c.quadrant in quadrants: + quadrants[c.quadrant].items.extend(c.items) + + resp = AnalyzeResponse( + domain=norm, + computed_at=computed_at, + cached=False, + sections=[quadrants["authority"], quadrants["market"], quadrants["risk"], quadrants["value"]], + ) + + # Upsert cache (best-effort) + payload = resp.model_dump(mode="json") + payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + existing = ( + await db.execute(select(DomainAnalysisCache).where(DomainAnalysisCache.domain == norm)) + ).scalar_one_or_none() + if existing: + existing.payload_json = payload_json + existing.computed_at = computed_at.replace(tzinfo=None) + existing.ttl_seconds = int(ttl or DEFAULT_CACHE_TTL_SECONDS) + else: + db.add( + DomainAnalysisCache( + domain=norm, + payload_json=payload_json, + computed_at=computed_at.replace(tzinfo=None), + ttl_seconds=int(ttl or DEFAULT_CACHE_TTL_SECONDS), + ) + ) + + return resp + diff --git a/backend/app/services/analyze/tld_matrix.py b/backend/app/services/analyze/tld_matrix.py new file mode 100644 index 0000000..53910a6 --- /dev/null +++ b/backend/app/services/analyze/tld_matrix.py @@ -0,0 +1,66 @@ +""" +TLD Matrix analyzer (open-data). + +We check availability for the same SLD across a small, curated TLD set. +This is intentionally small to keep the UX fast. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + +from app.services.domain_checker import domain_checker + + +DEFAULT_TLDS = ["com", "net", "org", "io", "ai", "co", "ch"] + + +@dataclass(frozen=True) +class TldMatrixRow: + tld: str + domain: str + is_available: bool | None + status: str # available|taken|unknown + method: str + error: str | None = None + + +async def _check_one(domain: str) -> TldMatrixRow: + try: + res = await domain_checker.check_domain(domain, quick=True) + return TldMatrixRow( + tld=domain.split(".")[-1], + domain=domain, + is_available=bool(res.is_available), + status="available" if res.is_available else "taken", + method=str(res.check_method or "dns"), + error=res.error_message, + ) + except Exception as e: # noqa: BLE001 + return TldMatrixRow( + tld=domain.split(".")[-1], + domain=domain, + is_available=None, + status="unknown", + method="error", + error=str(e), + ) + + +async def run_tld_matrix(domain: str, tlds: list[str] | None = None) -> list[TldMatrixRow]: + sld = (domain or "").split(".")[0].lower().strip() + tlds = [t.lower().lstrip(".") for t in (tlds or DEFAULT_TLDS)] + + # Avoid repeated checks and the original TLD duplication + seen = set() + candidates: list[str] = [] + for t in tlds: + d = f"{sld}.{t}" + if d not in seen: + candidates.append(d) + seen.add(d) + + rows = await asyncio.gather(*[_check_one(d) for d in candidates]) + return list(rows) + diff --git a/backend/app/services/domain_health.py b/backend/app/services/domain_health.py index fb7bec6..e1997c1 100644 --- a/backend/app/services/domain_health.py +++ b/backend/app/services/domain_health.py @@ -16,6 +16,7 @@ import logging import ssl import socket import re +import ipaddress from datetime import datetime, timezone, timedelta from dataclasses import dataclass, field from typing import Optional, List, Dict, Any @@ -173,6 +174,45 @@ class DomainHealthChecker: self._dns_resolver = dns.resolver.Resolver() self._dns_resolver.timeout = 3 self._dns_resolver.lifetime = 5 + + def _is_public_ip(self, ip: str) -> bool: + try: + addr = ipaddress.ip_address(ip) + return bool(getattr(addr, "is_global", False)) + except Exception: + return False + + async def _ssrf_guard(self, domain: str) -> tuple[bool, str | None]: + """ + SSRF hardening for HTTP/SSL probes. + + We block domains that resolve exclusively to non-public IPs. + """ + loop = asyncio.get_event_loop() + + def _resolve_ips() -> list[str]: + ips: list[str] = [] + try: + a = self._dns_resolver.resolve(domain, "A") + ips.extend([str(r.address) for r in a]) + except Exception: + pass + try: + aaaa = self._dns_resolver.resolve(domain, "AAAA") + ips.extend([str(r.address) for r in aaaa]) + except Exception: + pass + # de-dup + return list(dict.fromkeys([i.strip() for i in ips if i])) + + ips = await loop.run_in_executor(None, _resolve_ips) + if not ips: + return True, None # nothing to block; will fail naturally if unreachable + + if any(self._is_public_ip(ip) for ip in ips): + return True, None + + return False, f"blocked_ssrf: resolved_non_public_ips={ips}" async def check_domain(self, domain: str) -> DomainHealthReport: """ @@ -299,10 +339,15 @@ class DomainHealthChecker: - Parking/for-sale detection """ result = HTTPCheckResult() + + allowed, reason = await self._ssrf_guard(domain) + if not allowed: + result.error = reason + return result async with httpx.AsyncClient( timeout=10.0, - follow_redirects=True, + follow_redirects=False, headers={ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } @@ -311,7 +356,24 @@ class DomainHealthChecker: url = f"{scheme}://{domain}" try: start = asyncio.get_event_loop().time() - response = await client.get(url) + + # Follow redirects manually with host/IP guard + current_url = url + for _ in range(0, 5): + response = await client.get(current_url) + if response.status_code in (301, 302, 303, 307, 308) and response.headers.get("location"): + next_url = str(httpx.URL(current_url).join(response.headers["location"])) + next_host = httpx.URL(next_url).host + if not next_host: + break + ok, why = await self._ssrf_guard(next_host) + if not ok: + result.error = why + return result + current_url = next_url + continue + break + end = asyncio.get_event_loop().time() result.status_code = response.status_code @@ -320,7 +382,7 @@ class DomainHealthChecker: result.response_time_ms = (end - start) * 1000 # Check for redirects - if response.history: + if str(response.url) != url: result.redirect_url = str(response.url) # Check for parking keywords in content @@ -355,6 +417,11 @@ class DomainHealthChecker: 2. On validation failure, extract cert info without validation """ result = SSLCheckResult() + + allowed, reason = await self._ssrf_guard(domain) + if not allowed: + result.error = reason + return result loop = asyncio.get_event_loop() diff --git a/backend/app/services/hunt/__init__.py b/backend/app/services/hunt/__init__.py new file mode 100644 index 0000000..04d431b --- /dev/null +++ b/backend/app/services/hunt/__init__.py @@ -0,0 +1,2 @@ +"""HUNT services package.""" + diff --git a/backend/app/services/hunt/brandables.py b/backend/app/services/hunt/brandables.py new file mode 100644 index 0000000..5c8df97 --- /dev/null +++ b/backend/app/services/hunt/brandables.py @@ -0,0 +1,76 @@ +""" +Brandable generator (no external APIs). + +Generates pronounceable strings and checks availability via internal DomainChecker. +""" + +from __future__ import annotations + +import asyncio +import secrets +from dataclasses import dataclass + +from app.services.domain_checker import domain_checker + + +VOWELS = "aeiou" +CONSONANTS = "bcdfghjklmnpqrstvwxz" + + +def _rand_choice(alphabet: str) -> str: + return alphabet[secrets.randbelow(len(alphabet))] + + +def generate_cvcvc() -> str: + return ( + _rand_choice(CONSONANTS) + + _rand_choice(VOWELS) + + _rand_choice(CONSONANTS) + + _rand_choice(VOWELS) + + _rand_choice(CONSONANTS) + ) + + +def generate_cvccv() -> str: + return ( + _rand_choice(CONSONANTS) + + _rand_choice(VOWELS) + + _rand_choice(CONSONANTS) + + _rand_choice(CONSONANTS) + + _rand_choice(VOWELS) + ) + + +HUMAN_SUFFIXES = ["ly", "ri", "ro", "na", "no", "mi", "li", "sa", "ta", "ya"] + + +def generate_human() -> str: + # two syllable-ish: CV + CV + suffix + base = _rand_choice(CONSONANTS) + _rand_choice(VOWELS) + _rand_choice(CONSONANTS) + _rand_choice(VOWELS) + return base + HUMAN_SUFFIXES[secrets.randbelow(len(HUMAN_SUFFIXES))] + + +@dataclass(frozen=True) +class AvailabilityResult: + domain: str + is_available: bool | None + status: str + + +async def check_domains(domains: list[str], *, concurrency: int = 25) -> list[AvailabilityResult]: + sem = asyncio.Semaphore(concurrency) + + async def _one(d: str) -> AvailabilityResult: + async with sem: + try: + res = await domain_checker.check_domain(d, quick=True) + return AvailabilityResult( + domain=d, + is_available=bool(res.is_available), + status="available" if res.is_available else "taken", + ) + except Exception: + return AvailabilityResult(domain=d, is_available=None, status="unknown") + + return list(await asyncio.gather(*[_one(d) for d in domains])) + diff --git a/backend/app/services/hunt/trends.py b/backend/app/services/hunt/trends.py new file mode 100644 index 0000000..06c7223 --- /dev/null +++ b/backend/app/services/hunt/trends.py @@ -0,0 +1,53 @@ +""" +Trend Surfer: fetch trending search queries via public RSS (no API key). + +Note: This still performs an external HTTP request to Google Trends RSS. +It's not a paid API and uses public endpoints. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional +from xml.etree import ElementTree as ET + +import httpx + + +async def fetch_google_trends_daily_rss(geo: str = "US", *, timeout_seconds: float = 10.0) -> list[dict]: + geo = (geo or "US").upper().strip() + url = f"https://trends.google.com/trends/trendingsearches/daily/rss?geo={geo}" + async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client: + res = await client.get(url) + res.raise_for_status() + xml = res.text + + root = ET.fromstring(xml) + + items: list[dict] = [] + for item in root.findall(".//item"): + title = item.findtext("title") or "" + link = item.findtext("link") + pub = item.findtext("pubDate") + approx = item.findtext("{*}approx_traffic") # namespaced + + published_at: Optional[datetime] = None + if pub: + try: + # Example: "Sat, 14 Dec 2025 00:00:00 +0000" + published_at = datetime.strptime(pub, "%a, %d %b %Y %H:%M:%S %z").astimezone(timezone.utc) + except Exception: + published_at = None + + if title.strip(): + items.append( + { + "title": title.strip(), + "approx_traffic": approx.strip() if approx else None, + "published_at": published_at, + "link": link, + } + ) + + return items + diff --git a/backend/app/services/hunt/typos.py b/backend/app/services/hunt/typos.py new file mode 100644 index 0000000..34b74b5 --- /dev/null +++ b/backend/app/services/hunt/typos.py @@ -0,0 +1,89 @@ +""" +Typo generator for Trend Surfer / brand typos. + +No external APIs. +""" + +from __future__ import annotations + +import string + + +KEYBOARD_NEIGHBORS = { + # simplified QWERTY adjacency (enough for useful typos) + "q": "wa", + "w": "qase", + "e": "wsdr", + "r": "edft", + "t": "rfgy", + "y": "tghu", + "u": "yhji", + "i": "ujko", + "o": "iklp", + "p": "ol", + "a": "qwsz", + "s": "awedxz", + "d": "serfcx", + "f": "drtgvc", + "g": "ftyhbv", + "h": "gyujbn", + "j": "huikmn", + "k": "jiolm", + "l": "kop", + "z": "asx", + "x": "zsdc", + "c": "xdfv", + "v": "cfgb", + "b": "vghn", + "n": "bhjm", + "m": "njk", +} + + +def _normalize_brand(brand: str) -> str: + b = (brand or "").lower().strip() + b = "".join(ch for ch in b if ch in string.ascii_lowercase) + return b + + +def generate_typos(brand: str, *, limit: int = 100) -> list[str]: + b = _normalize_brand(brand) + if len(b) < 2: + return [] + + candidates: list[str] = [] + seen = set() + + def _add(s: str): + if s and s not in seen and s != b: + seen.add(s) + candidates.append(s) + + # 1) single deletion + for i in range(len(b)): + _add(b[:i] + b[i + 1 :]) + if len(candidates) >= limit: + return candidates + + # 2) single insertion (duplicate char) + for i in range(len(b)): + _add(b[:i] + b[i] + b[i:]) + if len(candidates) >= limit: + return candidates + + # 3) adjacent transposition + for i in range(len(b) - 1): + _add(b[:i] + b[i + 1] + b[i] + b[i + 2 :]) + if len(candidates) >= limit: + return candidates + + # 4) neighbor substitution + for i, ch in enumerate(b): + neigh = KEYBOARD_NEIGHBORS.get(ch, "") + for n in neigh: + _add(b[:i] + n + b[i + 1 :]) + if len(candidates) >= limit: + return candidates + + return candidates[:limit] + diff --git a/deploy.sh b/deploy.sh index 96960fe..ffce0a7 100755 --- a/deploy.sh +++ b/deploy.sh @@ -159,6 +159,11 @@ if ! $BACKEND_ONLY; then BUILD_EXIT=$? if [ $BUILD_EXIT -eq 0 ]; then + # Next.js standalone output requires public + static inside standalone folder + mkdir -p .next/standalone/.next + ln -sfn ../../static .next/standalone/.next/static + ln -sfn ../../public .next/standalone/public + # Gracefully restart Next.js NEXT_PID=$(pgrep -af 'next-serv|next start|node \\.next/standalone/server\\.js' | awk 'NR==1{print $1; exit}') @@ -167,6 +172,10 @@ if ! $BACKEND_ONLY; then kill $NEXT_PID 2>/dev/null sleep 1 fi + + # Ensure port is free (avoid EADDRINUSE) + lsof -ti:3000 2>/dev/null | xargs -r kill -9 2>/dev/null || true + sleep 1 # Start new instance nohup npm run start > frontend.log 2>&1 & diff --git a/frontend/src/app/terminal/cfo/page.tsx b/frontend/src/app/terminal/cfo/page.tsx new file mode 100644 index 0000000..af0dd31 --- /dev/null +++ b/frontend/src/app/terminal/cfo/page.tsx @@ -0,0 +1,176 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { Sidebar } from '@/components/Sidebar' +import { Toast, useToast } from '@/components/Toast' +import { api } from '@/lib/api' +import { useStore } from '@/lib/store' +import { BurnRateTimeline } from '@/components/cfo/BurnRateTimeline' +import { KillList } from '@/components/cfo/KillList' +import { Loader2, RefreshCw } from 'lucide-react' +import clsx from 'clsx' + +export default function CfoPage() { + const { checkAuth } = useStore() + const { toast, hideToast } = useToast() + + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [data, setData] = useState<{ + computed_at: string + upcoming_30d_total_usd: number + upcoming_30d_rows: Array<{ + domain_id: number + domain: string + renewal_date: string | null + renewal_cost_usd: number | null + cost_source: string + is_sold: boolean + }> + monthly: Array<{ month: string; total_cost_usd: number; domains: number }> + kill_list: Array<{ + domain_id: number + domain: string + renewal_date: string | null + renewal_cost_usd: number | null + cost_source: string + auto_renew: boolean + is_dns_verified: boolean + yield_net_60d: number + yield_clicks_60d: number + reason: string + }> + } | null>(null) + + const load = useCallback(async () => { + setError(null) + const res = await api.getCfoSummary() + setData(res) + }, []) + + useEffect(() => { + checkAuth() + }, [checkAuth]) + + useEffect(() => { + let cancelled = false + const run = async () => { + setLoading(true) + try { + await load() + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : String(e)) + } finally { + if (!cancelled) setLoading(false) + } + } + run() + return () => { + cancelled = true + } + }, [load]) + + const refresh = useCallback(async () => { + setLoading(true) + try { + await load() + } finally { + setLoading(false) + } + }, [load]) + + return ( +
+ + +
+
+
+
+

PHASE 4

+

CFO

+

+ Renewal runway, burn rate, and drop advice. No fluff — just numbers. +

+
+ +
+
+ +
+ {loading ? ( +
+ +
+ ) : error ? ( +
{error}
+ ) : !data ? ( +
No data.
+ ) : ( + <> +
+
+
+
Upcoming costs
+
Next 30 days
+
+
+
${Math.round(data.upcoming_30d_total_usd)}
+
{data.upcoming_30d_rows.length} renewals due
+
+ {data.upcoming_30d_rows.slice(0, 8).map((r) => ( +
+ {r.domain} + + {r.renewal_date ? r.renewal_date.slice(0, 10) : '—'} · ${Math.round(r.renewal_cost_usd || 0)} + +
+ ))} + {data.upcoming_30d_rows.length === 0 ? ( +
No renewals in the next 30 days.
+ ) : null} +
+
+
+ + +
+ + + +
+
What to do next
+
+
+ - If a renewal cost is missing, fill it in on the domain in Portfolio → Edit. +
+
+ - “Set to Drop” is a local flag — you still need to disable auto-renew at your registrar. +
+
+ - Want to cover costs? Activate Yield only for DNS‑verified domains. +
+
+
+ + )} +
+
+ + {toast && ( + + )} +
+ ) +} + diff --git a/frontend/src/app/terminal/hunt/page.tsx b/frontend/src/app/terminal/hunt/page.tsx new file mode 100644 index 0000000..92b98a0 --- /dev/null +++ b/frontend/src/app/terminal/hunt/page.tsx @@ -0,0 +1,59 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Sidebar } from '@/components/Sidebar' +import { Toast, useToast } from '@/components/Toast' +import { useStore } from '@/lib/store' +import { HuntStrategyChips, type HuntTab } from '@/components/hunt/HuntStrategyChips' +import { SniperTab } from '@/components/hunt/SniperTab' +import { TrendSurferTab } from '@/components/hunt/TrendSurferTab' +import { BrandableForgeTab } from '@/components/hunt/BrandableForgeTab' + +export default function HuntPage() { + const { checkAuth } = useStore() + const { toast, hideToast, showToast } = useToast() + const [tab, setTab] = useState('sniper') + + useEffect(() => { + checkAuth() + }, [checkAuth]) + + return ( +
+ + +
+ {/* Header */} +
+
+
+

PHASE 1

+

HUNT

+

+ Find → Analyze → Decide. Strategy-first discovery for domainers. +

+
+ +
+
+ + {/* Content */} +
+ {tab === 'sniper' ? : null} + {tab === 'trends' ? : null} + {tab === 'forge' ? : null} +
+
+ + {toast && ( + + )} +
+ ) +} + diff --git a/frontend/src/app/terminal/layout.tsx b/frontend/src/app/terminal/layout.tsx index 45b49a9..3236437 100644 --- a/frontend/src/app/terminal/layout.tsx +++ b/frontend/src/app/terminal/layout.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import { useRouter, usePathname } from 'next/navigation' import { useStore } from '@/lib/store' +import { AnalyzePanelProvider } from '@/components/analyze/AnalyzePanelProvider' import { Loader2 } from 'lucide-react' export default function TerminalLayout({ @@ -58,6 +59,6 @@ export default function TerminalLayout({ ) } - return <>{children} + return {children} } diff --git a/frontend/src/app/terminal/market/page.tsx b/frontend/src/app/terminal/market/page.tsx index 699bb72..6646fbf 100644 --- a/frontend/src/app/terminal/market/page.tsx +++ b/frontend/src/app/terminal/market/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, useMemo, useCallback } from 'react' import { useStore } from '@/lib/store' import { api } from '@/lib/api' +import { useAnalyzePanelStore } from '@/lib/analyze-store' import { Sidebar } from '@/components/Sidebar' import { Toast, useToast } from '@/components/Toast' import { @@ -121,6 +122,7 @@ function isSpam(domain: string): boolean { export default function MarketPage() { const { subscription, user, logout, checkAuth } = useStore() const { toast, showToast, hideToast } = useToast() + const openAnalyze = useAnalyzePanelStore((s) => s.open) const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) @@ -762,7 +764,13 @@ export default function MarketPage() { )}
-
{item.domain}
+
{item.source} | @@ -813,6 +821,14 @@ export default function MarketPage() { {isTracked ? 'Tracked' : 'Track'} + +
-
{item.domain}
+
{item.source} {isPounce && item.verified && ( @@ -923,6 +945,14 @@ export default function MarketPage() { )} + + s.open) const [domains, setDomains] = useState([]) const [summary, setSummary] = useState(null) @@ -646,7 +649,7 @@ export default function PortfolioPage() { const [checkingHealth, setCheckingHealth] = useState>(new Set()) // External status (Yield, Listed) - const [yieldDomains, setYieldDomains] = useState>(new Set()) + const [yieldByDomain, setYieldByDomain] = useState>({}) const [listedDomains, setListedDomains] = useState>(new Set()) // Mobile @@ -673,9 +676,14 @@ export default function PortfolioPage() { // Load yield domains try { const yieldData = await api.getYieldDomains() - const active = (yieldData.domains || []).filter((d: any) => d.status === 'active') - setYieldDomains(new Set(active.map((d: any) => String(d.domain).toLowerCase()))) - } catch { setYieldDomains(new Set()) } + const map: Record = {} + for (const d of yieldData.domains || []) { + const key = String((d as any).domain || '').toLowerCase() + if (!key) continue + map[key] = { id: Number((d as any).id), status: String((d as any).status || 'pending'), dns_verified: Boolean((d as any).dns_verified) } + } + setYieldByDomain(map) + } catch { setYieldByDomain({}) } // Load listed domains try { @@ -878,9 +886,18 @@ export default function PortfolioPage() { ) } - if (yieldDomains.has(domain.domain.toLowerCase())) { + const y = yieldByDomain[domain.domain.toLowerCase()] + if (y) { + const isActive = y.status === 'active' badges.push( - + ) @@ -895,6 +912,48 @@ export default function PortfolioPage() { return badges.length > 0 ?
{badges}
: null } + const renderYieldCell = (domain: PortfolioDomain) => { + const y = yieldByDomain[domain.domain.toLowerCase()] + const canActivate = domain.is_dns_verified && !domain.is_sold + if (y?.status === 'active') { + return ( + + ⚡ Active + + ) + } + if (y) { + return ( + + {y.status} + + ) + } + return ( + { if (!canActivate) e.preventDefault() }} + > + 💤 Idle + + ) + } + return (
@@ -1029,7 +1088,7 @@ export default function PortfolioPage() { ) : (
{/* Desktop Table Header */} -
+
@@ -1046,6 +1105,7 @@ export default function PortfolioPage() { +
Yield
Health
Actions
@@ -1067,7 +1127,7 @@ export default function PortfolioPage() { )} > {/* DESKTOP ROW */} -
+
{/* Domain */}
+ {/* Yield */} +
+ {renderYieldCell(domain)} +
+ {/* Health */}
{renderHealth(domain)} @@ -1151,6 +1216,13 @@ export default function PortfolioPage() { {/* Actions */}
+ + { if (!domain.is_dns_verified || domain.is_sold) e.preventDefault() }} + className={clsx( + "flex items-center gap-1 px-2 py-1.5 text-[9px] font-mono uppercase border", + domain.is_dns_verified && !domain.is_sold + ? "border-white/10 text-white/60 bg-white/5 hover:text-white hover:bg-white/10" + : "border-white/10 text-white/20 bg-white/[0.02] cursor-not-allowed" + )} + > + + Yield + {!domain.is_dns_verified && !domain.is_sold && (
-
{domain.name}
+
{domain.registrar || 'Unknown registrar'}
@@ -548,6 +556,14 @@ export default function WatchlistPage() { > + +
-
{domain.name}
+
{domain.registrar || 'Unknown'}
@@ -663,6 +685,13 @@ export default function WatchlistPage() { > +
) } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 1faa02f..4b7be10 100755 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -16,6 +16,7 @@ import { Crown, Zap, Shield, + Crosshair, Menu, X, Tag, @@ -68,6 +69,12 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S // SECTION 1: Discover - Radar first, then external market data const discoverItems = [ + { + href: '/terminal/hunt', + label: 'HUNT', + icon: Crosshair, + badge: null, + }, { href: '/terminal/radar', label: 'RADAR', @@ -108,6 +115,12 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S icon: Briefcase, badge: null, }, + { + href: '/terminal/cfo', + label: 'CFO', + icon: Shield, + badge: null, + }, { href: '/terminal/inbox', label: 'INBOX', diff --git a/frontend/src/components/analyze/AnalyzePanel.tsx b/frontend/src/components/analyze/AnalyzePanel.tsx new file mode 100644 index 0000000..8756402 --- /dev/null +++ b/frontend/src/components/analyze/AnalyzePanel.tsx @@ -0,0 +1,321 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import clsx from 'clsx' +import { X, RefreshCw, Search, Shield, Zap, Copy, ExternalLink } from 'lucide-react' +import { api } from '@/lib/api' +import { useAnalyzePanelStore } from '@/lib/analyze-store' +import type { AnalyzeResponse, AnalyzeSection, AnalyzeItem } from '@/components/analyze/types' + +function statusPill(status: string) { + switch (status) { + case 'pass': + return 'bg-accent/10 text-accent border-accent/20' + case 'warn': + return 'bg-amber-400/10 text-amber-300 border-amber-400/20' + case 'fail': + return 'bg-red-500/10 text-red-300 border-red-500/20' + case 'na': + return 'bg-white/5 text-white/30 border-white/10' + default: + return 'bg-white/5 text-white/50 border-white/10' + } +} + +async function copyToClipboard(text: string) { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + return false + } +} + +function formatValue(value: unknown): string { + if (value === null || value === undefined) return 'N/A' + if (typeof value === 'string') return value + if (typeof value === 'number') return String(value) + if (typeof value === 'boolean') return value ? 'Yes' : 'No' + if (Array.isArray(value)) return `${value.length} items` + return 'Details' +} + +function filterSection(section: AnalyzeSection, filterText: string): AnalyzeSection { + const f = filterText.trim().toLowerCase() + if (!f) return section + const items = section.items.filter((it) => { + const base = `${it.label} ${it.key} ${formatValue(it.value)}`.toLowerCase() + return base.includes(f) + }) + return { ...section, items } +} + +function isMatrix(item: AnalyzeItem) { + return item.key === 'tld_matrix' && Array.isArray(item.value) +} + +export function AnalyzePanel() { + const { isOpen, domain, close, fastMode, setFastMode, filterText, setFilterText, sectionVisibility, setSectionVisibility } = + useAnalyzePanelStore() + + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [data, setData] = useState(null) + const [copied, setCopied] = useState(false) + + const refresh = useCallback(async () => { + if (!domain) return + setLoading(true) + setError(null) + try { + const res = await api.analyzeDomain(domain, { fast: fastMode, refresh: true }) + setData(res) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + setData(null) + } finally { + setLoading(false) + } + }, [domain, fastMode]) + + useEffect(() => { + if (!isOpen || !domain) return + let cancelled = false + const run = async () => { + setLoading(true) + setError(null) + try { + const res = await api.analyzeDomain(domain, { fast: fastMode, refresh: false }) + if (!cancelled) setData(res) + } catch (e) { + if (!cancelled) { + setError(e instanceof Error ? e.message : String(e)) + setData(null) + } + } finally { + if (!cancelled) setLoading(false) + } + } + run() + return () => { + cancelled = true + } + }, [isOpen, domain, fastMode]) + + // ESC to close + useEffect(() => { + if (!isOpen) return + const onKey = (ev: KeyboardEvent) => { + if (ev.key === 'Escape') close() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [isOpen, close]) + + const visibleSections = useMemo(() => { + const sections = data?.sections || [] + const order = ['authority', 'market', 'risk', 'value'] + const ordered = [...sections].sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key)) + return ordered + .filter((s) => sectionVisibility[s.key] !== false) + .map((s) => filterSection(s, filterText)) + .filter((s) => s.items.length > 0 || !filterText.trim()) + }, [data, sectionVisibility, filterText]) + + const headerDomain = data?.domain || domain || '' + const computedAt = data?.computed_at ? new Date(data.computed_at).toLocaleString() : null + + if (!isOpen) return null + + return ( +
+
+ +
+ {/* Header */} +
+
+
+ +
ANALYZE
+ {data?.cached ? ( + CACHED + ) : null} +
+
+
{headerDomain}
+ + {copied ? Copied : null} +
+ {computedAt ?
Computed: {computedAt}
: null} +
+ +
+ + + + + +
+
+ + {/* Controls */} +
+
+
+ + setFilterText(e.target.value)} + placeholder="Filter signals…" + className="w-full bg-white/[0.02] border border-white/10 pl-9 pr-3 py-2 text-sm text-white placeholder:text-white/25 outline-none focus:border-accent/40 font-mono" + /> +
+ +
+ +
+ {(['authority', 'market', 'risk', 'value'] as const).map((key) => { + const on = sectionVisibility[key] !== false + return ( + + ) + })} +
+
+ + {/* Body */} +
+ {loading ? ( +
Loading…
+ ) : error ? ( +
+
Analyze failed
+
{error}
+
+ ) : !data ? ( +
No data.
+ ) : ( +
+
+ {visibleSections.map((section) => ( +
+
+
{section.title}
+
{section.key}
+
+
+ {section.items.map((it) => ( +
+
+
{it.label}
+
+ {isMatrix(it) ? ( +
+ {(it.value as any[]).slice(0, 14).map((row: any) => ( +
+ {String(row.domain)} + + {String(row.status).toUpperCase()} + +
+ ))} +
+ ) : ( + formatValue(it.value) + )} +
+ {it.details && Object.keys(it.details).length ? ( +
+ + Details + +
+                                  {JSON.stringify(it.details, null, 2)}
+                                
+
+ ) : null} +
+
+ + {it.status} + + {it.source} +
+
+ ))} +
+
+ ))} +
+
+ )} +
+ + {/* Footer */} +
+
+ Open-data-first. Some signals (trademarks/search volume/Wayback) require explicit data sources; we’ll only add them when we can do it without external APIs. +
+
+
+
+ ) +} + diff --git a/frontend/src/components/analyze/AnalyzePanelProvider.tsx b/frontend/src/components/analyze/AnalyzePanelProvider.tsx new file mode 100644 index 0000000..7309ece --- /dev/null +++ b/frontend/src/components/analyze/AnalyzePanelProvider.tsx @@ -0,0 +1,48 @@ +'use client' + +import { useEffect } from 'react' +import { AnalyzePanel } from '@/components/analyze/AnalyzePanel' +import { ANALYZE_PREFS_KEY, useAnalyzePanelStore } from '@/lib/analyze-store' + +type StoredPrefs = { + fastMode?: boolean + sectionVisibility?: Record +} + +export function AnalyzePanelProvider({ children }: { children: React.ReactNode }) { + const { fastMode, sectionVisibility, setFastMode, setSectionVisibility } = useAnalyzePanelStore() + + // Hydrate from localStorage once + useEffect(() => { + try { + const raw = localStorage.getItem(ANALYZE_PREFS_KEY) + if (!raw) return + const parsed = JSON.parse(raw) as StoredPrefs + if (typeof parsed.fastMode === 'boolean') setFastMode(parsed.fastMode) + if (parsed.sectionVisibility && typeof parsed.sectionVisibility === 'object') { + setSectionVisibility({ ...sectionVisibility, ...parsed.sectionVisibility }) + } + } catch { + // ignore corrupted prefs + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Persist prefs + useEffect(() => { + try { + const payload: StoredPrefs = { fastMode, sectionVisibility } + localStorage.setItem(ANALYZE_PREFS_KEY, JSON.stringify(payload)) + } catch { + // ignore + } + }, [fastMode, sectionVisibility]) + + return ( + <> + {children} + + + ) +} + diff --git a/frontend/src/components/analyze/types.ts b/frontend/src/components/analyze/types.ts new file mode 100644 index 0000000..8e267b1 --- /dev/null +++ b/frontend/src/components/analyze/types.ts @@ -0,0 +1,22 @@ +export type AnalyzeItem = { + key: string + label: string + value: unknown | null + status: 'pass' | 'warn' | 'fail' | 'info' | 'na' | string + source: string + details: Record +} + +export type AnalyzeSection = { + key: string + title: string + items: AnalyzeItem[] +} + +export type AnalyzeResponse = { + domain: string + computed_at: string + cached: boolean + sections: AnalyzeSection[] +} + diff --git a/frontend/src/components/cfo/BurnRateTimeline.tsx b/frontend/src/components/cfo/BurnRateTimeline.tsx new file mode 100644 index 0000000..5c87bec --- /dev/null +++ b/frontend/src/components/cfo/BurnRateTimeline.tsx @@ -0,0 +1,45 @@ +'use client' + +import clsx from 'clsx' + +export function BurnRateTimeline({ + monthly, +}: { + monthly: Array<{ month: string; total_cost_usd: number; domains: number }> +}) { + const max = Math.max(1, ...monthly.map((m) => m.total_cost_usd || 0)) + return ( +
+
+
Burn Rate Monitor
+
Renewals timeline (12 months)
+
+
+
+ {monthly.map((m) => { + const pct = Math.round(((m.total_cost_usd || 0) / max) * 100) + const isHot = m.total_cost_usd >= max * 0.7 + return ( +
+
{m.month}
+
+
+
+
+ ${Math.round(m.total_cost_usd)} · {m.domains} +
+
+ ) + })} +
+
+ Costs are based on your portfolio renewal_cost when present; otherwise cheapest renewal from your tracked TLD price DB. +
+
+
+ ) +} + diff --git a/frontend/src/components/cfo/KillList.tsx b/frontend/src/components/cfo/KillList.tsx new file mode 100644 index 0000000..19b3cc1 --- /dev/null +++ b/frontend/src/components/cfo/KillList.tsx @@ -0,0 +1,116 @@ +'use client' + +import { useCallback, useState } from 'react' +import clsx from 'clsx' +import { Loader2, Shield, Trash2 } from 'lucide-react' +import { api } from '@/lib/api' +import { useAnalyzePanelStore } from '@/lib/analyze-store' + +export function KillList({ + rows, + onChanged, +}: { + rows: Array<{ + domain_id: number + domain: string + renewal_date: string | null + renewal_cost_usd: number | null + cost_source: string + auto_renew: boolean + is_dns_verified: boolean + yield_net_60d: number + yield_clicks_60d: number + reason: string + }> + onChanged: () => Promise +}) { + const openAnalyze = useAnalyzePanelStore((s) => s.open) + const [busyId, setBusyId] = useState(null) + + const setToDrop = useCallback( + async (id: number) => { + setBusyId(id) + try { + await api.cfoSetToDrop(id) + await onChanged() + } finally { + setBusyId(null) + } + }, + [onChanged] + ) + + return ( +
+
+
Kill List
+
Drop advisor (30d)
+
+ Criteria: renewal due in < 30 days + no tracked yield signals in last 60 days. +
+
+ + {rows.length === 0 ? ( +
No drop candidates right now.
+ ) : ( +
+ {rows.map((r) => { + const busy = busyId === r.domain_id + const cost = r.renewal_cost_usd != null ? `$${Math.round(r.renewal_cost_usd)}` : '—' + return ( +
+
+
+ +
{r.reason}
+
+ Renewal: {r.renewal_date ? r.renewal_date.slice(0, 10) : '—'} + Cost: {cost} ({r.cost_source}) + + Yield 60d: ${Math.round(r.yield_net_60d)} · {r.yield_clicks_60d} clicks + + {!r.auto_renew ? ( + SET TO DROP + ) : null} +
+
+ +
+ + +
+
+
+ ) + })} +
+ )} +
+ ) +} + diff --git a/frontend/src/components/hunt/BrandableForgeTab.tsx b/frontend/src/components/hunt/BrandableForgeTab.tsx new file mode 100644 index 0000000..946cc99 --- /dev/null +++ b/frontend/src/components/hunt/BrandableForgeTab.tsx @@ -0,0 +1,175 @@ +'use client' + +import { useCallback, useState } from 'react' +import clsx from 'clsx' +import { ExternalLink, Loader2, Shield, Sparkles, Eye } from 'lucide-react' +import { api } from '@/lib/api' +import { useAnalyzePanelStore } from '@/lib/analyze-store' +import { useStore } from '@/lib/store' + +const PATTERNS = [ + { key: 'cvcvc', label: 'CVCVC (5 letters)' }, + { key: 'cvccv', label: 'CVCCV (5 letters)' }, + { key: 'human', label: 'Human-like (2 syllables)' }, +] + +function parseTlds(input: string): string[] { + return input + .split(',') + .map((t) => t.trim().toLowerCase().replace(/^\./, '')) + .filter(Boolean) + .slice(0, 10) +} + +export function BrandableForgeTab({ showToast }: { showToast: (message: string, type?: any) => void }) { + const openAnalyze = useAnalyzePanelStore((s) => s.open) + const addDomain = useStore((s) => s.addDomain) + const [pattern, setPattern] = useState('cvcvc') + const [tldsRaw, setTldsRaw] = useState('com') + const [limit, setLimit] = useState(30) + const [loading, setLoading] = useState(false) + const [items, setItems] = useState>([]) + const [error, setError] = useState(null) + const [tracking, setTracking] = useState(null) + + const run = useCallback(async () => { + setLoading(true) + setError(null) + try { + const res = await api.huntBrandables({ pattern, tlds: parseTlds(tldsRaw), limit, max_checks: 400 }) + setItems(res.items.map((i) => ({ domain: i.domain, status: i.status }))) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + setItems([]) + } finally { + setLoading(false) + } + }, [pattern, tldsRaw, limit]) + + const track = useCallback( + async (domain: string) => { + if (tracking) return + setTracking(domain) + try { + await addDomain(domain) + showToast(`Tracked ${domain}`, 'success') + } catch (e) { + showToast(e instanceof Error ? e.message : 'Failed to track domain', 'error') + } finally { + setTracking(null) + } + }, + [addDomain, showToast, tracking] + ) + + return ( +
+
+
+
Brandable Forge
+
Generate & verify
+
+ +
+ +
+
+
+ + +
+ +
+ + setTldsRaw(e.target.value)} + className="w-full bg-white/[0.02] border border-white/10 px-3 py-2 text-sm text-white outline-none focus:border-accent/40 font-mono" + placeholder="com, io, ai" + /> +
+ +
+ + setLimit(Math.max(1, Math.min(100, Number(e.target.value) || 30)))} + className="w-full bg-white/[0.02] border border-white/10 px-3 py-2 text-sm text-white outline-none focus:border-accent/40 font-mono" + min={1} + max={100} + /> +
+ +
+ No external APIs. Availability is checked via DNS/RDAP (quick mode). We only return domains that are actually available. +
+
+ +
+ {error ?
{error}
: null} +
+ {items.map((i) => ( +
+ +
+ + + + + +
+
+ ))} + {!loading && items.length === 0 ?
No results yet.
: null} +
+
+
+
+ ) +} + diff --git a/frontend/src/components/hunt/HuntStrategyChips.tsx b/frontend/src/components/hunt/HuntStrategyChips.tsx new file mode 100644 index 0000000..9b3fb67 --- /dev/null +++ b/frontend/src/components/hunt/HuntStrategyChips.tsx @@ -0,0 +1,41 @@ +'use client' + +import clsx from 'clsx' + +export type HuntTab = 'sniper' | 'trends' | 'forge' + +export function HuntStrategyChips({ + tab, + onChange, +}: { + tab: HuntTab + onChange: (tab: HuntTab) => void +}) { + const chips: Array<{ key: HuntTab; label: string; hint: string }> = [ + { key: 'sniper', label: 'THE SNIPER', hint: 'Closeouts < $10 · 5y+ · backlinks' }, + { key: 'trends', label: 'TREND SURFER', hint: 'Trends + keyword availability + typos' }, + { key: 'forge', label: 'BRANDABLE FORGE', hint: 'Generate pronounceables + check availability' }, + ] + + return ( +
+ {chips.map((c) => { + const active = tab === c.key + return ( + + ) + })} +
+ ) +} + diff --git a/frontend/src/components/hunt/SniperTab.tsx b/frontend/src/components/hunt/SniperTab.tsx new file mode 100644 index 0000000..3b5d982 --- /dev/null +++ b/frontend/src/components/hunt/SniperTab.tsx @@ -0,0 +1,221 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import clsx from 'clsx' +import { ExternalLink, Loader2, RefreshCw, Shield, Eye } from 'lucide-react' +import { api } from '@/lib/api' +import { useAnalyzePanelStore } from '@/lib/analyze-store' +import { useStore } from '@/lib/store' + +function calcTimeRemaining(endTimeIso: string): string { + const end = new Date(endTimeIso).getTime() + const now = Date.now() + const diff = end - now + if (diff <= 0) return 'Ended' + const seconds = Math.floor(diff / 1000) + const days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + const mins = Math.floor((seconds % 3600) / 60) + if (days > 0) return `${days}d ${hours}h` + if (hours > 0) return `${hours}h ${mins}m` + if (mins > 0) return `${mins}m` + return '< 1m' +} + +export function SniperTab({ showToast }: { showToast: (message: string, type?: any) => void }) { + const openAnalyze = useAnalyzePanelStore((s) => s.open) + const addDomain = useStore((s) => s.addDomain) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [error, setError] = useState(null) + const [items, setItems] = useState< + Array<{ + domain: string + platform: string + auction_url: string + current_bid: number + currency: string + end_time: string + age_years: number | null + backlinks: number | null + pounce_score: number | null + }> + >([]) + + const load = useCallback(async () => { + setError(null) + const res = await api.getHuntBargainBin(200) + setItems(res.items || []) + }, []) + + useEffect(() => { + let cancelled = false + const run = async () => { + setLoading(true) + try { + await load() + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : String(e)) + } finally { + if (!cancelled) setLoading(false) + } + } + run() + return () => { + cancelled = true + } + }, [load]) + + const handleRefresh = useCallback(async () => { + setRefreshing(true) + try { + await load() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setRefreshing(false) + } + }, [load]) + + const rows = useMemo(() => items.slice(0, 150), [items]) + const [tracking, setTracking] = useState(null) + + const track = useCallback( + async (domain: string) => { + if (tracking) return + setTracking(domain) + try { + await addDomain(domain) + showToast(`Tracked ${domain}`, 'success') + } catch (e) { + showToast(e instanceof Error ? e.message : 'Failed to track domain', 'error') + } finally { + setTracking(null) + } + }, + [addDomain, showToast, tracking] + ) + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+
+
+
Closeout Sniper
+
Bargain Bin
+
Only real scraped data: price < $10, age ≥ 5y, backlinks > 0.
+
+ +
+ + {error ? ( +
{error}
+ ) : rows.length === 0 ? ( +
No sniper items right now.
+ ) : ( +
+
+
Domain
+
Age
+
Backlinks
+
Price
+
Time
+
Action
+
+ + {rows.map((r) => ( +
+
+
+ +
+ + + + +
+
+ +
+ {r.age_years !== null ? `${r.age_years}y` : '—'} +
+
+ {r.backlinks !== null ? String(r.backlinks) : '—'} +
+
+ ${Math.round(r.current_bid)} +
+
{calcTimeRemaining(r.end_time)}
+ +
+ + + + Buy + + +
+
+
+ ))} +
+ )} +
+ ) +} + diff --git a/frontend/src/components/hunt/TrendSurferTab.tsx b/frontend/src/components/hunt/TrendSurferTab.tsx new file mode 100644 index 0000000..b695580 --- /dev/null +++ b/frontend/src/components/hunt/TrendSurferTab.tsx @@ -0,0 +1,299 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import clsx from 'clsx' +import { ExternalLink, Loader2, Search, Shield, Sparkles, Eye } from 'lucide-react' +import { api } from '@/lib/api' +import { useAnalyzePanelStore } from '@/lib/analyze-store' +import { useStore } from '@/lib/store' + +function normalizeKeyword(s: string) { + return s.trim().replace(/\s+/g, ' ') +} + +export function TrendSurferTab({ showToast }: { showToast: (message: string, type?: any) => void }) { + const openAnalyze = useAnalyzePanelStore((s) => s.open) + const addDomain = useStore((s) => s.addDomain) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [geo, setGeo] = useState('US') + const [trends, setTrends] = useState>([]) + const [selected, setSelected] = useState('') + + const [keywordInput, setKeywordInput] = useState('') + const [availability, setAvailability] = useState>([]) + const [checking, setChecking] = useState(false) + + const [brand, setBrand] = useState('') + const [typos, setTypos] = useState>([]) + const [typoLoading, setTypoLoading] = useState(false) + const [tracking, setTracking] = useState(null) + + const track = useCallback( + async (domain: string) => { + if (tracking) return + setTracking(domain) + try { + await addDomain(domain) + showToast(`Tracked ${domain}`, 'success') + } catch (e) { + showToast(e instanceof Error ? e.message : 'Failed to track domain', 'error') + } finally { + setTracking(null) + } + }, + [addDomain, showToast, tracking] + ) + + const loadTrends = useCallback(async () => { + setError(null) + const res = await api.getHuntTrends(geo) + setTrends(res.items || []) + if (!selected && res.items?.[0]?.title) setSelected(res.items[0].title) + }, [geo, selected]) + + useEffect(() => { + let cancelled = false + const run = async () => { + setLoading(true) + try { + await loadTrends() + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : String(e)) + } finally { + if (!cancelled) setLoading(false) + } + } + run() + return () => { + cancelled = true + } + }, [loadTrends]) + + const keyword = useMemo(() => normalizeKeyword(keywordInput || selected || ''), [keywordInput, selected]) + + const runCheck = useCallback(async () => { + if (!keyword) return + setChecking(true) + try { + const kw = keyword.toLowerCase().replace(/\s+/g, '') + const res = await api.huntKeywords({ keywords: [kw], tlds: ['com', 'io', 'ai', 'net', 'org'] }) + setAvailability(res.items.map((r) => ({ domain: r.domain, status: r.status, is_available: r.is_available }))) + } finally { + setChecking(false) + } + }, [keyword]) + + const runTypos = useCallback(async () => { + const b = brand.trim() + if (!b) return + setTypoLoading(true) + try { + const res = await api.huntTypos({ brand: b, tlds: ['com'], limit: 50 }) + setTypos(res.items.map((i) => ({ domain: i.domain, status: i.status }))) + } finally { + setTypoLoading(false) + } + }, [brand]) + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+
+
+
Top Google Trends (24h)
+
+ setGeo(e.target.value.toUpperCase().slice(0, 2))} + className="w-16 bg-white/[0.02] border border-white/10 px-2 py-1 text-xs font-mono text-white/70 outline-none focus:border-accent/40" + title="Geo (US/CH/DE/...)" + /> + +
+ {error ?
{error}
: null} +
+
+ {trends.slice(0, 30).map((t) => { + const active = selected === t.title + return ( + + ) + })} +
+
+ +
+ {/* Keyword availability */} +
+
+
+
Keyword Availability
+
Find available domains
+
+ +
+
+
+ + setKeywordInput(e.target.value)} + placeholder="Type a trend keyword…" + className="w-full bg-white/[0.02] border border-white/10 pl-9 pr-3 py-2 text-sm text-white placeholder:text-white/25 outline-none focus:border-accent/40 font-mono" + /> +
+ +
+ {availability.map((a) => ( +
+ +
+ + {a.status.toUpperCase()} + + + +
+
+ ))} + {availability.length === 0 ?
No results yet.
: null} +
+
+
+ + {/* Typo check */} +
+
+
+
Typo Check
+
Find typo domains of big brands
+
+ +
+
+ setBrand(e.target.value)} + placeholder="e.g. Shopify" + className="w-full bg-white/[0.02] border border-white/10 px-3 py-2 text-sm text-white placeholder:text-white/25 outline-none focus:border-accent/40 font-mono" + /> +
+ {typos.map((t) => ( +
+ +
+ {t.status.toUpperCase()} + + +
+
+ ))} + {typos.length === 0 ?
No typo results yet.
: null} +
+
+
+
+
+ ) +} + diff --git a/frontend/src/lib/analyze-store.ts b/frontend/src/lib/analyze-store.ts new file mode 100644 index 0000000..3018936 --- /dev/null +++ b/frontend/src/lib/analyze-store.ts @@ -0,0 +1,39 @@ +import { create } from 'zustand' + +export type AnalyzeSectionVisibility = Record + +export type AnalyzePanelState = { + isOpen: boolean + domain: string | null + fastMode: boolean + filterText: string + sectionVisibility: AnalyzeSectionVisibility + open: (domain: string) => void + close: () => void + setFastMode: (fast: boolean) => void + setFilterText: (value: string) => void + setSectionVisibility: (next: AnalyzeSectionVisibility) => void +} + +const DEFAULT_VISIBILITY: AnalyzeSectionVisibility = { + authority: true, + market: true, + risk: true, + value: true, +} + +export const useAnalyzePanelStore = create((set) => ({ + isOpen: false, + domain: null, + fastMode: false, + filterText: '', + sectionVisibility: DEFAULT_VISIBILITY, + open: (domain) => set({ isOpen: true, domain, filterText: '' }), + close: () => set({ isOpen: false }), + setFastMode: (fastMode) => set({ fastMode }), + setFilterText: (filterText) => set({ filterText }), + setSectionVisibility: (sectionVisibility) => set({ sectionVisibility }), +})) + +export const ANALYZE_PREFS_KEY = 'pounce_analyze_prefs_v1' + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c7500c0..c9ed575 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -162,6 +162,125 @@ class ApiClient { }>('/dashboard/summary') } + // Analyze (Alpha Terminal - Diligence) + async analyzeDomain(domain: string, opts?: { fast?: boolean; refresh?: boolean }) { + const params = new URLSearchParams() + if (opts?.fast) params.set('fast', 'true') + if (opts?.refresh) params.set('refresh', 'true') + const qs = params.toString() + return this.request<{ + domain: string + computed_at: string + cached: boolean + sections: Array<{ + key: string + title: string + items: Array<{ + key: string + label: string + value: unknown | null + status: string + source: string + details: Record + }> + }> + }>(`/analyze/${encodeURIComponent(domain)}${qs ? `?${qs}` : ''}`) + } + + // HUNT (Alpha Terminal - Discovery) + async getHuntBargainBin(limit: number = 100) { + const qs = new URLSearchParams({ limit: String(limit) }) + return this.request<{ + items: Array<{ + domain: string + platform: string + auction_url: string + current_bid: number + currency: string + end_time: string + age_years: number | null + backlinks: number | null + pounce_score: number | null + }> + total: number + filtered_out_missing_data: number + last_updated: string | null + }>(`/hunt/bargain-bin?${qs}`) + } + + async getHuntTrends(geo: string = 'US') { + const qs = new URLSearchParams({ geo }) + return this.request<{ + geo: string + items: Array<{ title: string; approx_traffic?: string | null; published_at?: string | null; link?: string | null }> + fetched_at: string + }>(`/hunt/trends?${qs}`) + } + + async huntKeywords(payload: { keywords: string[]; tlds?: string[] }) { + return this.request<{ + items: Array<{ keyword: string; domain: string; tld: string; is_available: boolean | null; status: string }> + }>(`/hunt/keywords`, { + method: 'POST', + body: JSON.stringify(payload), + }) + } + + async huntTypos(payload: { brand: string; tlds?: string[]; limit?: number }) { + return this.request<{ + brand: string + items: Array<{ domain: string; is_available: boolean | null; status: string }> + }>(`/hunt/typos`, { + method: 'POST', + body: JSON.stringify(payload), + }) + } + + async huntBrandables(payload: { pattern: string; tlds?: string[]; limit?: number; max_checks?: number }) { + return this.request<{ + pattern: string + items: Array<{ domain: string; is_available: boolean | null; status: string }> + }>(`/hunt/brandables`, { + method: 'POST', + body: JSON.stringify(payload), + }) + } + + // CFO (Alpha Terminal - Management) + async getCfoSummary() { + return this.request<{ + computed_at: string + upcoming_30d_total_usd: number + upcoming_30d_rows: Array<{ + domain_id: number + domain: string + renewal_date: string | null + renewal_cost_usd: number | null + cost_source: string + is_sold: boolean + }> + monthly: Array<{ month: string; total_cost_usd: number; domains: number }> + kill_list: Array<{ + domain_id: number + domain: string + renewal_date: string | null + renewal_cost_usd: number | null + cost_source: string + auto_renew: boolean + is_dns_verified: boolean + yield_net_60d: number + yield_clicks_60d: number + reason: string + }> + }>(`/cfo/summary`) + } + + async cfoSetToDrop(domainId: number) { + return this.request<{ domain_id: number; auto_renew: boolean; updated_at: string }>(`/cfo/domains/${domainId}/set-to-drop`, { + method: 'POST', + }) + } + async updateMe(data: { name?: string }) { return this.request<{ id: number