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_routing import router as yield_routing_router
from app.api.yield_payout_admin import router as yield_payout_admin_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.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() 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(portfolio_router, prefix="/portfolio", tags=["Portfolio"])
api_router.include_router(auctions_router, prefix="/auctions", tags=["Smart Pounce - Auctions"]) 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(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 # Marketplace (For Sale) - from analysis_3.md
api_router.include_router(listings_router, prefix="/listings", tags=["Marketplace - For Sale"]) 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.yield_domain import YieldDomain, YieldTransaction, YieldPayout, AffiliatePartner
from app.models.telemetry import TelemetryEvent from app.models.telemetry import TelemetryEvent
from app.models.ops_alert import OpsAlertEvent from app.models.ops_alert import OpsAlertEvent
from app.models.domain_analysis_cache import DomainAnalysisCache
__all__ = [ __all__ = [
"User", "User",
@ -48,4 +49,6 @@ __all__ = [
# New: Telemetry (events) # New: Telemetry (events)
"TelemetryEvent", "TelemetryEvent",
"OpsAlertEvent", "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 ssl
import socket import socket
import re import re
import ipaddress
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
@ -174,6 +175,45 @@ class DomainHealthChecker:
self._dns_resolver.timeout = 3 self._dns_resolver.timeout = 3
self._dns_resolver.lifetime = 5 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: async def check_domain(self, domain: str) -> DomainHealthReport:
""" """
Perform comprehensive health check on a domain. Perform comprehensive health check on a domain.
@ -300,9 +340,14 @@ class DomainHealthChecker:
""" """
result = HTTPCheckResult() result = HTTPCheckResult()
allowed, reason = await self._ssrf_guard(domain)
if not allowed:
result.error = reason
return result
async with httpx.AsyncClient( async with httpx.AsyncClient(
timeout=10.0, timeout=10.0,
follow_redirects=True, follow_redirects=False,
headers={ headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
} }
@ -311,7 +356,24 @@ class DomainHealthChecker:
url = f"{scheme}://{domain}" url = f"{scheme}://{domain}"
try: try:
start = asyncio.get_event_loop().time() 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() end = asyncio.get_event_loop().time()
result.status_code = response.status_code result.status_code = response.status_code
@ -320,7 +382,7 @@ class DomainHealthChecker:
result.response_time_ms = (end - start) * 1000 result.response_time_ms = (end - start) * 1000
# Check for redirects # Check for redirects
if response.history: if str(response.url) != url:
result.redirect_url = str(response.url) result.redirect_url = str(response.url)
# Check for parking keywords in content # Check for parking keywords in content
@ -356,6 +418,11 @@ class DomainHealthChecker:
""" """
result = SSLCheckResult() result = SSLCheckResult()
allowed, reason = await self._ssrf_guard(domain)
if not allowed:
result.error = reason
return result
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
try: 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=$? BUILD_EXIT=$?
if [ $BUILD_EXIT -eq 0 ]; then 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 # Gracefully restart Next.js
NEXT_PID=$(pgrep -af 'next-serv|next start|node \\.next/standalone/server\\.js' | awk 'NR==1{print $1; exit}') 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 sleep 1
fi 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 # Start new instance
nohup npm run start > frontend.log 2>&1 & nohup npm run start > frontend.log 2>&1 &
sleep 2 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 { useEffect, useState } from 'react'
import { useRouter, usePathname } from 'next/navigation' import { useRouter, usePathname } from 'next/navigation'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
import { AnalyzePanelProvider } from '@/components/analyze/AnalyzePanelProvider'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
export default function TerminalLayout({ 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 { useEffect, useState, useMemo, useCallback } from 'react'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store'
import { Sidebar } from '@/components/Sidebar' import { Sidebar } from '@/components/Sidebar'
import { Toast, useToast } from '@/components/Toast' import { Toast, useToast } from '@/components/Toast'
import { import {
@ -121,6 +122,7 @@ function isSpam(domain: string): boolean {
export default function MarketPage() { export default function MarketPage() {
const { subscription, user, logout, checkAuth } = useStore() const { subscription, user, logout, checkAuth } = useStore()
const { toast, showToast, hideToast } = useToast() const { toast, showToast, hideToast } = useToast()
const openAnalyze = useAnalyzePanelStore((s) => s.open)
const [items, setItems] = useState<MarketItem[]>([]) const [items, setItems] = useState<MarketItem[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -762,7 +764,13 @@ export default function MarketPage() {
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <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"> <div className="flex items-center gap-2 text-[10px] font-mono text-white/30">
<span className="uppercase">{item.source}</span> <span className="uppercase">{item.source}</span>
<span className="text-white/10">|</span> <span className="text-white/10">|</span>
@ -813,6 +821,14 @@ export default function MarketPage() {
{isTracked ? 'Tracked' : 'Track'} {isTracked ? 'Tracked' : 'Track'}
</button> </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 <a
href={item.url} href={item.url}
target={isPounce ? "_self" : "_blank"} target={isPounce ? "_self" : "_blank"}
@ -844,7 +860,13 @@ export default function MarketPage() {
)} )}
</div> </div>
<div className="min-w-0"> <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"> <div className="flex items-center gap-2 text-[10px] font-mono text-white/30">
<span className="uppercase">{item.source}</span> <span className="uppercase">{item.source}</span>
{isPounce && item.verified && ( {isPounce && item.verified && (
@ -924,6 +946,14 @@ export default function MarketPage() {
)} )}
</button> </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 <a
href={item.url} href={item.url}
target={isPounce ? "_self" : "_blank"} target={isPounce ? "_self" : "_blank"}

View File

@ -3,6 +3,7 @@
import { useEffect, useState, useMemo, useCallback } from 'react' import { useEffect, useState, useMemo, useCallback } from 'react'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
import { api, PortfolioDomain, PortfolioSummary, DomainHealthReport, HealthStatus } from '@/lib/api' import { api, PortfolioDomain, PortfolioSummary, DomainHealthReport, HealthStatus } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store'
import { Sidebar } from '@/components/Sidebar' import { Sidebar } from '@/components/Sidebar'
import { Toast, useToast } from '@/components/Toast' import { Toast, useToast } from '@/components/Toast'
import { import {
@ -19,6 +20,7 @@ import {
Menu, Menu,
Settings, Settings,
ShieldCheck, ShieldCheck,
Shield,
LogOut, LogOut,
Crown, Crown,
Tag, Tag,
@ -624,6 +626,7 @@ function VerifyModal({
export default function PortfolioPage() { export default function PortfolioPage() {
const { subscription, user, logout, checkAuth } = useStore() const { subscription, user, logout, checkAuth } = useStore()
const { toast, showToast, hideToast } = useToast() const { toast, showToast, hideToast } = useToast()
const openAnalyze = useAnalyzePanelStore((s) => s.open)
const [domains, setDomains] = useState<PortfolioDomain[]>([]) const [domains, setDomains] = useState<PortfolioDomain[]>([])
const [summary, setSummary] = useState<PortfolioSummary | null>(null) const [summary, setSummary] = useState<PortfolioSummary | null>(null)
@ -646,7 +649,7 @@ export default function PortfolioPage() {
const [checkingHealth, setCheckingHealth] = useState<Set<string>>(new Set()) const [checkingHealth, setCheckingHealth] = useState<Set<string>>(new Set())
// External status (Yield, Listed) // 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()) const [listedDomains, setListedDomains] = useState<Set<string>>(new Set())
// Mobile // Mobile
@ -673,9 +676,14 @@ export default function PortfolioPage() {
// Load yield domains // Load yield domains
try { try {
const yieldData = await api.getYieldDomains() const yieldData = await api.getYieldDomains()
const active = (yieldData.domains || []).filter((d: any) => d.status === 'active') const map: Record<string, { id: number; status: string; dns_verified: boolean }> = {}
setYieldDomains(new Set(active.map((d: any) => String(d.domain).toLowerCase()))) for (const d of yieldData.domains || []) {
} catch { setYieldDomains(new Set()) } 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 // Load listed domains
try { try {
@ -878,9 +886,18 @@ export default function PortfolioPage() {
</span> </span>
) )
} }
if (yieldDomains.has(domain.domain.toLowerCase())) { const y = yieldByDomain[domain.domain.toLowerCase()]
if (y) {
const isActive = y.status === 'active'
badges.push( 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" /> <Coins className="w-3 h-3" />
</span> </span>
) )
@ -895,6 +912,48 @@ export default function PortfolioPage() {
return badges.length > 0 ? <div className="flex items-center gap-1">{badges}</div> : null 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 ( return (
<div className="min-h-screen bg-[#020202]"> <div className="min-h-screen bg-[#020202]">
<div className="hidden lg:block"><Sidebar /></div> <div className="hidden lg:block"><Sidebar /></div>
@ -1029,7 +1088,7 @@ export default function PortfolioPage() {
) : ( ) : (
<div className="space-y-px"> <div className="space-y-px">
{/* Desktop Table Header */} {/* 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"> <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" />)} Domain {sortField === 'domain' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
</button> </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"> <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" />)} ROI {sortField === 'roi' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
</button> </button>
<div className="text-center">Yield</div>
<div className="text-center">Health</div> <div className="text-center">Health</div>
<div className="text-right">Actions</div> <div className="text-right">Actions</div>
</div> </div>
@ -1067,7 +1127,7 @@ export default function PortfolioPage() {
)} )}
> >
{/* DESKTOP ROW */} {/* 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 */} {/* Domain */}
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<div className={clsx( <div className={clsx(
@ -1144,6 +1204,11 @@ export default function PortfolioPage() {
</div> </div>
</div> </div>
{/* Yield */}
<div className="flex justify-center">
{renderYieldCell(domain)}
</div>
{/* Health */} {/* Health */}
<div className="flex justify-center"> <div className="flex justify-center">
{renderHealth(domain)} {renderHealth(domain)}
@ -1151,6 +1216,13 @@ export default function PortfolioPage() {
{/* Actions */} {/* Actions */}
<div className="flex items-center justify-end gap-1"> <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 <button
onClick={() => setEditingDomain(domain)} onClick={() => setEditingDomain(domain)}
title="Edit domain details" title="Edit domain details"
@ -1258,6 +1330,30 @@ export default function PortfolioPage() {
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2 pt-2"> <div className="flex items-center gap-2 pt-2">
{renderHealth(domain)} {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 && ( {!domain.is_dns_verified && !domain.is_sold && (
<button <button
onClick={() => setVerifyingDomain(domain)} onClick={() => setVerifyingDomain(domain)}

View File

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

View File

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

View File

@ -16,6 +16,7 @@ import {
Crown, Crown,
Zap, Zap,
Shield, Shield,
Crosshair,
Menu, Menu,
X, X,
Tag, Tag,
@ -68,6 +69,12 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
// SECTION 1: Discover - Radar first, then external market data // SECTION 1: Discover - Radar first, then external market data
const discoverItems = [ const discoverItems = [
{
href: '/terminal/hunt',
label: 'HUNT',
icon: Crosshair,
badge: null,
},
{ {
href: '/terminal/radar', href: '/terminal/radar',
label: 'RADAR', label: 'RADAR',
@ -108,6 +115,12 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
icon: Briefcase, icon: Briefcase,
badge: null, badge: null,
}, },
{
href: '/terminal/cfo',
label: 'CFO',
icon: Shield,
badge: null,
},
{ {
href: '/terminal/inbox', href: '/terminal/inbox',
label: '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') }>('/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 }) { async updateMe(data: { name?: string }) {
return this.request<{ return this.request<{
id: number id: number