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