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
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:
@ -23,6 +23,9 @@ from app.api.yield_webhooks import router as yield_webhooks_router
|
||||
from app.api.yield_routing import router as yield_routing_router
|
||||
from app.api.yield_payout_admin import router as yield_payout_admin_router
|
||||
from app.api.telemetry import router as telemetry_router
|
||||
from app.api.analyze import router as analyze_router
|
||||
from app.api.hunt import router as hunt_router
|
||||
from app.api.cfo import router as cfo_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@ -37,6 +40,9 @@ api_router.include_router(price_alerts_router, prefix="/price-alerts", tags=["Pr
|
||||
api_router.include_router(portfolio_router, prefix="/portfolio", tags=["Portfolio"])
|
||||
api_router.include_router(auctions_router, prefix="/auctions", tags=["Smart Pounce - Auctions"])
|
||||
api_router.include_router(dashboard_router, prefix="/dashboard", tags=["Dashboard"])
|
||||
api_router.include_router(analyze_router, prefix="/analyze", tags=["Analyze"])
|
||||
api_router.include_router(hunt_router, prefix="/hunt", tags=["Hunt"])
|
||||
api_router.include_router(cfo_router, prefix="/cfo", tags=["CFO"])
|
||||
|
||||
# Marketplace (For Sale) - from analysis_3.md
|
||||
api_router.include_router(listings_router, prefix="/listings", tags=["Marketplace - For Sale"])
|
||||
|
||||
36
backend/app/api/analyze.py
Normal file
36
backend/app/api/analyze.py
Normal 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
197
backend/app/api/cfo.py
Normal 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
242
backend/app/api/hunt.py
Normal 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)
|
||||
|
||||
@ -15,6 +15,7 @@ from app.models.seo_data import DomainSEOData
|
||||
from app.models.yield_domain import YieldDomain, YieldTransaction, YieldPayout, AffiliatePartner
|
||||
from app.models.telemetry import TelemetryEvent
|
||||
from app.models.ops_alert import OpsAlertEvent
|
||||
from app.models.domain_analysis_cache import DomainAnalysisCache
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@ -48,4 +49,6 @@ __all__ = [
|
||||
# New: Telemetry (events)
|
||||
"TelemetryEvent",
|
||||
"OpsAlertEvent",
|
||||
# New: Analyze cache
|
||||
"DomainAnalysisCache",
|
||||
]
|
||||
|
||||
25
backend/app/models/domain_analysis_cache.py
Normal file
25
backend/app/models/domain_analysis_cache.py
Normal 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)
|
||||
|
||||
35
backend/app/schemas/analyze.py
Normal file
35
backend/app/schemas/analyze.py
Normal 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]
|
||||
|
||||
51
backend/app/schemas/cfo.py
Normal file
51
backend/app/schemas/cfo.py
Normal 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
|
||||
|
||||
93
backend/app/schemas/hunt.py
Normal file
93
backend/app/schemas/hunt.py
Normal 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]
|
||||
|
||||
2
backend/app/services/analyze/__init__.py
Normal file
2
backend/app/services/analyze/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Analyze services package (Alpha Terminal)."""
|
||||
|
||||
2
backend/app/services/analyze/analyzers/__init__.py
Normal file
2
backend/app/services/analyze/analyzers/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Analyzer implementations."""
|
||||
|
||||
102
backend/app/services/analyze/analyzers/basic_risk.py
Normal file
102
backend/app/services/analyze/analyzers/basic_risk.py
Normal 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)]
|
||||
|
||||
56
backend/app/services/analyze/analyzers/domain_facts.py
Normal file
56
backend/app/services/analyze/analyzers/domain_facts.py
Normal 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)]
|
||||
|
||||
30
backend/app/services/analyze/analyzers/radio_test.py
Normal file
30
backend/app/services/analyze/analyzers/radio_test.py
Normal 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])]
|
||||
|
||||
23
backend/app/services/analyze/analyzers/tld_matrix.py
Normal file
23
backend/app/services/analyze/analyzers/tld_matrix.py
Normal 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])]
|
||||
|
||||
73
backend/app/services/analyze/analyzers/tld_pricing.py
Normal file
73
backend/app/services/analyze/analyzers/tld_pricing.py
Normal 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),
|
||||
]
|
||||
|
||||
41
backend/app/services/analyze/base.py
Normal file
41
backend/app/services/analyze/base.py
Normal 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]: ...
|
||||
|
||||
91
backend/app/services/analyze/radio_test.py
Normal file
91
backend/app/services/analyze/radio_test.py
Normal 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: 2–3 syllables, 4–12 chars, no digits
|
||||
if 2 <= syllables <= 3 and 4 <= length <= 12 and not has_digits:
|
||||
return RadioTestResult(
|
||||
sld=sld,
|
||||
syllables=syllables,
|
||||
length=length,
|
||||
has_hyphen=has_hyphen,
|
||||
has_digits=has_digits,
|
||||
status="pass",
|
||||
rationale="Short, pronounceable, low spelling friction.",
|
||||
)
|
||||
|
||||
return RadioTestResult(
|
||||
sld=sld,
|
||||
syllables=syllables,
|
||||
length=length,
|
||||
has_hyphen=has_hyphen,
|
||||
has_digits=has_digits,
|
||||
status="warn",
|
||||
rationale="Usable, but not ideal (syllables/length/digits).",
|
||||
)
|
||||
|
||||
21
backend/app/services/analyze/registry.py
Normal file
21
backend/app/services/analyze/registry.py
Normal 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(),
|
||||
]
|
||||
|
||||
93
backend/app/services/analyze/renewal_cost.py
Normal file
93
backend/app/services/analyze/renewal_cost.py
Normal 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,
|
||||
)
|
||||
|
||||
128
backend/app/services/analyze/service.py
Normal file
128
backend/app/services/analyze/service.py
Normal 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
|
||||
|
||||
66
backend/app/services/analyze/tld_matrix.py
Normal file
66
backend/app/services/analyze/tld_matrix.py
Normal 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)
|
||||
|
||||
@ -16,6 +16,7 @@ import logging
|
||||
import ssl
|
||||
import socket
|
||||
import re
|
||||
import ipaddress
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict, Any
|
||||
@ -174,6 +175,45 @@ class DomainHealthChecker:
|
||||
self._dns_resolver.timeout = 3
|
||||
self._dns_resolver.lifetime = 5
|
||||
|
||||
def _is_public_ip(self, ip: str) -> bool:
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
return bool(getattr(addr, "is_global", False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _ssrf_guard(self, domain: str) -> tuple[bool, str | None]:
|
||||
"""
|
||||
SSRF hardening for HTTP/SSL probes.
|
||||
|
||||
We block domains that resolve exclusively to non-public IPs.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _resolve_ips() -> list[str]:
|
||||
ips: list[str] = []
|
||||
try:
|
||||
a = self._dns_resolver.resolve(domain, "A")
|
||||
ips.extend([str(r.address) for r in a])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
aaaa = self._dns_resolver.resolve(domain, "AAAA")
|
||||
ips.extend([str(r.address) for r in aaaa])
|
||||
except Exception:
|
||||
pass
|
||||
# de-dup
|
||||
return list(dict.fromkeys([i.strip() for i in ips if i]))
|
||||
|
||||
ips = await loop.run_in_executor(None, _resolve_ips)
|
||||
if not ips:
|
||||
return True, None # nothing to block; will fail naturally if unreachable
|
||||
|
||||
if any(self._is_public_ip(ip) for ip in ips):
|
||||
return True, None
|
||||
|
||||
return False, f"blocked_ssrf: resolved_non_public_ips={ips}"
|
||||
|
||||
async def check_domain(self, domain: str) -> DomainHealthReport:
|
||||
"""
|
||||
Perform comprehensive health check on a domain.
|
||||
@ -300,9 +340,14 @@ class DomainHealthChecker:
|
||||
"""
|
||||
result = HTTPCheckResult()
|
||||
|
||||
allowed, reason = await self._ssrf_guard(domain)
|
||||
if not allowed:
|
||||
result.error = reason
|
||||
return result
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=10.0,
|
||||
follow_redirects=True,
|
||||
follow_redirects=False,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
}
|
||||
@ -311,7 +356,24 @@ class DomainHealthChecker:
|
||||
url = f"{scheme}://{domain}"
|
||||
try:
|
||||
start = asyncio.get_event_loop().time()
|
||||
response = await client.get(url)
|
||||
|
||||
# Follow redirects manually with host/IP guard
|
||||
current_url = url
|
||||
for _ in range(0, 5):
|
||||
response = await client.get(current_url)
|
||||
if response.status_code in (301, 302, 303, 307, 308) and response.headers.get("location"):
|
||||
next_url = str(httpx.URL(current_url).join(response.headers["location"]))
|
||||
next_host = httpx.URL(next_url).host
|
||||
if not next_host:
|
||||
break
|
||||
ok, why = await self._ssrf_guard(next_host)
|
||||
if not ok:
|
||||
result.error = why
|
||||
return result
|
||||
current_url = next_url
|
||||
continue
|
||||
break
|
||||
|
||||
end = asyncio.get_event_loop().time()
|
||||
|
||||
result.status_code = response.status_code
|
||||
@ -320,7 +382,7 @@ class DomainHealthChecker:
|
||||
result.response_time_ms = (end - start) * 1000
|
||||
|
||||
# Check for redirects
|
||||
if response.history:
|
||||
if str(response.url) != url:
|
||||
result.redirect_url = str(response.url)
|
||||
|
||||
# Check for parking keywords in content
|
||||
@ -356,6 +418,11 @@ class DomainHealthChecker:
|
||||
"""
|
||||
result = SSLCheckResult()
|
||||
|
||||
allowed, reason = await self._ssrf_guard(domain)
|
||||
if not allowed:
|
||||
result.error = reason
|
||||
return result
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
try:
|
||||
|
||||
2
backend/app/services/hunt/__init__.py
Normal file
2
backend/app/services/hunt/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""HUNT services package."""
|
||||
|
||||
76
backend/app/services/hunt/brandables.py
Normal file
76
backend/app/services/hunt/brandables.py
Normal 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]))
|
||||
|
||||
53
backend/app/services/hunt/trends.py
Normal file
53
backend/app/services/hunt/trends.py
Normal 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
|
||||
|
||||
89
backend/app/services/hunt/typos.py
Normal file
89
backend/app/services/hunt/typos.py
Normal 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]
|
||||
|
||||
@ -159,6 +159,11 @@ if ! $BACKEND_ONLY; then
|
||||
BUILD_EXIT=$?
|
||||
|
||||
if [ $BUILD_EXIT -eq 0 ]; then
|
||||
# Next.js standalone output requires public + static inside standalone folder
|
||||
mkdir -p .next/standalone/.next
|
||||
ln -sfn ../../static .next/standalone/.next/static
|
||||
ln -sfn ../../public .next/standalone/public
|
||||
|
||||
# Gracefully restart Next.js
|
||||
NEXT_PID=$(pgrep -af 'next-serv|next start|node \\.next/standalone/server\\.js' | awk 'NR==1{print $1; exit}')
|
||||
|
||||
@ -168,6 +173,10 @@ if ! $BACKEND_ONLY; then
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
# Ensure port is free (avoid EADDRINUSE)
|
||||
lsof -ti:3000 2>/dev/null | xargs -r kill -9 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# Start new instance
|
||||
nohup npm run start > frontend.log 2>&1 &
|
||||
sleep 2
|
||||
|
||||
176
frontend/src/app/terminal/cfo/page.tsx
Normal file
176
frontend/src/app/terminal/cfo/page.tsx
Normal 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">DNS‑verified</span> domains.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{toast && (
|
||||
<Toast message={toast.message} type={toast.type} isVisible={toast.isVisible} onClose={hideToast} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
59
frontend/src/app/terminal/hunt/page.tsx
Normal file
59
frontend/src/app/terminal/hunt/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { AnalyzePanelProvider } from '@/components/analyze/AnalyzePanelProvider'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export default function TerminalLayout({
|
||||
@ -58,6 +59,6 @@ export default function TerminalLayout({
|
||||
)
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
return <AnalyzePanelProvider>{children}</AnalyzePanelProvider>
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { Toast, useToast } from '@/components/Toast'
|
||||
import {
|
||||
@ -121,6 +122,7 @@ function isSpam(domain: string): boolean {
|
||||
export default function MarketPage() {
|
||||
const { subscription, user, logout, checkAuth } = useStore()
|
||||
const { toast, showToast, hideToast } = useToast()
|
||||
const openAnalyze = useAnalyzePanelStore((s) => s.open)
|
||||
|
||||
const [items, setItems] = useState<MarketItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@ -762,7 +764,13 @@ export default function MarketPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-bold text-white font-mono truncate">{item.domain}</div>
|
||||
<button
|
||||
onClick={() => openAnalyze(item.domain)}
|
||||
className="text-sm font-bold text-white font-mono truncate text-left"
|
||||
title="Analyze"
|
||||
>
|
||||
{item.domain}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-[10px] font-mono text-white/30">
|
||||
<span className="uppercase">{item.source}</span>
|
||||
<span className="text-white/10">|</span>
|
||||
@ -813,6 +821,14 @@ export default function MarketPage() {
|
||||
{isTracked ? 'Tracked' : 'Track'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => openAnalyze(item.domain)}
|
||||
className="w-10 py-2 text-[10px] font-bold uppercase tracking-wider border border-white/[0.08] text-white/50 flex items-center justify-center transition-all hover:text-white hover:bg-white/5"
|
||||
title="Analyze"
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={item.url}
|
||||
target={isPounce ? "_self" : "_blank"}
|
||||
@ -844,7 +860,13 @@ export default function MarketPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors">{item.domain}</div>
|
||||
<button
|
||||
onClick={() => openAnalyze(item.domain)}
|
||||
className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left"
|
||||
title="Analyze"
|
||||
>
|
||||
{item.domain}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-[10px] font-mono text-white/30">
|
||||
<span className="uppercase">{item.source}</span>
|
||||
{isPounce && item.verified && (
|
||||
@ -924,6 +946,14 @@ export default function MarketPage() {
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => openAnalyze(item.domain)}
|
||||
className="w-7 h-7 flex items-center justify-center border transition-colors text-white/30 border-white/10 hover:text-accent hover:bg-accent/10 hover:border-accent/20"
|
||||
title="Analyze"
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={item.url}
|
||||
target={isPounce ? "_self" : "_blank"}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api, PortfolioDomain, PortfolioSummary, DomainHealthReport, HealthStatus } from '@/lib/api'
|
||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { Toast, useToast } from '@/components/Toast'
|
||||
import {
|
||||
@ -19,6 +20,7 @@ import {
|
||||
Menu,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Shield,
|
||||
LogOut,
|
||||
Crown,
|
||||
Tag,
|
||||
@ -624,6 +626,7 @@ function VerifyModal({
|
||||
export default function PortfolioPage() {
|
||||
const { subscription, user, logout, checkAuth } = useStore()
|
||||
const { toast, showToast, hideToast } = useToast()
|
||||
const openAnalyze = useAnalyzePanelStore((s) => s.open)
|
||||
|
||||
const [domains, setDomains] = useState<PortfolioDomain[]>([])
|
||||
const [summary, setSummary] = useState<PortfolioSummary | null>(null)
|
||||
@ -646,7 +649,7 @@ export default function PortfolioPage() {
|
||||
const [checkingHealth, setCheckingHealth] = useState<Set<string>>(new Set())
|
||||
|
||||
// External status (Yield, Listed)
|
||||
const [yieldDomains, setYieldDomains] = useState<Set<string>>(new Set())
|
||||
const [yieldByDomain, setYieldByDomain] = useState<Record<string, { id: number; status: string; dns_verified: boolean }>>({})
|
||||
const [listedDomains, setListedDomains] = useState<Set<string>>(new Set())
|
||||
|
||||
// Mobile
|
||||
@ -673,9 +676,14 @@ export default function PortfolioPage() {
|
||||
// Load yield domains
|
||||
try {
|
||||
const yieldData = await api.getYieldDomains()
|
||||
const active = (yieldData.domains || []).filter((d: any) => d.status === 'active')
|
||||
setYieldDomains(new Set(active.map((d: any) => String(d.domain).toLowerCase())))
|
||||
} catch { setYieldDomains(new Set()) }
|
||||
const map: Record<string, { id: number; status: string; dns_verified: boolean }> = {}
|
||||
for (const d of yieldData.domains || []) {
|
||||
const key = String((d as any).domain || '').toLowerCase()
|
||||
if (!key) continue
|
||||
map[key] = { id: Number((d as any).id), status: String((d as any).status || 'pending'), dns_verified: Boolean((d as any).dns_verified) }
|
||||
}
|
||||
setYieldByDomain(map)
|
||||
} catch { setYieldByDomain({}) }
|
||||
|
||||
// Load listed domains
|
||||
try {
|
||||
@ -878,9 +886,18 @@ export default function PortfolioPage() {
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (yieldDomains.has(domain.domain.toLowerCase())) {
|
||||
const y = yieldByDomain[domain.domain.toLowerCase()]
|
||||
if (y) {
|
||||
const isActive = y.status === 'active'
|
||||
badges.push(
|
||||
<span key="yield" title="Earning via Yield" className="flex items-center gap-0.5 px-1.5 py-0.5 text-[9px] font-mono uppercase bg-amber-400/10 text-amber-400 border border-amber-400/20">
|
||||
<span
|
||||
key="yield"
|
||||
title={`Yield: ${y.status}`}
|
||||
className={clsx(
|
||||
"flex items-center gap-0.5 px-1.5 py-0.5 text-[9px] font-mono uppercase border",
|
||||
isActive ? "bg-accent/10 text-accent border-accent/20" : "bg-amber-400/10 text-amber-400 border-amber-400/20"
|
||||
)}
|
||||
>
|
||||
<Coins className="w-3 h-3" />
|
||||
</span>
|
||||
)
|
||||
@ -895,6 +912,48 @@ export default function PortfolioPage() {
|
||||
return badges.length > 0 ? <div className="flex items-center gap-1">{badges}</div> : null
|
||||
}
|
||||
|
||||
const renderYieldCell = (domain: PortfolioDomain) => {
|
||||
const y = yieldByDomain[domain.domain.toLowerCase()]
|
||||
const canActivate = domain.is_dns_verified && !domain.is_sold
|
||||
if (y?.status === 'active') {
|
||||
return (
|
||||
<Link
|
||||
href={`/terminal/yield`}
|
||||
className="inline-flex items-center justify-center w-full px-2 py-1 text-[10px] font-mono uppercase border border-accent/20 bg-accent/10 text-accent hover:opacity-90"
|
||||
title="Manage Yield"
|
||||
>
|
||||
⚡ Active
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
if (y) {
|
||||
return (
|
||||
<Link
|
||||
href={`/terminal/yield`}
|
||||
className="inline-flex items-center justify-center w-full px-2 py-1 text-[10px] font-mono uppercase border border-amber-400/20 bg-amber-400/10 text-amber-300 hover:opacity-90"
|
||||
title="Continue Yield setup"
|
||||
>
|
||||
{y.status}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href={canActivate ? `/terminal/yield?activate=${encodeURIComponent(domain.domain)}` : `/terminal/portfolio`}
|
||||
className={clsx(
|
||||
"inline-flex items-center justify-center w-full px-2 py-1 text-[10px] font-mono uppercase border",
|
||||
canActivate
|
||||
? "border-white/10 bg-white/[0.02] text-white/50 hover:text-white hover:bg-white/[0.05]"
|
||||
: "border-white/10 bg-white/[0.01] text-white/20 cursor-not-allowed"
|
||||
)}
|
||||
title={canActivate ? "Activate Yield" : "Verify DNS first to activate Yield"}
|
||||
onClick={(e) => { if (!canActivate) e.preventDefault() }}
|
||||
>
|
||||
💤 Idle
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020202]">
|
||||
<div className="hidden lg:block"><Sidebar /></div>
|
||||
@ -1029,7 +1088,7 @@ export default function PortfolioPage() {
|
||||
) : (
|
||||
<div className="space-y-px">
|
||||
{/* Desktop Table Header */}
|
||||
<div className="hidden lg:grid grid-cols-[1.5fr_1fr_100px_100px_100px_100px_60px_140px] gap-3 px-4 py-2.5 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08] bg-white/[0.02]">
|
||||
<div className="hidden lg:grid grid-cols-[1.5fr_1fr_100px_100px_100px_100px_90px_60px_140px] gap-3 px-4 py-2.5 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08] bg-white/[0.02]">
|
||||
<button onClick={() => handleSort('domain')} className="flex items-center gap-1 hover:text-white/60 text-left">
|
||||
Domain {sortField === 'domain' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||
</button>
|
||||
@ -1046,6 +1105,7 @@ export default function PortfolioPage() {
|
||||
<button onClick={() => handleSort('roi')} className="flex items-center gap-1 justify-end hover:text-white/60">
|
||||
ROI {sortField === 'roi' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||
</button>
|
||||
<div className="text-center">Yield</div>
|
||||
<div className="text-center">Health</div>
|
||||
<div className="text-right">Actions</div>
|
||||
</div>
|
||||
@ -1067,7 +1127,7 @@ export default function PortfolioPage() {
|
||||
)}
|
||||
>
|
||||
{/* DESKTOP ROW */}
|
||||
<div className="hidden lg:grid grid-cols-[1.5fr_1fr_100px_100px_100px_100px_60px_140px] gap-3 px-4 py-3 items-center">
|
||||
<div className="hidden lg:grid grid-cols-[1.5fr_1fr_100px_100px_100px_100px_90px_60px_140px] gap-3 px-4 py-3 items-center">
|
||||
{/* Domain */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={clsx(
|
||||
@ -1144,6 +1204,11 @@ export default function PortfolioPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Yield */}
|
||||
<div className="flex justify-center">
|
||||
{renderYieldCell(domain)}
|
||||
</div>
|
||||
|
||||
{/* Health */}
|
||||
<div className="flex justify-center">
|
||||
{renderHealth(domain)}
|
||||
@ -1151,6 +1216,13 @@ export default function PortfolioPage() {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => openAnalyze(domain.domain)}
|
||||
title="Analyze"
|
||||
className="p-2 text-white/30 hover:text-accent transition-colors"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingDomain(domain)}
|
||||
title="Edit domain details"
|
||||
@ -1258,6 +1330,30 @@ export default function PortfolioPage() {
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{renderHealth(domain)}
|
||||
<button
|
||||
onClick={() => openAnalyze(domain.domain)}
|
||||
className="flex items-center gap-1 px-2 py-1.5 text-[9px] font-mono uppercase border border-white/10 text-white/60 bg-white/5 hover:text-accent hover:border-accent/20 hover:bg-accent/10"
|
||||
>
|
||||
<Shield className="w-3 h-3" />
|
||||
Analyze
|
||||
</button>
|
||||
<Link
|
||||
href={
|
||||
domain.is_dns_verified && !domain.is_sold
|
||||
? `/terminal/yield?activate=${encodeURIComponent(domain.domain)}`
|
||||
: `/terminal/portfolio`
|
||||
}
|
||||
onClick={(e) => { if (!domain.is_dns_verified || domain.is_sold) e.preventDefault() }}
|
||||
className={clsx(
|
||||
"flex items-center gap-1 px-2 py-1.5 text-[9px] font-mono uppercase border",
|
||||
domain.is_dns_verified && !domain.is_sold
|
||||
? "border-white/10 text-white/60 bg-white/5 hover:text-white hover:bg-white/10"
|
||||
: "border-white/10 text-white/20 bg-white/[0.02] cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<Coins className="w-3 h-3" />
|
||||
Yield
|
||||
</Link>
|
||||
{!domain.is_dns_verified && !domain.is_sold && (
|
||||
<button
|
||||
onClick={() => setVerifyingDomain(domain)}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api, DomainHealthReport, HealthStatus } from '@/lib/api'
|
||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { Toast, useToast } from '@/components/Toast'
|
||||
import {
|
||||
@ -72,6 +73,7 @@ const healthConfig: Record<HealthStatus, { label: string; color: string; bg: str
|
||||
export default function WatchlistPage() {
|
||||
const { domains, addDomain, deleteDomain, refreshDomain, updateDomain, subscription, user, logout, checkAuth } = useStore()
|
||||
const { toast, showToast, hideToast } = useToast()
|
||||
const openAnalyze = useAnalyzePanelStore((s) => s.open)
|
||||
|
||||
const [newDomain, setNewDomain] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
@ -477,7 +479,13 @@ export default function WatchlistPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-bold text-white font-mono truncate">{domain.name}</div>
|
||||
<button
|
||||
onClick={() => openAnalyze(domain.name)}
|
||||
className="text-sm font-bold text-white font-mono truncate text-left"
|
||||
title="Analyze"
|
||||
>
|
||||
{domain.name}
|
||||
</button>
|
||||
<div className="text-[10px] font-mono text-white/30">
|
||||
{domain.registrar || 'Unknown registrar'}
|
||||
</div>
|
||||
@ -549,6 +557,14 @@ export default function WatchlistPage() {
|
||||
<RefreshCw className={clsx("w-4 h-4", refreshingId === domain.id && "animate-spin")} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => openAnalyze(domain.name)}
|
||||
className="px-4 py-2 border border-white/[0.08] text-white/40 hover:text-white hover:bg-white/5"
|
||||
title="Analyze"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(domain.id, domain.name)}
|
||||
disabled={deletingId === domain.id}
|
||||
@ -579,7 +595,13 @@ export default function WatchlistPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors">{domain.name}</div>
|
||||
<button
|
||||
onClick={() => openAnalyze(domain.name)}
|
||||
className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left"
|
||||
title="Analyze"
|
||||
>
|
||||
{domain.name}
|
||||
</button>
|
||||
<div className="text-[10px] font-mono text-white/30">
|
||||
{domain.registrar || 'Unknown'}
|
||||
</div>
|
||||
@ -663,6 +685,13 @@ export default function WatchlistPage() {
|
||||
>
|
||||
<RefreshCw className={clsx("w-3.5 h-3.5", refreshingId === domain.id && "animate-spin")} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openAnalyze(domain.name)}
|
||||
className="w-7 h-7 flex items-center justify-center text-white/20 hover:text-accent border border-white/10 hover:bg-accent/10 hover:border-accent/20 transition-all"
|
||||
title="Analyze"
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(domain.id, domain.name)}
|
||||
disabled={deletingId === domain.id}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
TrendingUp, DollarSign, Zap, Plus, CheckCircle2, Clock, AlertCircle,
|
||||
MousePointer, Target, Wallet, RefreshCw, ChevronRight, Copy, Check,
|
||||
@ -38,7 +39,17 @@ function StatusBadge({ status }: { status: string }) {
|
||||
// ACTIVATE MODAL - Only verified portfolio domains
|
||||
// ============================================================================
|
||||
|
||||
function ActivateModal({ isOpen, onClose, onSuccess }: { isOpen: boolean; onClose: () => void; onSuccess: () => void }) {
|
||||
function ActivateModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
prefillDomain,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
prefillDomain?: string | null
|
||||
}) {
|
||||
const [selectedDomain, setSelectedDomain] = useState('')
|
||||
const [verifiedDomains, setVerifiedDomains] = useState<{ id: number; domain: string }[]>([])
|
||||
const [loadingDomains, setLoadingDomains] = useState(true)
|
||||
@ -69,6 +80,12 @@ function ActivateModal({ isOpen, onClose, onSuccess }: { isOpen: boolean; onClos
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (prefillDomain) {
|
||||
setSelectedDomain(prefillDomain)
|
||||
setStep(1)
|
||||
setActivation(null)
|
||||
setDnsResult(null)
|
||||
}
|
||||
const fetchVerifiedDomains = async () => {
|
||||
setLoadingDomains(true)
|
||||
try {
|
||||
@ -298,9 +315,11 @@ function ActivateModal({ isOpen, onClose, onSuccess }: { isOpen: boolean; onClos
|
||||
|
||||
export default function YieldPage() {
|
||||
const { subscription, user, logout, checkAuth } = useStore()
|
||||
const searchParams = useSearchParams()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dashboard, setDashboard] = useState<any>(null)
|
||||
const [showActivateModal, setShowActivateModal] = useState(false)
|
||||
const [prefillDomain, setPrefillDomain] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
@ -330,6 +349,14 @@ export default function YieldPage() {
|
||||
|
||||
useEffect(() => { fetchDashboard() }, [fetchDashboard])
|
||||
|
||||
useEffect(() => {
|
||||
const activate = searchParams.get('activate')
|
||||
if (activate) {
|
||||
setPrefillDomain(activate)
|
||||
setShowActivateModal(true)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const stats = dashboard?.stats
|
||||
|
||||
const mobileNavItems = [
|
||||
@ -587,7 +614,12 @@ export default function YieldPage() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
<ActivateModal isOpen={showActivateModal} onClose={() => setShowActivateModal(false)} onSuccess={fetchDashboard} />
|
||||
<ActivateModal
|
||||
isOpen={showActivateModal}
|
||||
prefillDomain={prefillDomain}
|
||||
onClose={() => { setShowActivateModal(false); setPrefillDomain(null) }}
|
||||
onSuccess={fetchDashboard}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
Crown,
|
||||
Zap,
|
||||
Shield,
|
||||
Crosshair,
|
||||
Menu,
|
||||
X,
|
||||
Tag,
|
||||
@ -68,6 +69,12 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
|
||||
// SECTION 1: Discover - Radar first, then external market data
|
||||
const discoverItems = [
|
||||
{
|
||||
href: '/terminal/hunt',
|
||||
label: 'HUNT',
|
||||
icon: Crosshair,
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
href: '/terminal/radar',
|
||||
label: 'RADAR',
|
||||
@ -108,6 +115,12 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
icon: Briefcase,
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
href: '/terminal/cfo',
|
||||
label: 'CFO',
|
||||
icon: Shield,
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
href: '/terminal/inbox',
|
||||
label: 'INBOX',
|
||||
|
||||
321
frontend/src/components/analyze/AnalyzePanel.tsx
Normal file
321
frontend/src/components/analyze/AnalyzePanel.tsx
Normal 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; we’ll only add them when we can do it without external APIs.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
48
frontend/src/components/analyze/AnalyzePanelProvider.tsx
Normal file
48
frontend/src/components/analyze/AnalyzePanelProvider.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
22
frontend/src/components/analyze/types.ts
Normal file
22
frontend/src/components/analyze/types.ts
Normal 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[]
|
||||
}
|
||||
|
||||
45
frontend/src/components/cfo/BurnRateTimeline.tsx
Normal file
45
frontend/src/components/cfo/BurnRateTimeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
116
frontend/src/components/cfo/KillList.tsx
Normal file
116
frontend/src/components/cfo/KillList.tsx
Normal 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 < 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>
|
||||
)
|
||||
}
|
||||
|
||||
175
frontend/src/components/hunt/BrandableForgeTab.tsx
Normal file
175
frontend/src/components/hunt/BrandableForgeTab.tsx
Normal 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 & 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>
|
||||
)
|
||||
}
|
||||
|
||||
41
frontend/src/components/hunt/HuntStrategyChips.tsx
Normal file
41
frontend/src/components/hunt/HuntStrategyChips.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
221
frontend/src/components/hunt/SniperTab.tsx
Normal file
221
frontend/src/components/hunt/SniperTab.tsx
Normal 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 < $10, age ≥ 5y, backlinks > 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>
|
||||
)
|
||||
}
|
||||
|
||||
299
frontend/src/components/hunt/TrendSurferTab.tsx
Normal file
299
frontend/src/components/hunt/TrendSurferTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
39
frontend/src/lib/analyze-store.ts
Normal file
39
frontend/src/lib/analyze-store.ts
Normal 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'
|
||||
|
||||
@ -162,6 +162,125 @@ class ApiClient {
|
||||
}>('/dashboard/summary')
|
||||
}
|
||||
|
||||
// Analyze (Alpha Terminal - Diligence)
|
||||
async analyzeDomain(domain: string, opts?: { fast?: boolean; refresh?: boolean }) {
|
||||
const params = new URLSearchParams()
|
||||
if (opts?.fast) params.set('fast', 'true')
|
||||
if (opts?.refresh) params.set('refresh', 'true')
|
||||
const qs = params.toString()
|
||||
return this.request<{
|
||||
domain: string
|
||||
computed_at: string
|
||||
cached: boolean
|
||||
sections: Array<{
|
||||
key: string
|
||||
title: string
|
||||
items: Array<{
|
||||
key: string
|
||||
label: string
|
||||
value: unknown | null
|
||||
status: string
|
||||
source: string
|
||||
details: Record<string, unknown>
|
||||
}>
|
||||
}>
|
||||
}>(`/analyze/${encodeURIComponent(domain)}${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
// HUNT (Alpha Terminal - Discovery)
|
||||
async getHuntBargainBin(limit: number = 100) {
|
||||
const qs = new URLSearchParams({ limit: String(limit) })
|
||||
return this.request<{
|
||||
items: Array<{
|
||||
domain: string
|
||||
platform: string
|
||||
auction_url: string
|
||||
current_bid: number
|
||||
currency: string
|
||||
end_time: string
|
||||
age_years: number | null
|
||||
backlinks: number | null
|
||||
pounce_score: number | null
|
||||
}>
|
||||
total: number
|
||||
filtered_out_missing_data: number
|
||||
last_updated: string | null
|
||||
}>(`/hunt/bargain-bin?${qs}`)
|
||||
}
|
||||
|
||||
async getHuntTrends(geo: string = 'US') {
|
||||
const qs = new URLSearchParams({ geo })
|
||||
return this.request<{
|
||||
geo: string
|
||||
items: Array<{ title: string; approx_traffic?: string | null; published_at?: string | null; link?: string | null }>
|
||||
fetched_at: string
|
||||
}>(`/hunt/trends?${qs}`)
|
||||
}
|
||||
|
||||
async huntKeywords(payload: { keywords: string[]; tlds?: string[] }) {
|
||||
return this.request<{
|
||||
items: Array<{ keyword: string; domain: string; tld: string; is_available: boolean | null; status: string }>
|
||||
}>(`/hunt/keywords`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
async huntTypos(payload: { brand: string; tlds?: string[]; limit?: number }) {
|
||||
return this.request<{
|
||||
brand: string
|
||||
items: Array<{ domain: string; is_available: boolean | null; status: string }>
|
||||
}>(`/hunt/typos`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
async huntBrandables(payload: { pattern: string; tlds?: string[]; limit?: number; max_checks?: number }) {
|
||||
return this.request<{
|
||||
pattern: string
|
||||
items: Array<{ domain: string; is_available: boolean | null; status: string }>
|
||||
}>(`/hunt/brandables`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
// CFO (Alpha Terminal - Management)
|
||||
async getCfoSummary() {
|
||||
return this.request<{
|
||||
computed_at: string
|
||||
upcoming_30d_total_usd: number
|
||||
upcoming_30d_rows: Array<{
|
||||
domain_id: number
|
||||
domain: string
|
||||
renewal_date: string | null
|
||||
renewal_cost_usd: number | null
|
||||
cost_source: string
|
||||
is_sold: boolean
|
||||
}>
|
||||
monthly: Array<{ month: string; total_cost_usd: number; domains: number }>
|
||||
kill_list: Array<{
|
||||
domain_id: number
|
||||
domain: string
|
||||
renewal_date: string | null
|
||||
renewal_cost_usd: number | null
|
||||
cost_source: string
|
||||
auto_renew: boolean
|
||||
is_dns_verified: boolean
|
||||
yield_net_60d: number
|
||||
yield_clicks_60d: number
|
||||
reason: string
|
||||
}>
|
||||
}>(`/cfo/summary`)
|
||||
}
|
||||
|
||||
async cfoSetToDrop(domainId: number) {
|
||||
return this.request<{ domain_id: number; auto_renew: boolean; updated_at: string }>(`/cfo/domains/${domainId}/set-to-drop`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async updateMe(data: { name?: string }) {
|
||||
return this.request<{
|
||||
id: number
|
||||
|
||||
Reference in New Issue
Block a user