diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 9499986..d366e4f 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -26,6 +26,7 @@ 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 +from app.api.drops import router as drops_router api_router = APIRouter() @@ -43,6 +44,7 @@ api_router.include_router(dashboard_router, prefix="/dashboard", tags=["Dashboar 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"]) +api_router.include_router(drops_router, tags=["Drops - Zone Files"]) # Marketplace (For Sale) - from analysis_3.md api_router.include_router(listings_router, prefix="/listings", tags=["Marketplace - For Sale"]) diff --git a/backend/app/api/drops.py b/backend/app/api/drops.py new file mode 100644 index 0000000..dd9703e --- /dev/null +++ b/backend/app/api/drops.py @@ -0,0 +1,148 @@ +""" +Drops API - Zone File Analysis Endpoints +========================================= +API endpoints for accessing freshly dropped .ch and .li domains. +""" + +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.api.deps import get_current_user +from app.services.zone_file import ( + ZoneFileService, + get_dropped_domains, + get_zone_stats, +) + +router = APIRouter(prefix="/drops", tags=["drops"]) + + +# ============================================================================ +# PUBLIC ENDPOINTS (for stats) +# ============================================================================ + +@router.get("/stats") +async def api_get_zone_stats( + db: AsyncSession = Depends(get_db) +): + """ + Get zone file statistics. + Returns domain counts and last sync times for .ch and .li. + """ + try: + stats = await get_zone_stats(db) + return stats + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================ +# AUTHENTICATED ENDPOINTS +# ============================================================================ + +@router.get("") +async def api_get_drops( + tld: Optional[str] = Query(None, description="Filter by TLD (ch or li)"), + days: int = Query(7, ge=1, le=30, description="Days to look back"), + min_length: Optional[int] = Query(None, ge=1, le=63, description="Minimum domain length"), + max_length: Optional[int] = Query(None, ge=1, le=63, description="Maximum domain length"), + exclude_numeric: bool = Query(False, description="Exclude numeric-only domains"), + exclude_hyphen: bool = Query(False, description="Exclude domains with hyphens"), + keyword: Optional[str] = Query(None, description="Search keyword"), + limit: int = Query(50, ge=1, le=200, description="Results per page"), + offset: int = Query(0, ge=0, description="Offset for pagination"), + db: AsyncSession = Depends(get_db), + current_user = Depends(get_current_user) +): + """ + Get recently dropped domains from .ch and .li zone files. + + Domains are detected by comparing daily zone file snapshots. + Only available for authenticated users. + """ + if tld and tld not in ["ch", "li"]: + raise HTTPException(status_code=400, detail="TLD must be 'ch' or 'li'") + + try: + result = await get_dropped_domains( + db=db, + tld=tld, + days=days, + min_length=min_length, + max_length=max_length, + exclude_numeric=exclude_numeric, + exclude_hyphen=exclude_hyphen, + keyword=keyword, + limit=limit, + offset=offset + ) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/sync/{tld}") +async def api_trigger_sync( + tld: str, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + current_user = Depends(get_current_user) +): + """ + Trigger a manual zone file sync for a specific TLD. + Only available for admin users. + + This is normally run automatically by the scheduler. + """ + # Check if user is admin + if not getattr(current_user, 'is_admin', False): + raise HTTPException(status_code=403, detail="Admin access required") + + if tld not in ["ch", "li"]: + raise HTTPException(status_code=400, detail="TLD must be 'ch' or 'li'") + + # Run sync in background + service = ZoneFileService() + + async def run_sync(): + async for session in get_db(): + try: + await service.run_daily_sync(session, tld) + except Exception as e: + print(f"Zone sync failed for .{tld}: {e}") + break + + background_tasks.add_task(run_sync) + + return {"status": "sync_started", "tld": tld} + + +# ============================================================================ +# HELPER ENDPOINTS +# ============================================================================ + +@router.get("/tlds") +async def api_get_supported_tlds(): + """ + Get list of supported TLDs for zone file analysis. + """ + return { + "tlds": [ + { + "tld": "ch", + "name": "Switzerland", + "flag": "๐Ÿ‡จ๐Ÿ‡ญ", + "registry": "Switch" + }, + { + "tld": "li", + "name": "Liechtenstein", + "flag": "๐Ÿ‡ฑ๐Ÿ‡ฎ", + "registry": "Switch" + } + ] + } diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 081c5eb..6cfc27b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -16,6 +16,7 @@ from app.models.yield_domain import YieldDomain, YieldTransaction, YieldPayout, from app.models.telemetry import TelemetryEvent from app.models.ops_alert import OpsAlertEvent from app.models.domain_analysis_cache import DomainAnalysisCache +from app.models.zone_file import ZoneSnapshot, DroppedDomain __all__ = [ "User", @@ -51,4 +52,7 @@ __all__ = [ "OpsAlertEvent", # New: Analyze cache "DomainAnalysisCache", + # New: Zone file drops + "ZoneSnapshot", + "DroppedDomain", ] diff --git a/backend/app/models/zone_file.py b/backend/app/models/zone_file.py new file mode 100644 index 0000000..65d8793 --- /dev/null +++ b/backend/app/models/zone_file.py @@ -0,0 +1,43 @@ +""" +Zone File Models for .ch and .li domain drops +""" +from datetime import datetime + +from sqlalchemy import Column, Integer, String, DateTime, Boolean, Index + +from app.database import Base + + +class ZoneSnapshot(Base): + """Stores metadata about zone file snapshots (not the full data)""" + __tablename__ = "zone_snapshots" + + id = Column(Integer, primary_key=True) + tld = Column(String(10), nullable=False, index=True) # 'ch' or 'li' + snapshot_date = Column(DateTime, nullable=False, index=True) + domain_count = Column(Integer, nullable=False) + checksum = Column(String(64), nullable=False) # SHA256 of sorted domain list + created_at = Column(DateTime, default=datetime.utcnow) + + __table_args__ = ( + Index('ix_zone_snapshots_tld_date', 'tld', 'snapshot_date'), + ) + + +class DroppedDomain(Base): + """Stores domains that were dropped (found in previous snapshot but not current)""" + __tablename__ = "dropped_domains" + + id = Column(Integer, primary_key=True) + domain = Column(String(255), nullable=False, index=True) + tld = Column(String(10), nullable=False, index=True) + dropped_date = Column(DateTime, nullable=False, index=True) + length = Column(Integer, nullable=False) + is_numeric = Column(Boolean, default=False) + has_hyphen = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.utcnow) + + __table_args__ = ( + Index('ix_dropped_domains_tld_date', 'tld', 'dropped_date'), + Index('ix_dropped_domains_length', 'length'), + ) diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 8c772e5..8b6a2e6 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -657,6 +657,15 @@ def setup_scheduler(): replace_existing=True, ) + # Zone file sync for .ch and .li domains (daily at 05:00 UTC) + scheduler.add_job( + sync_zone_files, + CronTrigger(hour=5, minute=0), # Daily at 05:00 UTC + id="zone_file_sync", + name="Zone File Sync (daily)", + replace_existing=True, + ) + logger.info( f"Scheduler configured:" f"\n - Scout domain check at {settings.check_hour:02d}:{settings.check_minute:02d} (daily)" @@ -667,6 +676,7 @@ def setup_scheduler(): f"\n - Auction scrape every 2 hours at :30" f"\n - Expired auction cleanup every 15 minutes" f"\n - Sniper alert matching every 30 minutes" + f"\n - Zone file sync daily at 05:00 UTC" ) @@ -831,6 +841,36 @@ async def scrape_auctions(): logger.exception(f"Auction scrape failed: {e}") +async def sync_zone_files(): + """Sync zone files for .ch and .li domains from Switch.ch.""" + logger.info("Starting zone file sync...") + + try: + from app.services.zone_file import ZoneFileService + + service = ZoneFileService() + + async with AsyncSessionLocal() as db: + # Sync .ch zone + try: + ch_result = await service.run_daily_sync(db, "ch") + logger.info(f".ch zone sync: {len(ch_result.get('dropped', []))} dropped, {ch_result.get('new_count', 0)} new") + except Exception as e: + logger.error(f".ch zone sync failed: {e}") + + # Sync .li zone + try: + li_result = await service.run_daily_sync(db, "li") + logger.info(f".li zone sync: {len(li_result.get('dropped', []))} dropped, {li_result.get('new_count', 0)} new") + except Exception as e: + logger.error(f".li zone sync failed: {e}") + + logger.info("Zone file sync completed") + + except Exception as e: + logger.exception(f"Zone file sync failed: {e}") + + async def match_sniper_alerts(): """Match active sniper alerts against current auctions and notify users.""" from app.models.sniper_alert import SniperAlert, SniperAlertMatch diff --git a/backend/app/services/zone_file.py b/backend/app/services/zone_file.py new file mode 100644 index 0000000..1618cd9 --- /dev/null +++ b/backend/app/services/zone_file.py @@ -0,0 +1,357 @@ +""" +Zone File Service for .ch and .li domains +========================================== +Uses DNS AXFR zone transfer to fetch domain lists from Switch.ch +Compares daily snapshots to find freshly dropped domains. + +Storage: We only store the diff (dropped/new domains) to minimize disk usage. +""" + +import asyncio +import hashlib +import logging +from datetime import datetime, timedelta +from pathlib import Path +from typing import Optional + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.zone_file import ZoneSnapshot, DroppedDomain + +logger = logging.getLogger(__name__) + + +# ============================================================================ +# TSIG KEYS (from Switch.ch documentation) +# ============================================================================ + +TSIG_KEYS = { + "ch": { + "name": "tsig-zonedata-ch-public-21-01", + "algorithm": "hmac-sha512", + "secret": "stZwEGApYumtXkh73qMLPqfbIDozWKZLkqRvcjKSpRnsor6A6MxixRL6C2HeSVBQNfMW4wer+qjS0ZSfiWiJ3Q==" + }, + "li": { + "name": "tsig-zonedata-li-public-21-01", + "algorithm": "hmac-sha512", + "secret": "t8GgeCn+fhPaj+cRy1epox2Vj4hZ45ax6v3rQCkkfIQNg5fsxuU23QM5mzz+BxJ4kgF/jiQyBDBvL+XWPE6oCQ==" + } +} + +ZONE_SERVER = "zonedata.switch.ch" + +# ============================================================================ +# ZONE FILE SERVICE +# ============================================================================ + +class ZoneFileService: + """Service for fetching and analyzing zone files""" + + def __init__(self, data_dir: Optional[Path] = None): + self.data_dir = data_dir or Path("/tmp/pounce_zones") + self.data_dir.mkdir(parents=True, exist_ok=True) + + def _get_key_file_path(self, tld: str) -> Path: + """Generate TSIG key file for dig command""" + key_path = self.data_dir / f"{tld}_zonedata.key" + key_info = TSIG_KEYS.get(tld) + + if not key_info: + raise ValueError(f"Unknown TLD: {tld}") + + # Write TSIG key file in BIND format + key_content = f"""key "{key_info['name']}" {{ + algorithm {key_info['algorithm']}; + secret "{key_info['secret']}"; +}}; +""" + key_path.write_text(key_content) + return key_path + + async def fetch_zone_file(self, tld: str) -> set[str]: + """ + Fetch zone file via DNS AXFR transfer. + Returns set of domain names (without TLD suffix). + """ + if tld not in TSIG_KEYS: + raise ValueError(f"Unsupported TLD: {tld}. Only 'ch' and 'li' are supported.") + + logger.info(f"Starting zone transfer for .{tld}") + + key_file = self._get_key_file_path(tld) + + # Build dig command + cmd = [ + "dig", + "-k", str(key_file), + f"@{ZONE_SERVER}", + "+noall", + "+answer", + "+noidnout", + "+onesoa", + "AXFR", + f"{tld}." + ] + + try: + # Run dig command (this can take a while for large zones) + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await asyncio.wait_for( + process.communicate(), + timeout=600 # 10 minutes timeout for large zones + ) + + if process.returncode != 0: + error_msg = stderr.decode() if stderr else "Unknown error" + logger.error(f"Zone transfer failed for .{tld}: {error_msg}") + raise RuntimeError(f"Zone transfer failed: {error_msg}") + + # Parse output to extract domain names + domains = set() + for line in stdout.decode().splitlines(): + parts = line.split() + if len(parts) >= 1: + domain = parts[0].rstrip('.') + # Only include actual domain names (not the TLD itself) + if domain and domain != tld and '.' in domain: + # Extract just the name part (before the TLD) + name = domain.rsplit('.', 1)[0] + if name: + domains.add(name.lower()) + + logger.info(f"Zone transfer complete for .{tld}: {len(domains)} domains") + return domains + + except asyncio.TimeoutError: + logger.error(f"Zone transfer timed out for .{tld}") + raise RuntimeError("Zone transfer timed out") + except FileNotFoundError: + logger.error("dig command not found. Please install bind-utils or dnsutils.") + raise RuntimeError("dig command not available") + + def compute_checksum(self, domains: set[str]) -> str: + """Compute SHA256 checksum of sorted domain list""" + sorted_domains = "\n".join(sorted(domains)) + return hashlib.sha256(sorted_domains.encode()).hexdigest() + + async def get_previous_snapshot(self, db: AsyncSession, tld: str) -> Optional[set[str]]: + """Load previous day's domain set from cache file""" + cache_file = self.data_dir / f"{tld}_domains.txt" + + if cache_file.exists(): + try: + content = cache_file.read_text() + return set(line.strip() for line in content.splitlines() if line.strip()) + except Exception as e: + logger.warning(f"Failed to load cache for .{tld}: {e}") + + return None + + async def save_snapshot(self, db: AsyncSession, tld: str, domains: set[str]): + """Save current snapshot to cache and database""" + # Save to cache file + cache_file = self.data_dir / f"{tld}_domains.txt" + cache_file.write_text("\n".join(sorted(domains))) + + # Save metadata to database + checksum = self.compute_checksum(domains) + snapshot = ZoneSnapshot( + tld=tld, + snapshot_date=datetime.utcnow(), + domain_count=len(domains), + checksum=checksum + ) + db.add(snapshot) + await db.commit() + + logger.info(f"Saved snapshot for .{tld}: {len(domains)} domains") + + async def process_drops( + self, + db: AsyncSession, + tld: str, + previous: set[str], + current: set[str] + ) -> list[dict]: + """Find and store dropped domains""" + dropped = previous - current + + if not dropped: + logger.info(f"No dropped domains found for .{tld}") + return [] + + logger.info(f"Found {len(dropped)} dropped domains for .{tld}") + + today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + + # Store dropped domains + dropped_records = [] + for name in dropped: + record = DroppedDomain( + domain=f"{name}.{tld}", + tld=tld, + dropped_date=today, + length=len(name), + is_numeric=name.isdigit(), + has_hyphen='-' in name + ) + db.add(record) + dropped_records.append({ + "domain": f"{name}.{tld}", + "length": len(name), + "is_numeric": name.isdigit(), + "has_hyphen": '-' in name + }) + + await db.commit() + + return dropped_records + + async def run_daily_sync(self, db: AsyncSession, tld: str) -> dict: + """ + Run daily zone file sync: + 1. Fetch current zone file + 2. Compare with previous snapshot + 3. Store dropped domains + 4. Save new snapshot + """ + logger.info(f"Starting daily sync for .{tld}") + + # Get previous snapshot + previous = await self.get_previous_snapshot(db, tld) + + # Fetch current zone + current = await self.fetch_zone_file(tld) + + result = { + "tld": tld, + "current_count": len(current), + "previous_count": len(previous) if previous else 0, + "dropped": [], + "new_count": 0 + } + + if previous: + # Find dropped domains + result["dropped"] = await self.process_drops(db, tld, previous, current) + result["new_count"] = len(current - previous) + + # Save current snapshot + await self.save_snapshot(db, tld, current) + + logger.info(f"Daily sync complete for .{tld}: {len(result['dropped'])} dropped, {result['new_count']} new") + + return result + + +# ============================================================================ +# API FUNCTIONS +# ============================================================================ + +async def get_dropped_domains( + db: AsyncSession, + tld: Optional[str] = None, + days: int = 7, + min_length: Optional[int] = None, + max_length: Optional[int] = None, + exclude_numeric: bool = False, + exclude_hyphen: bool = False, + keyword: Optional[str] = None, + limit: int = 100, + offset: int = 0 +) -> dict: + """ + Get recently dropped domains with filters. + """ + cutoff = datetime.utcnow() - timedelta(days=days) + + query = select(DroppedDomain).where(DroppedDomain.dropped_date >= cutoff) + count_query = select(func.count(DroppedDomain.id)).where(DroppedDomain.dropped_date >= cutoff) + + if tld: + query = query.where(DroppedDomain.tld == tld) + count_query = count_query.where(DroppedDomain.tld == tld) + + if min_length: + query = query.where(DroppedDomain.length >= min_length) + count_query = count_query.where(DroppedDomain.length >= min_length) + + if max_length: + query = query.where(DroppedDomain.length <= max_length) + count_query = count_query.where(DroppedDomain.length <= max_length) + + if exclude_numeric: + query = query.where(DroppedDomain.is_numeric == False) + count_query = count_query.where(DroppedDomain.is_numeric == False) + + if exclude_hyphen: + query = query.where(DroppedDomain.has_hyphen == False) + count_query = count_query.where(DroppedDomain.has_hyphen == False) + + if keyword: + query = query.where(DroppedDomain.domain.ilike(f"%{keyword}%")) + count_query = count_query.where(DroppedDomain.domain.ilike(f"%{keyword}%")) + + # Get total count + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + # Get items with pagination + query = query.order_by(DroppedDomain.dropped_date.desc(), DroppedDomain.length) + query = query.offset(offset).limit(limit) + + result = await db.execute(query) + items = result.scalars().all() + + return { + "total": total, + "items": [ + { + "domain": item.domain, + "tld": item.tld, + "dropped_date": item.dropped_date.isoformat(), + "length": item.length, + "is_numeric": item.is_numeric, + "has_hyphen": item.has_hyphen + } + for item in items + ] + } + + +async def get_zone_stats(db: AsyncSession) -> dict: + """Get zone file statistics""" + # Get latest snapshots + ch_query = select(ZoneSnapshot).where(ZoneSnapshot.tld == "ch").order_by(ZoneSnapshot.snapshot_date.desc()).limit(1) + li_query = select(ZoneSnapshot).where(ZoneSnapshot.tld == "li").order_by(ZoneSnapshot.snapshot_date.desc()).limit(1) + + ch_result = await db.execute(ch_query) + li_result = await db.execute(li_query) + + ch_snapshot = ch_result.scalar_one_or_none() + li_snapshot = li_result.scalar_one_or_none() + + # Count recent drops + today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + week_ago = today - timedelta(days=7) + + drops_query = select(func.count(DroppedDomain.id)).where(DroppedDomain.dropped_date >= week_ago) + drops_result = await db.execute(drops_query) + weekly_drops = drops_result.scalar() or 0 + + return { + "ch": { + "domain_count": ch_snapshot.domain_count if ch_snapshot else 0, + "last_sync": ch_snapshot.snapshot_date.isoformat() if ch_snapshot else None + }, + "li": { + "domain_count": li_snapshot.domain_count if li_snapshot else 0, + "last_sync": li_snapshot.snapshot_date.isoformat() if li_snapshot else None + }, + "weekly_drops": weekly_drops + } diff --git a/frontend/src/components/hunt/BrandableForgeTab.tsx b/frontend/src/components/hunt/BrandableForgeTab.tsx index 946cc99..14c926e 100644 --- a/frontend/src/components/hunt/BrandableForgeTab.tsx +++ b/frontend/src/components/hunt/BrandableForgeTab.tsx @@ -2,17 +2,39 @@ import { useCallback, useState } from 'react' import clsx from 'clsx' -import { ExternalLink, Loader2, Shield, Sparkles, Eye } from 'lucide-react' +import { + ExternalLink, + Loader2, + Shield, + Sparkles, + Eye, + RefreshCw, + Wand2, + Settings, + ChevronRight, + Zap, + Filter, +} from 'lucide-react' import { api } from '@/lib/api' import { useAnalyzePanelStore } from '@/lib/analyze-store' import { useStore } from '@/lib/store' +// ============================================================================ +// CONSTANTS +// ============================================================================ + const PATTERNS = [ - { key: 'cvcvc', label: 'CVCVC (5 letters)' }, - { key: 'cvccv', label: 'CVCCV (5 letters)' }, - { key: 'human', label: 'Human-like (2 syllables)' }, + { key: 'cvcvc', label: 'CVCVC', desc: '5-letter brandables (Zalor, Mivex)' }, + { key: 'cvccv', label: 'CVCCV', desc: '5-letter variants (Bento, Salvo)' }, + { key: 'human', label: 'Human', desc: '2-syllable names (Siri, Alexa)' }, ] +const TLDS = ['com', 'io', 'ai', 'co', 'net', 'org'] + +// ============================================================================ +// HELPERS +// ============================================================================ + function parseTlds(input: string): string[] { return input .split(',') @@ -21,30 +43,54 @@ function parseTlds(input: string): string[] { .slice(0, 10) } +// ============================================================================ +// COMPONENT +// ============================================================================ + export function BrandableForgeTab({ showToast }: { showToast: (message: string, type?: any) => void }) { const openAnalyze = useAnalyzePanelStore((s) => s.open) const addDomain = useStore((s) => s.addDomain) + + // Config State const [pattern, setPattern] = useState('cvcvc') - const [tldsRaw, setTldsRaw] = useState('com') + const [selectedTlds, setSelectedTlds] = useState(['com']) const [limit, setLimit] = useState(30) + const [showConfig, setShowConfig] = useState(false) + + // Results State const [loading, setLoading] = useState(false) const [items, setItems] = useState>([]) const [error, setError] = useState(null) const [tracking, setTracking] = useState(null) + const toggleTld = useCallback((tld: string) => { + setSelectedTlds((prev) => + prev.includes(tld) ? prev.filter((t) => t !== tld) : [...prev, tld] + ) + }, []) + const run = useCallback(async () => { + if (selectedTlds.length === 0) { + showToast('Select at least one TLD', 'error') + return + } setLoading(true) setError(null) try { - const res = await api.huntBrandables({ pattern, tlds: parseTlds(tldsRaw), limit, max_checks: 400 }) + const res = await api.huntBrandables({ pattern, tlds: selectedTlds, limit, max_checks: 400 }) setItems(res.items.map((i) => ({ domain: i.domain, status: i.status }))) + if (res.items.length === 0) { + showToast('No available domains found. Try different settings.', 'info') + } } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + const msg = e instanceof Error ? e.message : String(e) + setError(msg) + showToast(msg, 'error') setItems([]) } finally { setLoading(false) } - }, [pattern, tldsRaw, limit]) + }, [pattern, selectedTlds, limit, showToast]) const track = useCallback( async (domain: string) => { @@ -63,113 +109,296 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string, ) return ( -
-
-
-
Brandable Forge
-
Generate & verify
+
+ {/* Header with Generate Button */} +
+
+
+
+ +
+
+
Brandable Forge
+
Generate available brandable names
+
+
+
+ + +
+
+ + {/* Pattern Selection */} +
+
+ {PATTERNS.map((p) => ( + + ))} +
+
+ + {/* TLD Selection */} +
+
+ TLDs +
+
+ {TLDS.map((tld) => ( + + ))} +
+
+ + {/* Advanced Config (collapsed) */} + {showConfig && ( +
+
+
+ + setLimit(Math.max(1, Math.min(100, Number(e.target.value) || 30)))} + className="w-24 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} + /> +
+
+ Generate up to {limit} available brandable domains. We check via DNS/RDAP and only return verified available domains. +
+
+
+ )} + + {/* Stats Bar */} +
+ {items.length} domains generated + + + All verified available +
-
-
-
-
- - -
- -
- - setTldsRaw(e.target.value)} - className="w-full bg-white/[0.02] border border-white/10 px-3 py-2 text-sm text-white outline-none focus:border-accent/40 font-mono" - placeholder="com, io, ai" - /> -
- -
- - setLimit(Math.max(1, Math.min(100, Number(e.target.value) || 30)))} - className="w-full bg-white/[0.02] border border-white/10 px-3 py-2 text-sm text-white outline-none focus:border-accent/40 font-mono" - min={1} - max={100} - /> -
- -
- No external APIs. Availability is checked via DNS/RDAP (quick mode). We only return domains that are actually available. -
+ {/* Error Message */} + {error && ( +
+ {error}
+ )} -
- {error ?
{error}
: null} -
- {items.map((i) => ( -
- -
+ {/* Results Grid */} + {items.length > 0 && ( +
+ {/* Desktop Header */} +
+ Domain + Status + Actions +
+ + {items.map((i) => ( +
+ {/* Mobile Row */} +
+
+
+
+ +
+
+ + + AVAILABLE + +
+
+
+ +
+ + - + Register + +
+
+ + {/* Desktop Row */} +
+
+
+ +
+ +
+ +
+ + AVAILABLE + +
+ +
+ + Register + +
- ))} - {!loading && items.length === 0 ?
No results yet.
: null} +
+ ))} +
+ )} + + {/* Empty State */} + {items.length === 0 && !loading && ( +
+ +

No domains generated yet

+

Click "Generate" to create brandable names

+
+ )} + + {/* Info Cards */} + {items.length === 0 && ( +
+
+
+
+ +
+
+
AI-Powered
+
Smart generation
+
+
+

+ Generate pronounceable, memorable names following proven patterns like CVCVC. +

+
+ +
+
+
+ +
+
+
Verified
+
Real availability
+
+
+

+ Every domain is checked via DNS/RDAP. Only verified available domains are shown. +

+
+ +
+
+
+ +
+
+
Instant Register
+
One-click buy
+
+
+

+ Found a perfect name? Register instantly via Namecheap with one click. +

-
+ )}
) } - diff --git a/frontend/src/components/hunt/DropsTab.tsx b/frontend/src/components/hunt/DropsTab.tsx index 2afedf3..ca36353 100644 --- a/frontend/src/components/hunt/DropsTab.tsx +++ b/frontend/src/components/hunt/DropsTab.tsx @@ -1,12 +1,53 @@ 'use client' -import { useState } from 'react' -import { Download, Clock, Globe, Loader2, Search, Filter, ChevronRight, AlertCircle } from 'lucide-react' +import { useState, useEffect, useCallback, useMemo } from 'react' +import { api } from '@/lib/api' +import { useAnalyzePanelStore } from '@/lib/analyze-store' +import { useStore } from '@/lib/store' +import { + Download, + Clock, + Globe, + Loader2, + Search, + Filter, + ChevronRight, + ChevronLeft, + ChevronUp, + ChevronDown, + RefreshCw, + X, + Eye, + Shield, + ExternalLink, + Zap, + Calendar, + Ban, + Hash, +} from 'lucide-react' import clsx from 'clsx' // ============================================================================ -// DROPS TAB - Zone File Analysis -// Placeholder component - User will set up the data source +// TYPES +// ============================================================================ + +interface DroppedDomain { + domain: string + tld: string + dropped_date: string + length: number + is_numeric: boolean + has_hyphen: boolean +} + +interface ZoneStats { + ch: { domain_count: number; last_sync: string | null } + li: { domain_count: number; last_sync: string | null } + weekly_drops: number +} + +// ============================================================================ +// COMPONENT // ============================================================================ interface DropsTabProps { @@ -14,25 +55,221 @@ interface DropsTabProps { } export function DropsTab({ showToast }: DropsTabProps) { - const [selectedTld, setSelectedTld] = useState('com') - const [loading, setLoading] = useState(false) + const openAnalyze = useAnalyzePanelStore((s) => s.open) + const addDomain = useStore((s) => s.addDomain) + + // Data State + const [items, setItems] = useState([]) + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [total, setTotal] = useState(0) + + // Filter State + const [selectedTld, setSelectedTld] = useState<'ch' | 'li' | null>(null) const [searchQuery, setSearchQuery] = useState('') const [searchFocused, setSearchFocused] = useState(false) + const [days, setDays] = useState(7) + const [minLength, setMinLength] = useState(undefined) + const [maxLength, setMaxLength] = useState(undefined) + const [excludeNumeric, setExcludeNumeric] = useState(true) + const [excludeHyphen, setExcludeHyphen] = useState(true) + const [filtersOpen, setFiltersOpen] = useState(false) - // TODO: Replace with real API call to zone file analysis endpoint - const handleRefresh = async () => { - setLoading(true) - // Simulated delay - replace with actual API call - await new Promise(resolve => setTimeout(resolve, 1500)) - setLoading(false) - showToast('Zone file data will be available once configured', 'info') + // Pagination + const [page, setPage] = useState(1) + const ITEMS_PER_PAGE = 50 + + // Sorting + const [sortField, setSortField] = useState<'domain' | 'length' | 'date'>('date') + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc') + + // Tracking + const [tracking, setTracking] = useState(null) + + // Load Stats + const loadStats = useCallback(async () => { + try { + const result = await api.getDropsStats() + setStats(result) + } catch (error) { + console.error('Failed to load zone stats:', error) + } + }, []) + + // Load Drops + const loadDrops = useCallback(async (currentPage = 1, isRefresh = false) => { + if (isRefresh) setRefreshing(true) + else setLoading(true) + + try { + const result = await api.getDrops({ + tld: selectedTld || undefined, + days, + min_length: minLength, + max_length: maxLength, + exclude_numeric: excludeNumeric, + exclude_hyphen: excludeHyphen, + keyword: searchQuery || undefined, + limit: ITEMS_PER_PAGE, + offset: (currentPage - 1) * ITEMS_PER_PAGE, + }) + setItems(result.items) + setTotal(result.total) + } catch (error: any) { + console.error('Failed to load drops:', error) + // If API returns error, show info message + if (error.message?.includes('401') || error.message?.includes('auth')) { + showToast('Login required to view drops', 'info') + } + setItems([]) + setTotal(0) + } finally { + setLoading(false) + setRefreshing(false) + } + }, [selectedTld, days, minLength, maxLength, excludeNumeric, excludeHyphen, searchQuery, showToast]) + + // Initial Load + useEffect(() => { + loadStats() + }, [loadStats]) + + useEffect(() => { + setPage(1) + loadDrops(1) + }, [loadDrops]) + + const handlePageChange = useCallback((newPage: number) => { + setPage(newPage) + loadDrops(newPage) + }, [loadDrops]) + + const handleRefresh = useCallback(async () => { + await loadDrops(page, true) + await loadStats() + }, [loadDrops, loadStats, page]) + + const track = useCallback(async (domain: string) => { + if (tracking) return + setTracking(domain) + try { + await addDomain(domain) + showToast(`Tracking ${domain}`, 'success') + } catch (e) { + showToast(e instanceof Error ? e.message : 'Failed', 'error') + } finally { + setTracking(null) + } + }, [addDomain, showToast, tracking]) + + // Sorted Items + const sortedItems = useMemo(() => { + const mult = sortDirection === 'asc' ? 1 : -1 + return [...items].sort((a, b) => { + switch (sortField) { + case 'domain': + return mult * a.domain.localeCompare(b.domain) + case 'length': + return mult * (a.length - b.length) + case 'date': + return mult * (new Date(a.dropped_date).getTime() - new Date(b.dropped_date).getTime()) + default: + return 0 + } + }) + }, [items, sortField, sortDirection]) + + const handleSort = useCallback((field: typeof sortField) => { + if (sortField === field) { + setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc')) + } else { + setSortField(field) + setSortDirection(field === 'length' ? 'asc' : 'desc') + } + }, [sortField]) + + const totalPages = Math.ceil(total / ITEMS_PER_PAGE) + const activeFiltersCount = [ + selectedTld !== null, + minLength !== undefined, + maxLength !== undefined, + excludeNumeric, + excludeHyphen, + days !== 7 + ].filter(Boolean).length + + const formatDate = (iso: string) => { + const d = new Date(iso) + return d.toLocaleDateString('de-CH', { day: '2-digit', month: 'short' }) } - const tlds = ['com', 'net', 'org', 'io', 'ai', 'co'] + if (loading && items.length === 0) { + return ( +
+ +
+ ) + } return (
- {/* Header Controls */} + {/* Stats Cards */} + {stats && ( +
+
+
+ ๐Ÿ‡จ๐Ÿ‡ญ + .ch Zone +
+
+ {stats.ch.domain_count.toLocaleString()} +
+
+ {stats.ch.last_sync ? `Last: ${new Date(stats.ch.last_sync).toLocaleDateString()}` : 'Not synced'} +
+
+ +
+
+ ๐Ÿ‡ฑ๐Ÿ‡ฎ + .li Zone +
+
+ {stats.li.domain_count.toLocaleString()} +
+
+ {stats.li.last_sync ? `Last: ${new Date(stats.li.last_sync).toLocaleDateString()}` : 'Not synced'} +
+
+ +
+
+ + Weekly Drops +
+
+ {stats.weekly_drops.toLocaleString()} +
+
+ Last 7 days +
+
+ +
+
+ + Data Source +
+
Switch.ch
+
+ Official zone files +
+
+
+ )} + + {/* Search & Filters */}
{/* Search */}
setSearchQuery(e.target.value)} onFocus={() => setSearchFocused(true)} onBlur={() => setSearchFocused(false)} - placeholder="Search freshly dropped domains..." + placeholder="Search dropped domains..." className="flex-1 bg-transparent px-3 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono" /> + {searchQuery && ( + + )} +
- {/* TLD Selector */} -
- {tlds.map((tld) => ( - - ))} + {/* TLD Quick Filter */} +
+ + +
-
- {/* Info Card - Setup Required */} -
-
- -
-

Zone File Integration

-

- This feature analyzes zone files to find freshly dropped domains. - Configure your zone file data source to see real-time drops. -

+ {/* Filter Toggle */} + + + {/* Filters Panel */} + {filtersOpen && ( +
+ {/* Time Range */} +
+
Time Range
+
+ {[ + { value: 1, label: '24h' }, + { value: 7, label: '7 days' }, + { value: 14, label: '14 days' }, + { value: 30, label: '30 days' }, + ].map((item) => ( + + ))} +
+
+ + {/* Length Filter */} +
+
Domain Length
+
+ setMinLength(e.target.value ? Number(e.target.value) : undefined)} + placeholder="Min" + className="w-20 bg-white/[0.02] border border-white/10 px-2 py-1.5 text-xs text-white placeholder:text-white/25 outline-none focus:border-accent/40 font-mono" + min={1} + max={63} + /> + โ€“ + setMaxLength(e.target.value ? Number(e.target.value) : undefined)} + placeholder="Max" + className="w-20 bg-white/[0.02] border border-white/10 px-2 py-1.5 text-xs text-white placeholder:text-white/25 outline-none focus:border-accent/40 font-mono" + min={1} + max={63} + /> + characters +
+
+ + {/* Quality Filters */}
+ +
-
+ )}
- {/* Feature Preview Cards */} -
-
-
-
- -
-
-
Real-time Drops
-
Updated every hour
-
-
-

- Monitor domains as they expire and become available for registration. -

-
- -
-
-
- -
-
-
Smart Filters
-
Length, keywords, patterns
-
-
-

- Filter by domain length, keywords, character patterns, and more. -

-
- -
-
-
- -
-
-
Multi-TLD
-
All major TLDs
-
-
-

- Track drops across .com, .net, .org, .io, .ai, and more. -

-
+ {/* Stats Bar */} +
+ {total.toLocaleString()} dropped domains found + Page {page}/{Math.max(1, totalPages)}
- {/* Placeholder Table */} -
-
+ {/* Results */} + {sortedItems.length === 0 ? ( +
+ +

No dropped domains found

+

+ {stats?.weekly_drops === 0 + ? 'Zone file sync may be pending' + : 'Try adjusting your filters'} +

+
+ ) : ( + <> +
+ {/* Desktop Table Header */} +
+ + + +
Actions
+
+ + {sortedItems.map((item) => ( +
+ {/* Mobile Row */} +
+
+
+
+ {item.tld === 'ch' ? '๐Ÿ‡จ๐Ÿ‡ญ' : '๐Ÿ‡ฑ๐Ÿ‡ฎ'} +
+
+ +
+ {item.length} chars + | + {formatDate(item.dropped_date)} +
+
+
+
+ +
+ + + + + + Register + + +
+
+ + {/* Desktop Row */} +
+
+
+ {item.tld === 'ch' ? '๐Ÿ‡จ๐Ÿ‡ญ' : '๐Ÿ‡ฑ๐Ÿ‡ฎ'} +
+ +
+ +
+ + {item.length} + +
+ +
+ {formatDate(item.dropped_date)} +
+ +
+ + + + + + Get + + +
+
+
+ ))} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+
+ Page {page}/{totalPages} +
+
+ + + + {page}/{totalPages} + + + +
+
+ )} + + )} + + {/* Info Box */} +
+
+
+ +
-
Freshly Dropped
-
.{selectedTld.toUpperCase()} Domains
+

Zone File Analysis

+

+ Domains are detected by comparing daily zone file snapshots from Switch.ch. + Data is updated automatically every 24 hours. Only .ch and .li domains are supported + as these zones are publicly available from the Swiss registry. +

-
- Awaiting data... -
-
- -
- -

No zone file data available

-

- Configure zone file integration to see dropped domains -

diff --git a/frontend/src/components/hunt/SearchTab.tsx b/frontend/src/components/hunt/SearchTab.tsx index 69834ec..74a8c93 100644 --- a/frontend/src/components/hunt/SearchTab.tsx +++ b/frontend/src/components/hunt/SearchTab.tsx @@ -16,6 +16,11 @@ import { Globe, Calendar, Building, + Clock, + RefreshCw, + Sparkles, + ExternalLink, + History, } from 'lucide-react' import clsx from 'clsx' @@ -56,7 +61,7 @@ export function SearchTab({ showToast }: SearchTabProps) { const stored = localStorage.getItem('pounce_recent_searches') if (stored) { try { - setRecentSearches(JSON.parse(stored).slice(0, 5)) + setRecentSearches(JSON.parse(stored).slice(0, 8)) } catch { // Ignore invalid JSON } @@ -67,7 +72,7 @@ export function SearchTab({ showToast }: SearchTabProps) { const saveToRecent = useCallback((domain: string) => { setRecentSearches((prev) => { const filtered = prev.filter((d) => d !== domain) - const updated = [domain, ...filtered].slice(0, 5) + const updated = [domain, ...filtered].slice(0, 8) localStorage.setItem('pounce_recent_searches', JSON.stringify(updated)) return updated }) @@ -126,166 +131,163 @@ export function SearchTab({ showToast }: SearchTabProps) { }, []) return ( -
- {/* Search Card */} -
-
- -
-
- - - Domain Search - -
-
-
-
-
-
- -
-
-
- - setSearchQuery(e.target.value)} - onFocus={() => setSearchFocused(true)} - onBlur={() => setSearchFocused(false)} - onKeyDown={(e) => e.key === 'Enter' && handleSearch(searchQuery)} - placeholder="example.com" - className="flex-1 bg-transparent px-3 py-4 text-base lg:text-lg text-white placeholder:text-white/20 outline-none font-mono" - /> - {searchQuery && ( - - )} -
-
- - {!searchResult && ( -

- Enter a domain to check availability, WHOIS data, and registration options -

+
+ {/* Search Input */} +
+
+ + setSearchQuery(e.target.value)} + onFocus={() => setSearchFocused(true)} + onBlur={() => setSearchFocused(false)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch(searchQuery)} + placeholder="example.com" + className="flex-1 bg-transparent px-3 py-4 text-base lg:text-lg text-white placeholder:text-white/20 outline-none font-mono" + /> + {searchQuery && ( + + )} +
+ > + Check +
+ {/* Stats Bar */} +
+ Enter a domain to check availability via RDAP/WHOIS + + + Instant check + +
+ {/* Search Result */} {searchResult && (
{searchResult.loading ? ( -
+
Checking availability...
) : (
- {/* Result Header */} -
-
- {searchResult.is_available ? ( -
- + {/* Result Row */} +
+
+
+ {/* Status Icon */} +
+ {searchResult.is_available ? ( + + ) : ( + + )}
- ) : ( -
- -
- )} -
-
{searchResult.domain}
-
- {searchResult.is_available ? 'Available for registration' : 'Already registered'} + + {/* Domain Info */} +
+
{searchResult.domain}
+
+ + {searchResult.is_available ? 'Available' : 'Taken'} + + {searchResult.registrar && ( + <> + | + + + {searchResult.registrar} + + + )} + {searchResult.expiration_date && ( + <> + | + + + Expires {new Date(searchResult.expiration_date).toLocaleDateString()} + + + )} +
+ + {/* Actions */} +
+ + + + + {searchResult.is_available ? ( + + Register + + + ) : ( + + Find Similar + + + )} +
- - {searchResult.is_available ? 'Available' : 'Taken'} - -
- - {/* WHOIS Info (if taken) */} - {!searchResult.is_available && (searchResult.registrar || searchResult.expiration_date) && ( -
- {searchResult.registrar && ( -
- -
-
Registrar
-
{searchResult.registrar}
-
-
- )} - {searchResult.expiration_date && ( -
- -
-
Expires
-
- {new Date(searchResult.expiration_date).toLocaleDateString()} -
-
-
- )} -
- )} - - {/* Actions */} -
- - - - - {searchResult.is_available && ( - - Register - - - )}
)} @@ -294,24 +296,37 @@ export function SearchTab({ showToast }: SearchTabProps) { {/* Recent Searches */} {!searchResult && recentSearches.length > 0 && ( -
-
-
- Recent Searches +
+
+
+ + Recent Searches +
+
-
- {recentSearches.map((domain) => ( - - ))} +
+
+ {recentSearches.map((domain) => ( + + ))} +
)} @@ -319,27 +334,48 @@ export function SearchTab({ showToast }: SearchTabProps) { {/* Quick Tips */} {!searchResult && (
-
- -
Instant Check
-

- Check any domain's availability in real-time using RDAP/WHOIS. +

+
+
+ +
+
+
Instant Check
+
RDAP/WHOIS
+
+
+

+ Check any domain's availability in real-time using RDAP/WHOIS protocols.

-
- -
Track Changes
-

- Add domains to your watchlist to monitor when they become available. +

+
+
+ +
+
+
Monitor Drops
+
Watchlist
+
+
+

+ Add taken domains to your watchlist. Get alerted when they become available.

-
- -
Deep Analysis
-

- Run full analysis to check backlinks, SEO metrics, and domain history. +

+
+
+ +
+
+
Deep Analysis
+
Full report
+
+
+

+ Run full analysis: backlinks, SEO metrics, history, trademark checks.

diff --git a/frontend/src/components/hunt/TrendSurferTab.tsx b/frontend/src/components/hunt/TrendSurferTab.tsx index 8744bfb..6b3054e 100644 --- a/frontend/src/components/hunt/TrendSurferTab.tsx +++ b/frontend/src/components/hunt/TrendSurferTab.tsx @@ -2,31 +2,62 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import clsx from 'clsx' -import { ExternalLink, Loader2, Search, Shield, Sparkles, Eye } from 'lucide-react' +import { + ExternalLink, + Loader2, + Search, + Shield, + Sparkles, + Eye, + TrendingUp, + RefreshCw, + Filter, + ChevronRight, + Globe, + Zap, + X +} from 'lucide-react' import { api } from '@/lib/api' import { useAnalyzePanelStore } from '@/lib/analyze-store' import { useStore } from '@/lib/store' +// ============================================================================ +// HELPERS +// ============================================================================ + function normalizeKeyword(s: string) { return s.trim().replace(/\s+/g, ' ') } +// ============================================================================ +// COMPONENT +// ============================================================================ + export function TrendSurferTab({ showToast }: { showToast: (message: string, type?: any) => void }) { const openAnalyze = useAnalyzePanelStore((s) => s.open) const addDomain = useStore((s) => s.addDomain) + + // Trends State const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [geo, setGeo] = useState('US') const [trends, setTrends] = useState>([]) const [selected, setSelected] = useState('') + const [refreshing, setRefreshing] = useState(false) + // Keyword Check State const [keywordInput, setKeywordInput] = useState('') + const [keywordFocused, setKeywordFocused] = useState(false) const [availability, setAvailability] = useState>([]) const [checking, setChecking] = useState(false) + // Typo Check State const [brand, setBrand] = useState('') + const [brandFocused, setBrandFocused] = useState(false) const [typos, setTypos] = useState>([]) const [typoLoading, setTypoLoading] = useState(false) + + // Tracking State const [tracking, setTracking] = useState(null) const track = useCallback( @@ -45,7 +76,8 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ [addDomain, showToast, tracking] ) - const loadTrends = useCallback(async () => { + const loadTrends = useCallback(async (isRefresh = false) => { + if (isRefresh) setRefreshing(true) setError(null) try { const res = await api.getHuntTrends(geo) @@ -56,6 +88,8 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ setError(msg) showToast(msg, 'error') setTrends([]) + } finally { + if (isRefresh) setRefreshing(false) } }, [geo, selected, showToast]) @@ -72,9 +106,7 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ } } run() - return () => { - cancelled = true - } + return () => { cancelled = true } }, [loadTrends]) const keyword = useMemo(() => normalizeKeyword(keywordInput || selected || ''), [keywordInput, selected]) @@ -113,202 +145,290 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ if (loading) { return ( -
+
) } return ( -
+
+ {/* Trends Header */}
-
-
Top Google Trends (24h)
-
- +
+
+ +
+
+
Google Trends (24h)
+
Real-time trending topics
+
+
+
+ +
- {error ?
{error}
: null} -
-
- {trends.slice(0, 30).map((t) => { - const active = selected === t.title - return ( - - ) - })}
+ + {error ? ( +
{error}
+ ) : ( +
+ {trends.slice(0, 20).map((t) => { + const active = selected === t.title + return ( + + ) + })} +
+ )}
-
- {/* Keyword availability */} -
-
+ {/* Keyword Availability Check */} +
+
+
+
+ +
-
Keyword Availability
-
Find available domains
+
Domain Availability
+
Check {keyword || 'keyword'} across TLDs
+
+
+
+ +
+
+
+
+ + setKeywordInput(e.target.value)} + onFocus={() => setKeywordFocused(true)} + onBlur={() => setKeywordFocused(false)} + placeholder="Type a keyword..." + className="flex-1 bg-transparent px-3 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono" + /> + {(keywordInput || selected) && ( + + )} +
-
-
- - 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" - /> -
-
+ {/* Results Grid */} + {availability.length > 0 && ( +
{availability.map((a) => ( -
- -
- +
+
+
+ +
+
+ {a.status.toUpperCase()} + {a.status === 'available' && ( + + Buy + + )}
))} - {availability.length === 0 ?
No results yet.
: null} +
+ )} + + {availability.length === 0 && keyword && !checking && ( +
+ +

Click "Check" to find available domains

+
+ )} +
+
+ + {/* Typo Finder */} +
+
+
+
+ +
+
+
Typo Finder
+
Find available typos of big brands
- {/* Typo check */} -
-
-
-
Typo Check
-
Find typo domains of big brands
+
+
+
+
+ + setBrand(e.target.value)} + onFocus={() => setBrandFocused(true)} + onBlur={() => setBrandFocused(false)} + placeholder="e.g. Shopify, Amazon, Google..." + className="flex-1 bg-transparent px-3 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono" + /> + {brand && ( + + )} +
-
- 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" - /> -
+ + {/* Typo Results Grid */} + {typos.length > 0 && ( +
{typos.map((t) => ( -
+
-
- {t.status.toUpperCase()} +
+ + {t.status.toUpperCase()} + - + +
))} - {typos.length === 0 ?
No typo results yet.
: null}
-
+ )} + + {typos.length === 0 && !typoLoading && ( +
+ Enter a brand name to find available typo domains +
+ )}
) } - diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c9ed575..b22572c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1830,6 +1830,64 @@ class AdminApiClient extends ApiClient { }> }>(`/telemetry/referrals?${query}`) } + + // ============================================================================ + // DROPS - Zone File Analysis (.ch / .li) + // ============================================================================ + + async getDropsStats() { + return this.request<{ + ch: { domain_count: number; last_sync: string | null } + li: { domain_count: number; last_sync: string | null } + weekly_drops: number + }>('/drops/stats') + } + + async getDrops(params?: { + tld?: 'ch' | 'li' + days?: number + min_length?: number + max_length?: number + exclude_numeric?: boolean + exclude_hyphen?: boolean + keyword?: string + limit?: number + offset?: number + }) { + const query = new URLSearchParams() + if (params?.tld) query.set('tld', params.tld) + if (params?.days) query.set('days', params.days.toString()) + if (params?.min_length) query.set('min_length', params.min_length.toString()) + if (params?.max_length) query.set('max_length', params.max_length.toString()) + if (params?.exclude_numeric) query.set('exclude_numeric', 'true') + if (params?.exclude_hyphen) query.set('exclude_hyphen', 'true') + if (params?.keyword) query.set('keyword', params.keyword) + if (params?.limit) query.set('limit', params.limit.toString()) + if (params?.offset) query.set('offset', params.offset.toString()) + + return this.request<{ + total: number + items: Array<{ + domain: string + tld: string + dropped_date: string + length: number + is_numeric: boolean + has_hyphen: boolean + }> + }>(`/drops?${query}`) + } + + async getDropsTlds() { + return this.request<{ + tlds: Array<{ + tld: string + name: string + flag: string + registry: string + }> + }>('/drops/tlds') + } } // Yield Types