feat: add Alpha Terminal HUNT/CFO modules and Analyze framework
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled

Adds HUNT (Sniper/Trend/Forge), CFO dashboard (burn rate + kill list), and a plugin-based Analyze side panel with caching and SSRF hardening.
This commit is contained in:
2025-12-15 16:15:58 +01:00
parent 49732fb649
commit 3485668b5e
47 changed files with 3612 additions and 18 deletions

View File

@ -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"])

View File

@ -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

197
backend/app/api/cfo.py Normal file
View File

@ -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))

242
backend/app/api/hunt.py Normal file
View File

@ -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)

View File

@ -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",
]

View File

@ -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)

View File

@ -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]

View File

@ -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

View File

@ -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]

View File

@ -0,0 +1,2 @@
"""Analyze services package (Alpha Terminal)."""

View File

@ -0,0 +1,2 @@
"""Analyzer implementations."""

View File

@ -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)]

View File

@ -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)]

View File

@ -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])]

View File

@ -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])]

View File

@ -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),
]

View File

@ -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]: ...

View File

@ -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: 23 syllables, 412 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).",
)

View File

@ -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(),
]

View File

@ -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,
)

View File

@ -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

View File

@ -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)

View File

@ -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
@ -174,6 +175,45 @@ class DomainHealthChecker:
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:
"""
Perform comprehensive health check on a domain.
@ -300,9 +340,14 @@ class DomainHealthChecker:
"""
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
@ -356,6 +418,11 @@ class DomainHealthChecker:
"""
result = SSLCheckResult()
allowed, reason = await self._ssrf_guard(domain)
if not allowed:
result.error = reason
return result
loop = asyncio.get_event_loop()
try:

View File

@ -0,0 +1,2 @@
"""HUNT services package."""

View File

@ -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]))

View File

@ -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

View File

@ -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]

View File

@ -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}')
@ -168,6 +173,10 @@ if ! $BACKEND_ONLY; then
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 &
sleep 2

View File

@ -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<string | null>(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 (
<div className="min-h-screen bg-[#020202]">
<Sidebar />
<main className="lg:ml-72">
<section className="px-4 lg:px-10 pt-8 pb-5 border-b border-white/[0.08]">
<div className="flex items-end justify-between gap-6 flex-wrap">
<div>
<p className="text-[10px] font-mono text-white/40 uppercase tracking-[0.25em]">PHASE 4</p>
<h1 className="text-3xl font-bold text-white tracking-tight">CFO</h1>
<p className="text-white/40 text-sm font-mono mt-2 max-w-2xl">
Renewal runway, burn rate, and drop advice. No fluff just numbers.
</p>
</div>
<button
onClick={refresh}
disabled={loading}
className={clsx(
'p-2 border border-white/10 text-white/40 hover:text-white hover:bg-white/5 transition-colors',
loading && 'opacity-50'
)}
title="Refresh"
>
<RefreshCw className={clsx('w-4 h-4', loading && 'animate-spin')} />
</button>
</div>
</section>
<section className="px-4 lg:px-10 py-6 pb-24 space-y-3">
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
</div>
) : error ? (
<div className="border border-white/[0.08] bg-[#020202] p-4 text-[12px] font-mono text-red-300">{error}</div>
) : !data ? (
<div className="border border-white/[0.08] bg-[#020202] p-6 text-white/40 font-mono text-sm">No data.</div>
) : (
<>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div className="border border-white/[0.08] bg-[#020202]">
<div className="px-4 py-3 border-b border-white/[0.08]">
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">Upcoming costs</div>
<div className="text-sm font-bold text-white">Next 30 days</div>
</div>
<div className="p-4">
<div className="text-2xl font-bold text-white font-mono">${Math.round(data.upcoming_30d_total_usd)}</div>
<div className="text-[10px] font-mono text-white/30 mt-1">{data.upcoming_30d_rows.length} renewals due</div>
<div className="mt-3 space-y-1">
{data.upcoming_30d_rows.slice(0, 8).map((r) => (
<div key={r.domain_id} className="flex items-center justify-between text-[11px] font-mono text-white/60">
<span className="truncate">{r.domain}</span>
<span className="text-white/40">
{r.renewal_date ? r.renewal_date.slice(0, 10) : '—'} · ${Math.round(r.renewal_cost_usd || 0)}
</span>
</div>
))}
{data.upcoming_30d_rows.length === 0 ? (
<div className="text-[12px] font-mono text-white/30">No renewals in the next 30 days.</div>
) : null}
</div>
</div>
</div>
<BurnRateTimeline monthly={data.monthly} />
</div>
<KillList rows={data.kill_list} onChanged={refresh} />
<div className="border border-white/[0.08] bg-[#020202] p-4">
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">What to do next</div>
<div className="mt-2 text-[12px] font-mono text-white/50 space-y-1">
<div>
- If a renewal cost is missing, fill it in on the domain in <span className="text-white/70">Portfolio Edit</span>.
</div>
<div>
- Set to Drop is a local flag you still need to disable auto-renew at your registrar.
</div>
<div>
- Want to cover costs? Activate Yield only for <span className="text-white/70">DNSverified</span> domains.
</div>
</div>
</div>
</>
)}
</section>
</main>
{toast && (
<Toast message={toast.message} type={toast.type} isVisible={toast.isVisible} onClose={hideToast} />
)}
</div>
)
}

View File

@ -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<HuntTab>('sniper')
useEffect(() => {
checkAuth()
}, [checkAuth])
return (
<div className="min-h-screen bg-[#020202]">
<Sidebar />
<main className="lg:ml-72">
{/* Header */}
<section className="px-4 lg:px-10 pt-8 pb-5 border-b border-white/[0.08]">
<div className="flex items-end justify-between gap-6 flex-wrap">
<div>
<p className="text-[10px] font-mono text-white/40 uppercase tracking-[0.25em]">PHASE 1</p>
<h1 className="text-3xl font-bold text-white tracking-tight">HUNT</h1>
<p className="text-white/40 text-sm font-mono mt-2 max-w-2xl">
Find Analyze Decide. Strategy-first discovery for domainers.
</p>
</div>
<HuntStrategyChips tab={tab} onChange={setTab} />
</div>
</section>
{/* Content */}
<section className="px-4 lg:px-10 py-6 pb-24">
{tab === 'sniper' ? <SniperTab showToast={showToast} /> : null}
{tab === 'trends' ? <TrendSurferTab showToast={showToast} /> : null}
{tab === 'forge' ? <BrandableForgeTab showToast={showToast} /> : null}
</section>
</main>
{toast && (
<Toast
message={toast.message}
type={toast.type}
isVisible={toast.isVisible}
onClose={hideToast}
/>
)}
</div>
)
}

View File

@ -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 <AnalyzePanelProvider>{children}</AnalyzePanelProvider>
}

View File

@ -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<MarketItem[]>([])
const [loading, setLoading] = useState(true)
@ -762,7 +764,13 @@ export default function MarketPage() {
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-bold text-white font-mono truncate">{item.domain}</div>
<button
onClick={() => openAnalyze(item.domain)}
className="text-sm font-bold text-white font-mono truncate text-left"
title="Analyze"
>
{item.domain}
</button>
<div className="flex items-center gap-2 text-[10px] font-mono text-white/30">
<span className="uppercase">{item.source}</span>
<span className="text-white/10">|</span>
@ -813,6 +821,14 @@ export default function MarketPage() {
{isTracked ? 'Tracked' : 'Track'}
</button>
<button
onClick={() => openAnalyze(item.domain)}
className="w-10 py-2 text-[10px] font-bold uppercase tracking-wider border border-white/[0.08] text-white/50 flex items-center justify-center transition-all hover:text-white hover:bg-white/5"
title="Analyze"
>
<Shield className="w-3.5 h-3.5" />
</button>
<a
href={item.url}
target={isPounce ? "_self" : "_blank"}
@ -844,7 +860,13 @@ export default function MarketPage() {
)}
</div>
<div className="min-w-0">
<div className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors">{item.domain}</div>
<button
onClick={() => openAnalyze(item.domain)}
className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left"
title="Analyze"
>
{item.domain}
</button>
<div className="flex items-center gap-2 text-[10px] font-mono text-white/30">
<span className="uppercase">{item.source}</span>
{isPounce && item.verified && (
@ -924,6 +946,14 @@ export default function MarketPage() {
)}
</button>
<button
onClick={() => openAnalyze(item.domain)}
className="w-7 h-7 flex items-center justify-center border transition-colors text-white/30 border-white/10 hover:text-accent hover:bg-accent/10 hover:border-accent/20"
title="Analyze"
>
<Shield className="w-3.5 h-3.5" />
</button>
<a
href={item.url}
target={isPounce ? "_self" : "_blank"}

View File

@ -3,6 +3,7 @@
import { useEffect, useState, useMemo, useCallback } from 'react'
import { useStore } from '@/lib/store'
import { api, PortfolioDomain, PortfolioSummary, DomainHealthReport, HealthStatus } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store'
import { Sidebar } from '@/components/Sidebar'
import { Toast, useToast } from '@/components/Toast'
import {
@ -19,6 +20,7 @@ import {
Menu,
Settings,
ShieldCheck,
Shield,
LogOut,
Crown,
Tag,
@ -624,6 +626,7 @@ function VerifyModal({
export default function PortfolioPage() {
const { subscription, user, logout, checkAuth } = useStore()
const { toast, showToast, hideToast } = useToast()
const openAnalyze = useAnalyzePanelStore((s) => s.open)
const [domains, setDomains] = useState<PortfolioDomain[]>([])
const [summary, setSummary] = useState<PortfolioSummary | null>(null)
@ -646,7 +649,7 @@ export default function PortfolioPage() {
const [checkingHealth, setCheckingHealth] = useState<Set<string>>(new Set())
// External status (Yield, Listed)
const [yieldDomains, setYieldDomains] = useState<Set<string>>(new Set())
const [yieldByDomain, setYieldByDomain] = useState<Record<string, { id: number; status: string; dns_verified: boolean }>>({})
const [listedDomains, setListedDomains] = useState<Set<string>>(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<string, { id: number; status: string; dns_verified: boolean }> = {}
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() {
</span>
)
}
if (yieldDomains.has(domain.domain.toLowerCase())) {
const y = yieldByDomain[domain.domain.toLowerCase()]
if (y) {
const isActive = y.status === 'active'
badges.push(
<span key="yield" title="Earning via Yield" className="flex items-center gap-0.5 px-1.5 py-0.5 text-[9px] font-mono uppercase bg-amber-400/10 text-amber-400 border border-amber-400/20">
<span
key="yield"
title={`Yield: ${y.status}`}
className={clsx(
"flex items-center gap-0.5 px-1.5 py-0.5 text-[9px] font-mono uppercase border",
isActive ? "bg-accent/10 text-accent border-accent/20" : "bg-amber-400/10 text-amber-400 border-amber-400/20"
)}
>
<Coins className="w-3 h-3" />
</span>
)
@ -895,6 +912,48 @@ export default function PortfolioPage() {
return badges.length > 0 ? <div className="flex items-center gap-1">{badges}</div> : 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 (
<Link
href={`/terminal/yield`}
className="inline-flex items-center justify-center w-full px-2 py-1 text-[10px] font-mono uppercase border border-accent/20 bg-accent/10 text-accent hover:opacity-90"
title="Manage Yield"
>
⚡ Active
</Link>
)
}
if (y) {
return (
<Link
href={`/terminal/yield`}
className="inline-flex items-center justify-center w-full px-2 py-1 text-[10px] font-mono uppercase border border-amber-400/20 bg-amber-400/10 text-amber-300 hover:opacity-90"
title="Continue Yield setup"
>
{y.status}
</Link>
)
}
return (
<Link
href={canActivate ? `/terminal/yield?activate=${encodeURIComponent(domain.domain)}` : `/terminal/portfolio`}
className={clsx(
"inline-flex items-center justify-center w-full px-2 py-1 text-[10px] font-mono uppercase border",
canActivate
? "border-white/10 bg-white/[0.02] text-white/50 hover:text-white hover:bg-white/[0.05]"
: "border-white/10 bg-white/[0.01] text-white/20 cursor-not-allowed"
)}
title={canActivate ? "Activate Yield" : "Verify DNS first to activate Yield"}
onClick={(e) => { if (!canActivate) e.preventDefault() }}
>
💤 Idle
</Link>
)
}
return (
<div className="min-h-screen bg-[#020202]">
<div className="hidden lg:block"><Sidebar /></div>
@ -1029,7 +1088,7 @@ export default function PortfolioPage() {
) : (
<div className="space-y-px">
{/* Desktop Table Header */}
<div className="hidden lg:grid grid-cols-[1.5fr_1fr_100px_100px_100px_100px_60px_140px] gap-3 px-4 py-2.5 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08] bg-white/[0.02]">
<div className="hidden lg:grid grid-cols-[1.5fr_1fr_100px_100px_100px_100px_90px_60px_140px] gap-3 px-4 py-2.5 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08] bg-white/[0.02]">
<button onClick={() => handleSort('domain')} className="flex items-center gap-1 hover:text-white/60 text-left">
Domain {sortField === 'domain' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
</button>
@ -1046,6 +1105,7 @@ export default function PortfolioPage() {
<button onClick={() => handleSort('roi')} className="flex items-center gap-1 justify-end hover:text-white/60">
ROI {sortField === 'roi' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
</button>
<div className="text-center">Yield</div>
<div className="text-center">Health</div>
<div className="text-right">Actions</div>
</div>
@ -1067,7 +1127,7 @@ export default function PortfolioPage() {
)}
>
{/* DESKTOP ROW */}
<div className="hidden lg:grid grid-cols-[1.5fr_1fr_100px_100px_100px_100px_60px_140px] gap-3 px-4 py-3 items-center">
<div className="hidden lg:grid grid-cols-[1.5fr_1fr_100px_100px_100px_100px_90px_60px_140px] gap-3 px-4 py-3 items-center">
{/* Domain */}
<div className="flex items-center gap-3 min-w-0">
<div className={clsx(
@ -1144,6 +1204,11 @@ export default function PortfolioPage() {
</div>
</div>
{/* Yield */}
<div className="flex justify-center">
{renderYieldCell(domain)}
</div>
{/* Health */}
<div className="flex justify-center">
{renderHealth(domain)}
@ -1151,6 +1216,13 @@ export default function PortfolioPage() {
{/* Actions */}
<div className="flex items-center justify-end gap-1">
<button
onClick={() => openAnalyze(domain.domain)}
title="Analyze"
className="p-2 text-white/30 hover:text-accent transition-colors"
>
<Shield className="w-4 h-4" />
</button>
<button
onClick={() => setEditingDomain(domain)}
title="Edit domain details"
@ -1258,6 +1330,30 @@ export default function PortfolioPage() {
{/* Actions */}
<div className="flex items-center gap-2 pt-2">
{renderHealth(domain)}
<button
onClick={() => openAnalyze(domain.domain)}
className="flex items-center gap-1 px-2 py-1.5 text-[9px] font-mono uppercase border border-white/10 text-white/60 bg-white/5 hover:text-accent hover:border-accent/20 hover:bg-accent/10"
>
<Shield className="w-3 h-3" />
Analyze
</button>
<Link
href={
domain.is_dns_verified && !domain.is_sold
? `/terminal/yield?activate=${encodeURIComponent(domain.domain)}`
: `/terminal/portfolio`
}
onClick={(e) => { 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"
)}
>
<Coins className="w-3 h-3" />
Yield
</Link>
{!domain.is_dns_verified && !domain.is_sold && (
<button
onClick={() => setVerifyingDomain(domain)}

View File

@ -3,6 +3,7 @@
import { useEffect, useState, useMemo, useCallback } from 'react'
import { useStore } from '@/lib/store'
import { api, DomainHealthReport, HealthStatus } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store'
import { Sidebar } from '@/components/Sidebar'
import { Toast, useToast } from '@/components/Toast'
import {
@ -72,6 +73,7 @@ const healthConfig: Record<HealthStatus, { label: string; color: string; bg: str
export default function WatchlistPage() {
const { domains, addDomain, deleteDomain, refreshDomain, updateDomain, subscription, user, logout, checkAuth } = useStore()
const { toast, showToast, hideToast } = useToast()
const openAnalyze = useAnalyzePanelStore((s) => s.open)
const [newDomain, setNewDomain] = useState('')
const [adding, setAdding] = useState(false)
@ -477,7 +479,13 @@ export default function WatchlistPage() {
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-bold text-white font-mono truncate">{domain.name}</div>
<button
onClick={() => openAnalyze(domain.name)}
className="text-sm font-bold text-white font-mono truncate text-left"
title="Analyze"
>
{domain.name}
</button>
<div className="text-[10px] font-mono text-white/30">
{domain.registrar || 'Unknown registrar'}
</div>
@ -549,6 +557,14 @@ export default function WatchlistPage() {
<RefreshCw className={clsx("w-4 h-4", refreshingId === domain.id && "animate-spin")} />
</button>
<button
onClick={() => openAnalyze(domain.name)}
className="px-4 py-2 border border-white/[0.08] text-white/40 hover:text-white hover:bg-white/5"
title="Analyze"
>
<Shield className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(domain.id, domain.name)}
disabled={deletingId === domain.id}
@ -579,7 +595,13 @@ export default function WatchlistPage() {
)}
</div>
<div className="min-w-0">
<div className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors">{domain.name}</div>
<button
onClick={() => openAnalyze(domain.name)}
className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left"
title="Analyze"
>
{domain.name}
</button>
<div className="text-[10px] font-mono text-white/30">
{domain.registrar || 'Unknown'}
</div>
@ -663,6 +685,13 @@ export default function WatchlistPage() {
>
<RefreshCw className={clsx("w-3.5 h-3.5", refreshingId === domain.id && "animate-spin")} />
</button>
<button
onClick={() => openAnalyze(domain.name)}
className="w-7 h-7 flex items-center justify-center text-white/20 hover:text-accent border border-white/10 hover:bg-accent/10 hover:border-accent/20 transition-all"
title="Analyze"
>
<Shield className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDelete(domain.id, domain.name)}
disabled={deletingId === domain.id}

View File

@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useSearchParams } from 'next/navigation'
import {
TrendingUp, DollarSign, Zap, Plus, CheckCircle2, Clock, AlertCircle,
MousePointer, Target, Wallet, RefreshCw, ChevronRight, Copy, Check,
@ -38,7 +39,17 @@ function StatusBadge({ status }: { status: string }) {
// ACTIVATE MODAL - Only verified portfolio domains
// ============================================================================
function ActivateModal({ isOpen, onClose, onSuccess }: { isOpen: boolean; onClose: () => void; onSuccess: () => void }) {
function ActivateModal({
isOpen,
onClose,
onSuccess,
prefillDomain,
}: {
isOpen: boolean
onClose: () => void
onSuccess: () => void
prefillDomain?: string | null
}) {
const [selectedDomain, setSelectedDomain] = useState('')
const [verifiedDomains, setVerifiedDomains] = useState<{ id: number; domain: string }[]>([])
const [loadingDomains, setLoadingDomains] = useState(true)
@ -69,6 +80,12 @@ function ActivateModal({ isOpen, onClose, onSuccess }: { isOpen: boolean; onClos
useEffect(() => {
if (!isOpen) return
if (prefillDomain) {
setSelectedDomain(prefillDomain)
setStep(1)
setActivation(null)
setDnsResult(null)
}
const fetchVerifiedDomains = async () => {
setLoadingDomains(true)
try {
@ -298,9 +315,11 @@ function ActivateModal({ isOpen, onClose, onSuccess }: { isOpen: boolean; onClos
export default function YieldPage() {
const { subscription, user, logout, checkAuth } = useStore()
const searchParams = useSearchParams()
const [loading, setLoading] = useState(true)
const [dashboard, setDashboard] = useState<any>(null)
const [showActivateModal, setShowActivateModal] = useState(false)
const [prefillDomain, setPrefillDomain] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
@ -330,6 +349,14 @@ export default function YieldPage() {
useEffect(() => { fetchDashboard() }, [fetchDashboard])
useEffect(() => {
const activate = searchParams.get('activate')
if (activate) {
setPrefillDomain(activate)
setShowActivateModal(true)
}
}, [searchParams])
const stats = dashboard?.stats
const mobileNavItems = [
@ -587,7 +614,12 @@ export default function YieldPage() {
)}
</main>
<ActivateModal isOpen={showActivateModal} onClose={() => setShowActivateModal(false)} onSuccess={fetchDashboard} />
<ActivateModal
isOpen={showActivateModal}
prefillDomain={prefillDomain}
onClose={() => { setShowActivateModal(false); setPrefillDomain(null) }}
onSuccess={fetchDashboard}
/>
</div>
)
}

View File

@ -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',

View File

@ -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<string | null>(null)
const [data, setData] = useState<AnalyzeResponse | null>(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 (
<div className="fixed inset-0 z-[200]">
<div className="absolute inset-0 bg-black/80" onClick={close} />
<div className="absolute right-0 top-0 bottom-0 w-full sm:w-[520px] bg-[#0A0A0A] border-l border-white/[0.08] flex flex-col">
{/* Header */}
<div className="px-4 py-3 border-b border-white/[0.08] flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-accent" />
<div className="text-sm font-bold text-white font-mono truncate">ANALYZE</div>
{data?.cached ? (
<span className="text-[9px] font-mono px-1.5 py-0.5 border border-white/10 text-white/40">CACHED</span>
) : null}
</div>
<div className="mt-1 flex items-center gap-2">
<div className="text-[12px] text-white/70 font-mono truncate">{headerDomain}</div>
<button
onClick={async () => {
const ok = await copyToClipboard(headerDomain)
setCopied(ok)
window.setTimeout(() => setCopied(false), 900)
}}
className="p-1 border border-white/10 text-white/50 hover:text-white transition-colors"
title="Copy domain"
>
<Copy className="w-3.5 h-3.5" />
</button>
{copied ? <span className="text-[10px] font-mono text-accent">Copied</span> : null}
</div>
{computedAt ? <div className="text-[10px] text-white/30 font-mono mt-1">Computed: {computedAt}</div> : null}
</div>
<div className="flex items-center gap-2">
<a
href={`https://${encodeURIComponent(headerDomain)}`}
target="_blank"
rel="noopener noreferrer"
className="p-2 border border-white/10 text-white/50 hover:text-white transition-colors"
title="Open in browser"
>
<ExternalLink className="w-4 h-4" />
</a>
<button
onClick={refresh}
disabled={loading}
className="p-2 border border-white/10 text-white/50 hover:text-white transition-colors disabled:opacity-50"
title="Refresh"
>
<RefreshCw className={clsx('w-4 h-4', loading && 'animate-spin')} />
</button>
<button onClick={close} className="p-2 border border-white/10 text-white/60 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Controls */}
<div className="px-4 py-3 border-b border-white/[0.08]">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="w-4 h-4 text-white/25 absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={filterText}
onChange={(e) => 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"
/>
</div>
<button
onClick={() => setFastMode(!fastMode)}
className={clsx(
'px-3 py-2 text-[10px] font-bold uppercase tracking-wider border flex items-center gap-1.5 transition-all font-mono',
fastMode ? 'border-accent/30 bg-accent/10 text-accent' : 'border-white/10 text-white/50 hover:text-white'
)}
title="Fast mode skips slower HTTP/SSL checks"
>
<Zap className="w-3.5 h-3.5" />
Fast
</button>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{(['authority', 'market', 'risk', 'value'] as const).map((key) => {
const on = sectionVisibility[key] !== false
return (
<button
key={key}
onClick={() => setSectionVisibility({ ...sectionVisibility, [key]: !on })}
className={clsx(
'px-2 py-1 text-[10px] font-mono border transition-colors',
on ? 'border-white/10 text-white/50 hover:text-white' : 'border-white/10 text-white/25 bg-white/[0.02]'
)}
>
{key}
</button>
)
})}
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="p-6 text-white/40 font-mono text-sm">Loading</div>
) : error ? (
<div className="p-6">
<div className="text-sm font-mono text-red-300 mb-2">Analyze failed</div>
<div className="text-[12px] text-white/40 font-mono break-words">{error}</div>
</div>
) : !data ? (
<div className="p-6 text-white/40 font-mono text-sm">No data.</div>
) : (
<div className="p-4 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{visibleSections.map((section) => (
<div key={section.key} className="border border-white/[0.08] bg-[#020202]">
<div className="px-3 py-2 border-b border-white/[0.08] flex items-center justify-between">
<div className="text-[10px] font-bold uppercase tracking-wider text-white/60">{section.title}</div>
<div className="text-[10px] font-mono text-white/25">{section.key}</div>
</div>
<div className="divide-y divide-white/[0.06]">
{section.items.map((it) => (
<div key={it.key} className="px-3 py-2 flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="text-[11px] font-mono text-white/70">{it.label}</div>
<div className="text-[12px] font-mono text-white/40 mt-1 break-words">
{isMatrix(it) ? (
<div className="grid grid-cols-2 gap-1">
{(it.value as any[]).slice(0, 14).map((row: any) => (
<div
key={String(row.domain)}
className="flex items-center justify-between border border-white/10 bg-white/[0.02] px-2 py-1"
>
<span className="text-white/50">{String(row.domain)}</span>
<span
className={clsx(
'text-[10px] font-bold',
row.status === 'available'
? 'text-accent'
: row.status === 'taken'
? 'text-white/40'
: 'text-amber-300'
)}
>
{String(row.status).toUpperCase()}
</span>
</div>
))}
</div>
) : (
formatValue(it.value)
)}
</div>
{it.details && Object.keys(it.details).length ? (
<details className="mt-2">
<summary className="text-[10px] font-mono text-white/30 cursor-pointer hover:text-white/50">
Details
</summary>
<pre className="mt-2 text-[10px] leading-relaxed font-mono text-white/30 overflow-x-auto bg-black/30 border border-white/10 p-2">
{JSON.stringify(it.details, null, 2)}
</pre>
</details>
) : null}
</div>
<div className="shrink-0 flex flex-col items-end gap-1">
<span className={clsx('text-[9px] font-bold uppercase tracking-wider px-2 py-1 border', statusPill(it.status))}>
{it.status}
</span>
<span className="text-[9px] font-mono text-white/25">{it.source}</span>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="px-4 py-3 border-t border-white/[0.08] bg-white/[0.02]">
<div className="text-[10px] font-mono text-white/30">
Open-data-first. Some signals (trademarks/search volume/Wayback) require explicit data sources; well only add them when we can do it without external APIs.
</div>
</div>
</div>
</div>
)
}

View File

@ -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<string, boolean>
}
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}
<AnalyzePanel />
</>
)
}

View File

@ -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<string, unknown>
}
export type AnalyzeSection = {
key: string
title: string
items: AnalyzeItem[]
}
export type AnalyzeResponse = {
domain: string
computed_at: string
cached: boolean
sections: AnalyzeSection[]
}

View File

@ -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 (
<div className="border border-white/[0.08] bg-[#020202]">
<div className="px-4 py-3 border-b border-white/[0.08]">
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">Burn Rate Monitor</div>
<div className="text-sm font-bold text-white">Renewals timeline (12 months)</div>
</div>
<div className="p-4">
<div className="space-y-2">
{monthly.map((m) => {
const pct = Math.round(((m.total_cost_usd || 0) / max) * 100)
const isHot = m.total_cost_usd >= max * 0.7
return (
<div key={m.month} className="grid grid-cols-[72px_1fr_90px] gap-3 items-center">
<div className="text-[10px] font-mono text-white/40">{m.month}</div>
<div className="h-2 border border-white/10 bg-white/[0.02]">
<div
className={clsx('h-full', isHot ? 'bg-orange-500/80' : 'bg-white/20')}
style={{ width: `${pct}%` }}
/>
</div>
<div className="text-[10px] font-mono text-white/50 text-right">
${Math.round(m.total_cost_usd)} · {m.domains}
</div>
</div>
)
})}
</div>
<div className="mt-3 text-[10px] font-mono text-white/30">
Costs are based on your portfolio renewal_cost when present; otherwise cheapest renewal from your tracked TLD price DB.
</div>
</div>
</div>
)
}

View File

@ -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<void>
}) {
const openAnalyze = useAnalyzePanelStore((s) => s.open)
const [busyId, setBusyId] = useState<number | null>(null)
const setToDrop = useCallback(
async (id: number) => {
setBusyId(id)
try {
await api.cfoSetToDrop(id)
await onChanged()
} finally {
setBusyId(null)
}
},
[onChanged]
)
return (
<div className="border border-white/[0.08] bg-[#020202]">
<div className="px-4 py-3 border-b border-white/[0.08]">
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">Kill List</div>
<div className="text-sm font-bold text-white">Drop advisor (30d)</div>
<div className="text-[10px] font-mono text-white/30 mt-1">
Criteria: renewal due in &lt; 30 days + no tracked yield signals in last 60 days.
</div>
</div>
{rows.length === 0 ? (
<div className="p-6 text-white/40 font-mono text-sm">No drop candidates right now.</div>
) : (
<div className="divide-y divide-white/[0.06]">
{rows.map((r) => {
const busy = busyId === r.domain_id
const cost = r.renewal_cost_usd != null ? `$${Math.round(r.renewal_cost_usd)}` : '—'
return (
<div key={r.domain_id} className="px-4 py-3 hover:bg-white/[0.02] transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<button
onClick={() => openAnalyze(r.domain)}
className="text-white font-mono font-bold truncate text-left hover:text-accent transition-colors"
title="Analyze"
>
{r.domain}
</button>
<div className="text-[10px] font-mono text-white/30 mt-1 break-words">{r.reason}</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-[10px] font-mono text-white/40">
<span className="border border-white/10 bg-white/[0.02] px-2 py-1">Renewal: {r.renewal_date ? r.renewal_date.slice(0, 10) : '—'}</span>
<span className="border border-white/10 bg-white/[0.02] px-2 py-1">Cost: {cost} ({r.cost_source})</span>
<span className="border border-white/10 bg-white/[0.02] px-2 py-1">
Yield 60d: ${Math.round(r.yield_net_60d)} · {r.yield_clicks_60d} clicks
</span>
{!r.auto_renew ? (
<span className="border border-orange-500/30 bg-orange-500/10 text-orange-300 px-2 py-1">SET TO DROP</span>
) : null}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => openAnalyze(r.domain)}
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-accent hover:border-accent/20 hover:bg-accent/10"
title="Analyze"
>
<Shield className="w-4 h-4" />
</button>
<button
onClick={() => setToDrop(r.domain_id)}
disabled={busy || !r.auto_renew}
className={clsx(
'h-8 px-3 border text-[10px] font-bold uppercase tracking-wider font-mono transition-colors flex items-center gap-2',
!r.auto_renew
? 'border-orange-500/20 text-orange-300/50 bg-orange-500/5 cursor-not-allowed'
: 'border-white/10 text-white/60 hover:text-white hover:bg-white/5'
)}
title="Set to drop (local reminder)"
>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Set to Drop
</button>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
)
}

View File

@ -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<Array<{ domain: string; status: string }>>([])
const [error, setError] = useState<string | null>(null)
const [tracking, setTracking] = useState<string | null>(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 (
<div className="border border-white/[0.08] bg-[#020202]">
<div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between gap-3">
<div>
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">Brandable Forge</div>
<div className="text-sm font-bold text-white">Generate &amp; verify</div>
</div>
<button
onClick={run}
disabled={loading}
className={clsx(
'px-3 py-2 border text-[10px] font-bold uppercase tracking-wider font-mono transition-colors',
loading ? 'border-white/10 text-white/20' : 'border-accent/30 bg-accent/10 text-accent hover:bg-accent/15'
)}
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
</button>
</div>
<div className="p-4 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-4">
<div className="space-y-3">
<div>
<label className="block text-[10px] font-mono text-white/40 mb-1">Pattern</label>
<select
value={pattern}
onChange={(e) => setPattern(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"
>
{PATTERNS.map((p) => (
<option key={p.key} value={p.key}>
{p.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-[10px] font-mono text-white/40 mb-1">TLDs (comma)</label>
<input
value={tldsRaw}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-[10px] font-mono text-white/40 mb-1">Results</label>
<input
type="number"
value={limit}
onChange={(e) => 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}
/>
</div>
<div className="text-[10px] font-mono text-white/30">
No external APIs. Availability is checked via DNS/RDAP (quick mode). We only return domains that are actually available.
</div>
</div>
<div>
{error ? <div className="text-[12px] font-mono text-red-300 mb-2">{error}</div> : null}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{items.map((i) => (
<div key={i.domain} className="border border-white/10 bg-white/[0.02] px-3 py-2 flex items-center justify-between gap-2">
<button
onClick={() => openAnalyze(i.domain)}
className="text-[12px] font-mono text-white/70 hover:text-accent truncate text-left"
title="Analyze"
>
{i.domain}
</button>
<div className="flex items-center gap-2 shrink-0">
<a
href={`https://www.namecheap.com/domains/registration/results/?domain=${encodeURIComponent(i.domain)}`}
target="_blank"
rel="noopener noreferrer"
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5"
title="Register (Namecheap)"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
<button
onClick={() => track(i.domain)}
disabled={tracking === i.domain}
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 disabled:opacity-50"
title="Track in Watchlist"
>
{tracking === i.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
</button>
<button
onClick={() => openAnalyze(i.domain)}
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/10"
title="Analyze"
>
<Shield className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
{!loading && items.length === 0 ? <div className="text-[12px] font-mono text-white/30">No results yet.</div> : null}
</div>
</div>
</div>
</div>
)
}

View File

@ -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 (
<div className="flex flex-wrap gap-2">
{chips.map((c) => {
const active = tab === c.key
return (
<button
key={c.key}
onClick={() => onChange(c.key)}
className={clsx(
'px-3 py-2 border text-left transition-colors',
active ? 'border-accent/30 bg-accent/10 text-accent' : 'border-white/10 text-white/50 hover:text-white hover:bg-white/5'
)}
>
<div className="text-[10px] font-bold uppercase tracking-wider font-mono">{c.label}</div>
<div className="text-[10px] font-mono mt-0.5 text-white/30">{c.hint}</div>
</button>
)
})}
</div>
)
}

View File

@ -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<string | null>(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<string | null>(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 (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
</div>
)
}
return (
<div className="border border-white/[0.08] bg-[#020202]">
<div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between">
<div>
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">Closeout Sniper</div>
<div className="text-sm font-bold text-white">Bargain Bin</div>
<div className="text-[10px] font-mono text-white/30 mt-1">Only real scraped data: price &lt; $10, age 5y, backlinks &gt; 0.</div>
</div>
<button
onClick={handleRefresh}
disabled={refreshing}
className="p-2 border border-white/10 text-white/40 hover:text-white hover:bg-white/5 transition-colors disabled:opacity-50"
title="Refresh"
>
<RefreshCw className={clsx('w-4 h-4', refreshing && 'animate-spin')} />
</button>
</div>
{error ? (
<div className="p-4 text-[12px] font-mono text-red-300">{error}</div>
) : rows.length === 0 ? (
<div className="p-8 text-center text-white/40 font-mono text-sm">No sniper items right now.</div>
) : (
<div className="divide-y divide-white/[0.06]">
<div className="hidden lg:grid grid-cols-[1fr_70px_90px_80px_90px_120px] gap-3 px-4 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider">
<div>Domain</div>
<div className="text-center">Age</div>
<div className="text-center">Backlinks</div>
<div className="text-right">Price</div>
<div className="text-center">Time</div>
<div className="text-right">Action</div>
</div>
{rows.map((r) => (
<div key={r.domain} className="px-4 py-3 hover:bg-white/[0.02] transition-colors">
<div className="lg:grid lg:grid-cols-[1fr_70px_90px_80px_90px_120px] lg:gap-3 flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<button
onClick={() => openAnalyze(r.domain)}
className="text-white font-mono font-bold truncate text-left hover:text-accent transition-colors"
title="Analyze"
>
{r.domain}
</button>
<div className="flex items-center gap-2 lg:hidden">
<button
onClick={() => openAnalyze(r.domain)}
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-accent hover:border-accent/20 hover:bg-accent/10"
title="Analyze"
>
<Shield className="w-4 h-4" />
</button>
<a
href={r.auction_url}
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-white hover:bg-white/5"
title="Open auction"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
<div className="lg:text-center text-[11px] font-mono text-white/60">
{r.age_years !== null ? `${r.age_years}y` : '—'}
</div>
<div className="lg:text-center text-[11px] font-mono text-white/60">
{r.backlinks !== null ? String(r.backlinks) : '—'}
</div>
<div className="lg:text-right text-[12px] font-mono font-bold text-white">
${Math.round(r.current_bid)}
</div>
<div className="lg:text-center text-[11px] font-mono text-white/50">{calcTimeRemaining(r.end_time)}</div>
<div className="hidden lg:flex items-center justify-end gap-2">
<button
onClick={() => openAnalyze(r.domain)}
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-accent hover:border-accent/20 hover:bg-accent/10"
title="Analyze"
>
<Shield className="w-4 h-4" />
</button>
<button
onClick={() => track(r.domain)}
disabled={tracking === r.domain}
className={clsx(
'w-8 h-8 flex items-center justify-center border transition-colors',
tracking === r.domain
? 'border-white/10 text-white/20'
: 'border-white/10 text-white/40 hover:text-white hover:bg-white/5'
)}
title="Track in Watchlist"
>
{tracking === r.domain ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
</button>
<a
href={r.auction_url}
target="_blank"
rel="noopener noreferrer"
className="h-8 px-3 flex items-center gap-2 border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.06] transition-colors font-mono text-[10px] uppercase tracking-wider"
>
Buy
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@ -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<string | null>(null)
const [geo, setGeo] = useState('US')
const [trends, setTrends] = useState<Array<{ title: string; approx_traffic?: string | null; link?: string | null }>>([])
const [selected, setSelected] = useState<string>('')
const [keywordInput, setKeywordInput] = useState('')
const [availability, setAvailability] = useState<Array<{ domain: string; status: string; is_available: boolean | null }>>([])
const [checking, setChecking] = useState(false)
const [brand, setBrand] = useState('')
const [typos, setTypos] = useState<Array<{ domain: string; status: string }>>([])
const [typoLoading, setTypoLoading] = useState(false)
const [tracking, setTracking] = useState<string | null>(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 (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
</div>
)
}
return (
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-3">
<div className="border border-white/[0.08] bg-[#020202]">
<div className="px-4 py-3 border-b border-white/[0.08]">
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">Top Google Trends (24h)</div>
<div className="mt-2 flex items-center gap-2">
<input
value={geo}
onChange={(e) => 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/...)"
/>
<button
onClick={() => loadTrends()}
className="flex-1 px-3 py-1 border border-white/10 text-white/50 hover:text-white hover:bg-white/5 transition-colors text-xs font-mono"
>
Refresh
</button>
</div>
{error ? <div className="mt-2 text-[12px] font-mono text-red-300">{error}</div> : null}
</div>
<div className="max-h-[520px] overflow-y-auto divide-y divide-white/[0.06]">
{trends.slice(0, 30).map((t) => {
const active = selected === t.title
return (
<button
key={t.title}
onClick={() => {
setSelected(t.title)
setKeywordInput('')
}}
className={clsx(
'w-full px-4 py-2 text-left hover:bg-white/[0.02] transition-colors',
active && 'bg-white/[0.02]'
)}
>
<div className={clsx('text-[12px] font-mono truncate', active ? 'text-accent' : 'text-white/70')}>{t.title}</div>
<div className="text-[10px] font-mono text-white/30 flex items-center justify-between mt-0.5">
<span>{t.approx_traffic || '—'}</span>
{t.link ? (
<a
href={t.link}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-white/25 hover:text-white/50"
title="Open source"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
) : null}
</div>
</button>
)
})}
</div>
</div>
<div className="space-y-3">
{/* Keyword availability */}
<div className="border border-white/[0.08] bg-[#020202]">
<div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between gap-3">
<div>
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">Keyword Availability</div>
<div className="text-sm font-bold text-white">Find available domains</div>
</div>
<button
onClick={runCheck}
disabled={!keyword || checking}
className={clsx(
'px-3 py-2 border text-[10px] font-bold uppercase tracking-wider font-mono transition-colors',
!keyword || checking
? 'border-white/10 text-white/20'
: 'border-accent/30 bg-accent/10 text-accent hover:bg-accent/15'
)}
>
{checking ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Check'}
</button>
</div>
<div className="p-4">
<div className="relative">
<Search className="w-4 h-4 text-white/25 absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={keyword}
onChange={(e) => 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"
/>
</div>
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-2">
{availability.map((a) => (
<div key={a.domain} className="border border-white/10 bg-white/[0.02] px-3 py-2 flex items-center justify-between">
<button
onClick={() => openAnalyze(a.domain)}
className="text-[12px] font-mono text-white/70 hover:text-accent truncate text-left"
title="Analyze"
>
{a.domain}
</button>
<div className="flex items-center gap-2">
<span className={clsx('text-[10px] font-mono', a.status === 'available' ? 'text-accent' : 'text-white/30')}>
{a.status.toUpperCase()}
</span>
<button
onClick={() => track(a.domain)}
disabled={tracking === a.domain}
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 disabled:opacity-50"
title="Track in Watchlist"
>
{tracking === a.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
</button>
<button
onClick={() => openAnalyze(a.domain)}
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/10"
title="Analyze"
>
<Shield className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
{availability.length === 0 ? <div className="text-[12px] font-mono text-white/30">No results yet.</div> : null}
</div>
</div>
</div>
{/* Typo check */}
<div className="border border-white/[0.08] bg-[#020202]">
<div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between gap-3">
<div>
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">Typo Check</div>
<div className="text-sm font-bold text-white">Find typo domains of big brands</div>
</div>
<button
onClick={runTypos}
disabled={!brand.trim() || typoLoading}
className={clsx(
'px-3 py-2 border text-[10px] font-bold uppercase tracking-wider font-mono transition-colors',
!brand.trim() || typoLoading
? 'border-white/10 text-white/20'
: 'border-white/10 text-white/60 hover:text-white hover:bg-white/5'
)}
>
{typoLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
</button>
</div>
<div className="p-4">
<input
value={brand}
onChange={(e) => 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"
/>
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-2">
{typos.map((t) => (
<div key={t.domain} className="border border-white/10 bg-white/[0.02] px-3 py-2 flex items-center justify-between">
<button
onClick={() => openAnalyze(t.domain)}
className="text-[12px] font-mono text-white/70 hover:text-accent truncate text-left"
title="Analyze"
>
{t.domain}
</button>
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-accent">{t.status.toUpperCase()}</span>
<button
onClick={() => track(t.domain)}
disabled={tracking === t.domain}
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 disabled:opacity-50"
title="Track in Watchlist"
>
{tracking === t.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
</button>
<button
onClick={() => openAnalyze(t.domain)}
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/10"
title="Analyze"
>
<Shield className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
{typos.length === 0 ? <div className="text-[12px] font-mono text-white/30">No typo results yet.</div> : null}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,39 @@
import { create } from 'zustand'
export type AnalyzeSectionVisibility = Record<string, boolean>
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<AnalyzePanelState>((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'

View File

@ -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<string, unknown>
}>
}>
}>(`/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