feat: award-winning hunt tabs + .ch/.li zone file drops
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

This commit is contained in:
2025-12-15 21:45:42 +01:00
parent fccd88da46
commit c5abba5d2f
11 changed files with 2005 additions and 502 deletions

View File

@ -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.analyze import router as analyze_router
from app.api.hunt import router as hunt_router from app.api.hunt import router as hunt_router
from app.api.cfo import router as cfo_router from app.api.cfo import router as cfo_router
from app.api.drops import router as drops_router
api_router = APIRouter() 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(analyze_router, prefix="/analyze", tags=["Analyze"])
api_router.include_router(hunt_router, prefix="/hunt", tags=["Hunt"]) api_router.include_router(hunt_router, prefix="/hunt", tags=["Hunt"])
api_router.include_router(cfo_router, prefix="/cfo", tags=["CFO"]) 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 # 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"])

148
backend/app/api/drops.py Normal file
View File

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

View File

@ -16,6 +16,7 @@ from app.models.yield_domain import YieldDomain, YieldTransaction, YieldPayout,
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 from app.models.domain_analysis_cache import DomainAnalysisCache
from app.models.zone_file import ZoneSnapshot, DroppedDomain
__all__ = [ __all__ = [
"User", "User",
@ -51,4 +52,7 @@ __all__ = [
"OpsAlertEvent", "OpsAlertEvent",
# New: Analyze cache # New: Analyze cache
"DomainAnalysisCache", "DomainAnalysisCache",
# New: Zone file drops
"ZoneSnapshot",
"DroppedDomain",
] ]

View File

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

View File

@ -657,6 +657,15 @@ def setup_scheduler():
replace_existing=True, 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( logger.info(
f"Scheduler configured:" f"Scheduler configured:"
f"\n - Scout domain check at {settings.check_hour:02d}:{settings.check_minute:02d} (daily)" 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 - Auction scrape every 2 hours at :30"
f"\n - Expired auction cleanup every 15 minutes" f"\n - Expired auction cleanup every 15 minutes"
f"\n - Sniper alert matching every 30 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}") 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(): async def match_sniper_alerts():
"""Match active sniper alerts against current auctions and notify users.""" """Match active sniper alerts against current auctions and notify users."""
from app.models.sniper_alert import SniperAlert, SniperAlertMatch from app.models.sniper_alert import SniperAlert, SniperAlertMatch

View File

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

View File

@ -2,17 +2,39 @@
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import clsx from 'clsx' 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 { api } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store' import { useAnalyzePanelStore } from '@/lib/analyze-store'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
// ============================================================================
// CONSTANTS
// ============================================================================
const PATTERNS = [ const PATTERNS = [
{ key: 'cvcvc', label: 'CVCVC (5 letters)' }, { key: 'cvcvc', label: 'CVCVC', desc: '5-letter brandables (Zalor, Mivex)' },
{ key: 'cvccv', label: 'CVCCV (5 letters)' }, { key: 'cvccv', label: 'CVCCV', desc: '5-letter variants (Bento, Salvo)' },
{ key: 'human', label: 'Human-like (2 syllables)' }, { key: 'human', label: 'Human', desc: '2-syllable names (Siri, Alexa)' },
] ]
const TLDS = ['com', 'io', 'ai', 'co', 'net', 'org']
// ============================================================================
// HELPERS
// ============================================================================
function parseTlds(input: string): string[] { function parseTlds(input: string): string[] {
return input return input
.split(',') .split(',')
@ -21,30 +43,54 @@ function parseTlds(input: string): string[] {
.slice(0, 10) .slice(0, 10)
} }
// ============================================================================
// COMPONENT
// ============================================================================
export function BrandableForgeTab({ showToast }: { showToast: (message: string, type?: any) => void }) { export function BrandableForgeTab({ showToast }: { showToast: (message: string, type?: any) => void }) {
const openAnalyze = useAnalyzePanelStore((s) => s.open) const openAnalyze = useAnalyzePanelStore((s) => s.open)
const addDomain = useStore((s) => s.addDomain) const addDomain = useStore((s) => s.addDomain)
// Config State
const [pattern, setPattern] = useState('cvcvc') const [pattern, setPattern] = useState('cvcvc')
const [tldsRaw, setTldsRaw] = useState('com') const [selectedTlds, setSelectedTlds] = useState<string[]>(['com'])
const [limit, setLimit] = useState(30) const [limit, setLimit] = useState(30)
const [showConfig, setShowConfig] = useState(false)
// Results State
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [items, setItems] = useState<Array<{ domain: string; status: string }>>([]) const [items, setItems] = useState<Array<{ domain: string; status: string }>>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [tracking, setTracking] = useState<string | null>(null) const [tracking, setTracking] = useState<string | null>(null)
const toggleTld = useCallback((tld: string) => {
setSelectedTlds((prev) =>
prev.includes(tld) ? prev.filter((t) => t !== tld) : [...prev, tld]
)
}, [])
const run = useCallback(async () => { const run = useCallback(async () => {
if (selectedTlds.length === 0) {
showToast('Select at least one TLD', 'error')
return
}
setLoading(true) setLoading(true)
setError(null) setError(null)
try { 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 }))) 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) { } 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([]) setItems([])
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [pattern, tldsRaw, limit]) }, [pattern, selectedTlds, limit, showToast])
const track = useCallback( const track = useCallback(
async (domain: string) => { async (domain: string) => {
@ -63,113 +109,296 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
) )
return ( return (
<div className="border border-white/[0.08] bg-[#020202]"> <div className="space-y-4">
<div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between gap-3"> {/* Header with Generate Button */}
<div> <div className="border border-white/[0.08] bg-[#020202]">
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">Brandable Forge</div> <div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between">
<div className="text-sm font-bold text-white">Generate &amp; verify</div> <div className="flex items-center gap-3">
<div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center">
<Wand2 className="w-4 h-4 text-accent" />
</div>
<div>
<div className="text-sm font-bold text-white">Brandable Forge</div>
<div className="text-[10px] font-mono text-white/40">Generate available brandable names</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowConfig(!showConfig)}
className={clsx(
"w-8 h-8 flex items-center justify-center border transition-colors",
showConfig ? "border-accent/30 bg-accent/10 text-accent" : "border-white/10 text-white/30 hover:text-white hover:bg-white/5"
)}
>
<Settings className="w-4 h-4" />
</button>
<button
onClick={run}
disabled={loading}
className={clsx(
"h-8 px-4 text-xs font-bold uppercase tracking-wider transition-all flex items-center gap-2",
loading ? "bg-white/5 text-white/20" : "bg-accent text-black hover:bg-white"
)}
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
Generate
</button>
</div>
</div>
{/* Pattern Selection */}
<div className="p-3 border-b border-white/[0.08]">
<div className="flex gap-2 flex-wrap">
{PATTERNS.map((p) => (
<button
key={p.key}
onClick={() => setPattern(p.key)}
className={clsx(
"flex-1 min-w-[120px] px-3 py-2 border transition-all text-left",
pattern === p.key
? "border-accent bg-accent/10"
: "border-white/[0.08] hover:border-white/20"
)}
>
<div className={clsx("text-xs font-bold font-mono", pattern === p.key ? "text-accent" : "text-white/60")}>
{p.label}
</div>
<div className="text-[10px] text-white/30 mt-0.5">{p.desc}</div>
</button>
))}
</div>
</div>
{/* TLD Selection */}
<div className="p-3 border-b border-white/[0.08]">
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">TLDs</span>
</div>
<div className="flex gap-2 flex-wrap">
{TLDS.map((tld) => (
<button
key={tld}
onClick={() => toggleTld(tld)}
className={clsx(
"px-3 py-1.5 text-[10px] font-mono uppercase border transition-colors",
selectedTlds.includes(tld)
? "border-accent bg-accent/10 text-accent"
: "border-white/[0.08] text-white/40 hover:text-white/60"
)}
>
.{tld}
</button>
))}
</div>
</div>
{/* Advanced Config (collapsed) */}
{showConfig && (
<div className="p-3 border-b border-white/[0.08] bg-white/[0.01] animate-in fade-in slide-in-from-top-2 duration-200">
<div className="flex items-center gap-4">
<div>
<label className="block text-[10px] font-mono text-white/40 mb-1">Results Count</label>
<input
type="number"
value={limit}
onChange={(e) => 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}
/>
</div>
<div className="flex-1 text-[10px] font-mono text-white/30">
Generate up to {limit} available brandable domains. We check via DNS/RDAP and only return verified available domains.
</div>
</div>
</div>
)}
{/* Stats Bar */}
<div className="px-4 py-2 flex items-center justify-between text-[10px] font-mono text-white/40">
<span>{items.length} domains generated</span>
<span className="flex items-center gap-1">
<Zap className="w-3 h-3" />
All verified available
</span>
</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>
<div className="p-4 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-4"> {/* Error Message */}
<div className="space-y-3"> {error && (
<div> <div className="p-3 border border-red-500/20 bg-red-500/5 text-xs font-mono text-red-400">
<label className="block text-[10px] font-mono text-white/40 mb-1">Pattern</label> {error}
<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>
)}
<div> {/* Results Grid */}
{error ? <div className="text-[12px] font-mono text-red-300 mb-2">{error}</div> : null} {items.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> <div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
{items.map((i) => ( {/* Desktop Header */}
<div key={i.domain} className="border border-white/10 bg-white/[0.02] px-3 py-2 flex items-center justify-between gap-2"> <div className="hidden lg:grid grid-cols-[1fr_100px_140px] gap-4 px-3 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
<button <span>Domain</span>
onClick={() => openAnalyze(i.domain)} <span className="text-center">Status</span>
className="text-[12px] font-mono text-white/70 hover:text-accent truncate text-left" <span className="text-right">Actions</span>
title="Analyze" </div>
>
{i.domain} {items.map((i) => (
</button> <div key={i.domain} className="bg-[#020202] hover:bg-white/[0.02] transition-all">
<div className="flex items-center gap-2 shrink-0"> {/* Mobile Row */}
<div className="lg:hidden p-3">
<div className="flex items-center justify-between gap-3 mb-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center shrink-0">
<Sparkles className="w-4 h-4 text-accent" />
</div>
<div className="min-w-0 flex-1">
<button
onClick={() => openAnalyze(i.domain)}
className="text-sm font-bold text-white font-mono truncate text-left"
>
{i.domain}
</button>
<span className="text-[10px] font-mono text-accent bg-accent/10 px-1.5 py-0.5">
AVAILABLE
</span>
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => track(i.domain)}
disabled={tracking === i.domain}
className="flex-1 py-2 text-[10px] font-bold uppercase tracking-wider border border-white/[0.08] text-white/40 flex items-center justify-center gap-1.5 transition-all hover:text-white hover:bg-white/5"
>
{tracking === i.domain ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />}
Track
</button>
<button
onClick={() => openAnalyze(i.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"
>
<Shield className="w-3.5 h-3.5" />
</button>
<a <a
href={`https://www.namecheap.com/domains/registration/results/?domain=${encodeURIComponent(i.domain)}`} href={`https://www.namecheap.com/domains/registration/results/?domain=${encodeURIComponent(i.domain)}`}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" className="flex-1 py-2 bg-accent text-black text-[10px] font-bold uppercase tracking-wider flex items-center justify-center gap-1.5"
title="Register (Namecheap)"
> >
<ExternalLink className="w-3.5 h-3.5" /> Register
<ExternalLink className="w-3 h-3" />
</a> </a>
</div>
</div>
{/* Desktop Row */}
<div className="hidden lg:grid grid-cols-[1fr_100px_140px] gap-4 items-center p-3 group">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center shrink-0">
<Sparkles className="w-4 h-4 text-accent" />
</div>
<button
onClick={() => openAnalyze(i.domain)}
className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left"
>
{i.domain}
</button>
</div>
<div className="text-center">
<span className="text-[10px] font-mono font-bold text-accent bg-accent/10 px-2 py-0.5">
AVAILABLE
</span>
</div>
<div className="flex items-center justify-end gap-2 opacity-50 group-hover:opacity-100 transition-opacity">
<button <button
onClick={() => track(i.domain)} onClick={() => track(i.domain)}
disabled={tracking === 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" className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-colors"
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" />} {tracking === i.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
</button> </button>
<button <button
onClick={() => openAnalyze(i.domain)} 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" 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 transition-colors"
title="Analyze"
> >
<Shield className="w-3.5 h-3.5" /> <Shield className="w-3.5 h-3.5" />
</button> </button>
<a
href={`https://www.namecheap.com/domains/registration/results/?domain=${encodeURIComponent(i.domain)}`}
target="_blank"
rel="noopener noreferrer"
className="h-7 px-3 bg-accent text-black text-xs font-bold flex items-center gap-1 hover:bg-white transition-colors"
>
Register
<ExternalLink className="w-3 h-3" />
</a>
</div> </div>
</div> </div>
))} </div>
{!loading && items.length === 0 ? <div className="text-[12px] font-mono text-white/30">No results yet.</div> : null} ))}
</div>
)}
{/* Empty State */}
{items.length === 0 && !loading && (
<div className="text-center py-16 border border-dashed border-white/[0.08]">
<Wand2 className="w-8 h-8 text-white/10 mx-auto mb-3" />
<p className="text-white/40 text-sm font-mono">No domains generated yet</p>
<p className="text-white/25 text-xs font-mono mt-1">Click "Generate" to create brandable names</p>
</div>
)}
{/* Info Cards */}
{items.length === 0 && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
<div className="border border-white/[0.08] bg-[#020202] p-4 group hover:border-accent/20 transition-colors">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center">
<Wand2 className="w-5 h-5 text-accent" />
</div>
<div>
<div className="text-sm font-bold text-white">AI-Powered</div>
<div className="text-[10px] font-mono text-white/40">Smart generation</div>
</div>
</div>
<p className="text-xs text-white/30 leading-relaxed">
Generate pronounceable, memorable names following proven patterns like CVCVC.
</p>
</div>
<div className="border border-white/[0.08] bg-[#020202] p-4 group hover:border-white/20 transition-colors">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center">
<Zap className="w-5 h-5 text-white/40" />
</div>
<div>
<div className="text-sm font-bold text-white">Verified</div>
<div className="text-[10px] font-mono text-white/40">Real availability</div>
</div>
</div>
<p className="text-xs text-white/30 leading-relaxed">
Every domain is checked via DNS/RDAP. Only verified available domains are shown.
</p>
</div>
<div className="border border-white/[0.08] bg-[#020202] p-4 group hover:border-white/20 transition-colors">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center">
<ExternalLink className="w-5 h-5 text-white/40" />
</div>
<div>
<div className="text-sm font-bold text-white">Instant Register</div>
<div className="text-[10px] font-mono text-white/40">One-click buy</div>
</div>
</div>
<p className="text-xs text-white/30 leading-relaxed">
Found a perfect name? Register instantly via Namecheap with one click.
</p>
</div> </div>
</div> </div>
</div> )}
</div> </div>
) )
} }

View File

@ -1,12 +1,53 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { Download, Clock, Globe, Loader2, Search, Filter, ChevronRight, AlertCircle } from 'lucide-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' import clsx from 'clsx'
// ============================================================================ // ============================================================================
// DROPS TAB - Zone File Analysis // TYPES
// Placeholder component - User will set up the data source // ============================================================================
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 { interface DropsTabProps {
@ -14,25 +55,221 @@ interface DropsTabProps {
} }
export function DropsTab({ showToast }: DropsTabProps) { export function DropsTab({ showToast }: DropsTabProps) {
const [selectedTld, setSelectedTld] = useState<string>('com') const openAnalyze = useAnalyzePanelStore((s) => s.open)
const [loading, setLoading] = useState(false) const addDomain = useStore((s) => s.addDomain)
// Data State
const [items, setItems] = useState<DroppedDomain[]>([])
const [stats, setStats] = useState<ZoneStats | null>(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 [searchQuery, setSearchQuery] = useState('')
const [searchFocused, setSearchFocused] = useState(false) const [searchFocused, setSearchFocused] = useState(false)
const [days, setDays] = useState(7)
const [minLength, setMinLength] = useState<number | undefined>(undefined)
const [maxLength, setMaxLength] = useState<number | undefined>(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 // Pagination
const handleRefresh = async () => { const [page, setPage] = useState(1)
setLoading(true) const ITEMS_PER_PAGE = 50
// Simulated delay - replace with actual API call
await new Promise(resolve => setTimeout(resolve, 1500)) // Sorting
setLoading(false) const [sortField, setSortField] = useState<'domain' | 'length' | 'date'>('date')
showToast('Zone file data will be available once configured', 'info') const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
// Tracking
const [tracking, setTracking] = useState<string | null>(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 (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
</div>
)
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Header Controls */} {/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="border border-white/[0.08] bg-[#020202] p-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">🇨🇭</span>
<span className="text-[10px] font-mono text-white/40 uppercase">.ch Zone</span>
</div>
<div className="text-xl font-bold text-white font-mono">
{stats.ch.domain_count.toLocaleString()}
</div>
<div className="text-[10px] font-mono text-white/30">
{stats.ch.last_sync ? `Last: ${new Date(stats.ch.last_sync).toLocaleDateString()}` : 'Not synced'}
</div>
</div>
<div className="border border-white/[0.08] bg-[#020202] p-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">🇱🇮</span>
<span className="text-[10px] font-mono text-white/40 uppercase">.li Zone</span>
</div>
<div className="text-xl font-bold text-white font-mono">
{stats.li.domain_count.toLocaleString()}
</div>
<div className="text-[10px] font-mono text-white/30">
{stats.li.last_sync ? `Last: ${new Date(stats.li.last_sync).toLocaleDateString()}` : 'Not synced'}
</div>
</div>
<div className="border border-accent/20 bg-accent/[0.03] p-3">
<div className="flex items-center gap-2 mb-1">
<Zap className="w-4 h-4 text-accent" />
<span className="text-[10px] font-mono text-accent/60 uppercase">Weekly Drops</span>
</div>
<div className="text-xl font-bold text-accent font-mono">
{stats.weekly_drops.toLocaleString()}
</div>
<div className="text-[10px] font-mono text-white/30">
Last 7 days
</div>
</div>
<div className="border border-white/[0.08] bg-[#020202] p-3">
<div className="flex items-center gap-2 mb-1">
<Clock className="w-4 h-4 text-white/40" />
<span className="text-[10px] font-mono text-white/40 uppercase">Data Source</span>
</div>
<div className="text-sm font-bold text-white">Switch.ch</div>
<div className="text-[10px] font-mono text-white/30">
Official zone files
</div>
</div>
</div>
)}
{/* Search & Filters */}
<div className="space-y-3"> <div className="space-y-3">
{/* Search */} {/* Search */}
<div className={clsx( <div className={clsx(
@ -47,121 +284,350 @@ export function DropsTab({ showToast }: DropsTabProps) {
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => setSearchFocused(true)} onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)} 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" className="flex-1 bg-transparent px-3 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono"
/> />
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="p-3 text-white/30 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
)}
<button onClick={handleRefresh} disabled={refreshing} className="p-3 text-white/30 hover:text-white transition-colors">
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
</button>
</div> </div>
</div> </div>
{/* TLD Selector */} {/* TLD Quick Filter */}
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2">
{tlds.map((tld) => ( <button
<button onClick={() => setSelectedTld(null)}
key={tld} className={clsx(
onClick={() => setSelectedTld(tld)} "px-4 py-2 text-xs font-mono uppercase border transition-colors",
className={clsx( selectedTld === null ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
"px-3 py-1.5 text-[10px] font-mono uppercase border transition-colors", )}
selectedTld === tld >
? "border-accent bg-accent/10 text-accent" All
: "border-white/[0.08] text-white/40 hover:text-white/60" </button>
)} <button
> onClick={() => setSelectedTld('ch')}
.{tld} className={clsx(
</button> "px-4 py-2 text-xs font-mono uppercase border transition-colors flex items-center gap-2",
))} selectedTld === 'ch' ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
)}
>
🇨🇭 .ch
</button>
<button
onClick={() => setSelectedTld('li')}
className={clsx(
"px-4 py-2 text-xs font-mono uppercase border transition-colors flex items-center gap-2",
selectedTld === 'li' ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
)}
>
🇱🇮 .li
</button>
</div> </div>
</div>
{/* Info Card - Setup Required */} {/* Filter Toggle */}
<div className="border border-amber-500/20 bg-amber-500/[0.05] p-4"> <button
<div className="flex items-start gap-3"> onClick={() => setFiltersOpen(!filtersOpen)}
<AlertCircle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" /> className={clsx(
<div className="flex-1"> "flex items-center justify-between w-full py-2 px-3 border transition-colors",
<h3 className="text-sm font-bold text-amber-400 mb-1">Zone File Integration</h3> filtersOpen ? "border-accent/30 bg-accent/[0.05]" : "border-white/[0.08] bg-white/[0.02]"
<p className="text-xs text-white/50 leading-relaxed mb-3"> )}
This feature analyzes zone files to find freshly dropped domains. >
Configure your zone file data source to see real-time drops. <div className="flex items-center gap-2">
</p> <Filter className="w-4 h-4 text-white/40" />
<span className="text-xs font-mono text-white/60">Advanced Filters</span>
{activeFiltersCount > 0 && (
<span className="px-1.5 py-0.5 text-[9px] font-bold bg-accent text-black">{activeFiltersCount}</span>
)}
</div>
<ChevronRight className={clsx("w-4 h-4 text-white/30 transition-transform", filtersOpen && "rotate-90")} />
</button>
{/* Filters Panel */}
{filtersOpen && (
<div className="p-3 border border-white/[0.08] bg-white/[0.02] space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
{/* Time Range */}
<div>
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider mb-2">Time Range</div>
<div className="flex gap-2">
{[
{ value: 1, label: '24h' },
{ value: 7, label: '7 days' },
{ value: 14, label: '14 days' },
{ value: 30, label: '30 days' },
].map((item) => (
<button
key={item.value}
onClick={() => setDays(item.value)}
className={clsx(
"flex-1 py-1.5 text-[10px] font-mono border transition-colors",
days === item.value ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
)}
>
{item.label}
</button>
))}
</div>
</div>
{/* Length Filter */}
<div>
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider mb-2">Domain Length</div>
<div className="flex gap-2 items-center">
<input
type="number"
value={minLength || ''}
onChange={(e) => 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}
/>
<span className="text-white/20"></span>
<input
type="number"
value={maxLength || ''}
onChange={(e) => 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}
/>
<span className="text-[10px] font-mono text-white/30">characters</span>
</div>
</div>
{/* Quality Filters */}
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={handleRefresh} onClick={() => setExcludeNumeric(!excludeNumeric)}
disabled={loading} className={clsx(
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider border border-amber-500/30 text-amber-400 hover:bg-amber-500/10 transition-colors flex items-center gap-1.5" "flex-1 flex items-center justify-between py-2 px-3 border transition-colors",
excludeNumeric ? "border-white/20 bg-white/[0.05]" : "border-white/[0.08]"
)}
> >
{loading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />} <div className="flex items-center gap-2">
Refresh Data <Hash className="w-4 h-4 text-white/40" />
<span className="text-xs font-mono text-white/60">Exclude numeric</span>
</div>
<div className={clsx("w-4 h-4 border flex items-center justify-center", excludeNumeric ? "border-accent bg-accent" : "border-white/30")}>
{excludeNumeric && <span className="text-black text-[10px] font-bold"></span>}
</div>
</button>
<button
onClick={() => setExcludeHyphen(!excludeHyphen)}
className={clsx(
"flex-1 flex items-center justify-between py-2 px-3 border transition-colors",
excludeHyphen ? "border-white/20 bg-white/[0.05]" : "border-white/[0.08]"
)}
>
<div className="flex items-center gap-2">
<Ban className="w-4 h-4 text-white/40" />
<span className="text-xs font-mono text-white/60">Exclude hyphens</span>
</div>
<div className={clsx("w-4 h-4 border flex items-center justify-center", excludeHyphen ? "border-accent bg-accent" : "border-white/30")}>
{excludeHyphen && <span className="text-black text-[10px] font-bold"></span>}
</div>
</button> </button>
</div> </div>
</div> </div>
</div> )}
</div> </div>
{/* Feature Preview Cards */} {/* Stats Bar */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3"> <div className="flex items-center justify-between text-[10px] font-mono text-white/40">
<div className="border border-white/[0.08] bg-white/[0.02] p-4"> <span>{total.toLocaleString()} dropped domains found</span>
<div className="flex items-center gap-3 mb-3"> <span>Page {page}/{Math.max(1, totalPages)}</span>
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center">
<Clock className="w-5 h-5 text-accent" />
</div>
<div>
<div className="text-sm font-bold text-white">Real-time Drops</div>
<div className="text-[10px] font-mono text-white/40">Updated every hour</div>
</div>
</div>
<p className="text-xs text-white/30">
Monitor domains as they expire and become available for registration.
</p>
</div>
<div className="border border-white/[0.08] bg-white/[0.02] p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center">
<Filter className="w-5 h-5 text-white/40" />
</div>
<div>
<div className="text-sm font-bold text-white">Smart Filters</div>
<div className="text-[10px] font-mono text-white/40">Length, keywords, patterns</div>
</div>
</div>
<p className="text-xs text-white/30">
Filter by domain length, keywords, character patterns, and more.
</p>
</div>
<div className="border border-white/[0.08] bg-white/[0.02] p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center">
<Globe className="w-5 h-5 text-white/40" />
</div>
<div>
<div className="text-sm font-bold text-white">Multi-TLD</div>
<div className="text-[10px] font-mono text-white/40">All major TLDs</div>
</div>
</div>
<p className="text-xs text-white/30">
Track drops across .com, .net, .org, .io, .ai, and more.
</p>
</div>
</div> </div>
{/* Placeholder Table */} {/* Results */}
<div className="border border-white/[0.08] bg-white/[0.02]"> {sortedItems.length === 0 ? (
<div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between"> <div className="text-center py-16 border border-dashed border-white/[0.08]">
<Globe className="w-8 h-8 text-white/10 mx-auto mb-3" />
<p className="text-white/40 text-sm font-mono">No dropped domains found</p>
<p className="text-white/25 text-xs font-mono mt-1">
{stats?.weekly_drops === 0
? 'Zone file sync may be pending'
: 'Try adjusting your filters'}
</p>
</div>
) : (
<>
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
{/* Desktop Table Header */}
<div className="hidden lg:grid grid-cols-[1fr_80px_100px_120px] gap-4 px-3 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
<button onClick={() => handleSort('domain')} className="flex items-center gap-1 hover:text-white/60 text-left">
Domain
{sortField === 'domain' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
</button>
<button onClick={() => handleSort('length')} className="flex items-center gap-1 justify-center hover:text-white/60">
Length
{sortField === 'length' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
</button>
<button onClick={() => handleSort('date')} className="flex items-center gap-1 justify-center hover:text-white/60">
Dropped
{sortField === 'date' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
</button>
<div className="text-right">Actions</div>
</div>
{sortedItems.map((item) => (
<div key={item.domain} className="bg-[#020202] hover:bg-white/[0.02] transition-all">
{/* Mobile Row */}
<div className="lg:hidden p-3">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 flex items-center justify-center border bg-white/[0.02] border-white/[0.06] shrink-0">
<span className="text-sm">{item.tld === 'ch' ? '🇨🇭' : '🇱🇮'}</span>
</div>
<div className="min-w-0 flex-1">
<button onClick={() => openAnalyze(item.domain)} className="text-sm font-bold text-white font-mono truncate text-left">
{item.domain}
</button>
<div className="flex items-center gap-2 text-[10px] font-mono text-white/30">
<span>{item.length} chars</span>
<span className="text-white/10">|</span>
<span>{formatDate(item.dropped_date)}</span>
</div>
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => track(item.domain)}
disabled={tracking === item.domain}
className="flex-1 py-2 text-[10px] font-bold uppercase tracking-wider border border-white/[0.08] text-white/40 flex items-center justify-center gap-1.5 transition-all"
>
{tracking === item.domain ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />}
Track
</button>
<button onClick={() => openAnalyze(item.domain)} className="w-10 py-2 border border-white/[0.08] text-white/50 flex items-center justify-center">
<Shield className="w-3.5 h-3.5" />
</button>
<a
href={`https://www.nic.${item.tld}/whois/?domain=${item.domain.split('.')[0]}`}
target="_blank"
rel="noopener noreferrer"
className="flex-1 py-2 bg-accent text-black text-[10px] font-bold uppercase tracking-wider flex items-center justify-center gap-1.5"
>
Register
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
{/* Desktop Row */}
<div className="hidden lg:grid grid-cols-[1fr_80px_100px_120px] gap-4 items-center p-3 group">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 flex items-center justify-center border bg-white/[0.02] border-white/[0.06] shrink-0">
<span className="text-sm">{item.tld === 'ch' ? '🇨🇭' : '🇱🇮'}</span>
</div>
<button
onClick={() => openAnalyze(item.domain)}
className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left"
>
{item.domain}
</button>
</div>
<div className="text-center">
<span className={clsx(
"text-xs font-mono font-bold px-2 py-0.5",
item.length <= 5 ? "text-accent bg-accent/10" : item.length <= 8 ? "text-amber-400 bg-amber-400/10" : "text-white/40 bg-white/5"
)}>
{item.length}
</span>
</div>
<div className="text-center">
<span className="text-xs font-mono text-white/50">{formatDate(item.dropped_date)}</span>
</div>
<div className="flex items-center justify-end gap-2 opacity-50 group-hover:opacity-100 transition-opacity">
<button
onClick={() => track(item.domain)}
disabled={tracking === item.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 transition-colors"
>
{tracking === item.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
</button>
<button
onClick={() => openAnalyze(item.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 transition-colors"
>
<Shield className="w-3.5 h-3.5" />
</button>
<a
href={`https://www.nic.${item.tld}/whois/?domain=${item.domain.split('.')[0]}`}
target="_blank"
rel="noopener noreferrer"
className="h-7 px-3 bg-accent text-black text-xs font-bold flex items-center gap-1 hover:bg-white transition-colors"
>
Get
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
</div>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4">
<div className="text-[10px] text-white/40 font-mono uppercase tracking-wider">
Page {page}/{totalPages}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handlePageChange(page - 1)}
disabled={page === 1}
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-xs text-white/50 font-mono px-2">
{page}/{totalPages}
</span>
<button
onClick={() => handlePageChange(page + 1)}
disabled={page === totalPages}
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</>
)}
{/* Info Box */}
<div className="border border-white/[0.08] bg-white/[0.02] p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center shrink-0">
<Download className="w-5 h-5 text-accent" />
</div>
<div> <div>
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">Freshly Dropped</div> <h3 className="text-sm font-bold text-white mb-1">Zone File Analysis</h3>
<div className="text-sm font-bold text-white">.{selectedTld.toUpperCase()} Domains</div> <p className="text-xs text-white/40 leading-relaxed">
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.
</p>
</div> </div>
<div className="text-[10px] font-mono text-white/30">
Awaiting data...
</div>
</div>
<div className="p-8 text-center">
<Globe className="w-12 h-12 text-white/10 mx-auto mb-4" />
<p className="text-white/40 text-sm font-mono mb-2">No zone file data available</p>
<p className="text-white/25 text-xs font-mono">
Configure zone file integration to see dropped domains
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -16,6 +16,11 @@ import {
Globe, Globe,
Calendar, Calendar,
Building, Building,
Clock,
RefreshCw,
Sparkles,
ExternalLink,
History,
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
@ -56,7 +61,7 @@ export function SearchTab({ showToast }: SearchTabProps) {
const stored = localStorage.getItem('pounce_recent_searches') const stored = localStorage.getItem('pounce_recent_searches')
if (stored) { if (stored) {
try { try {
setRecentSearches(JSON.parse(stored).slice(0, 5)) setRecentSearches(JSON.parse(stored).slice(0, 8))
} catch { } catch {
// Ignore invalid JSON // Ignore invalid JSON
} }
@ -67,7 +72,7 @@ export function SearchTab({ showToast }: SearchTabProps) {
const saveToRecent = useCallback((domain: string) => { const saveToRecent = useCallback((domain: string) => {
setRecentSearches((prev) => { setRecentSearches((prev) => {
const filtered = prev.filter((d) => d !== domain) 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)) localStorage.setItem('pounce_recent_searches', JSON.stringify(updated))
return updated return updated
}) })
@ -126,166 +131,163 @@ export function SearchTab({ showToast }: SearchTabProps) {
}, []) }, [])
return ( return (
<div className="space-y-6"> <div className="space-y-4">
{/* Search Card */} {/* Search Input */}
<div className="relative"> <div className={clsx(
<div className="absolute -inset-6 bg-gradient-to-tr from-accent/5 via-transparent to-accent/5 blur-3xl opacity-50 pointer-events-none hidden lg:block" /> "relative border-2 transition-all duration-200",
searchFocused ? "border-accent/50 bg-accent/[0.02]" : "border-white/[0.08] bg-white/[0.02]"
<div className="relative bg-[#0A0A0A] border border-white/[0.08] overflow-hidden"> )}>
<div className="flex items-center justify-between px-4 py-2 border-b border-white/[0.06] bg-black/40"> <div className="flex items-center">
<span className="text-[10px] font-mono text-white/40 flex items-center gap-2"> <Search className={clsx("w-5 h-5 ml-4 transition-colors", searchFocused ? "text-accent" : "text-white/30")} />
<Search className="w-3 h-3 text-accent" /> <input
Domain Search ref={searchInputRef}
</span> type="text"
<div className="flex gap-1.5"> value={searchQuery}
<div className="w-2 h-2 bg-white/10" /> onChange={(e) => setSearchQuery(e.target.value)}
<div className="w-2 h-2 bg-white/10" /> onFocus={() => setSearchFocused(true)}
<div className="w-2 h-2 bg-accent/50" /> onBlur={() => setSearchFocused(false)}
</div> onKeyDown={(e) => e.key === 'Enter' && handleSearch(searchQuery)}
</div> 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"
<div className="p-4 lg:p-6"> />
<div className={clsx( {searchQuery && (
"relative border-2 transition-all duration-200", <button
searchFocused ? "border-accent/50 bg-accent/[0.02]" : "border-white/[0.08] bg-white/[0.02]" onClick={() => { setSearchQuery(''); setSearchResult(null) }}
)}> className="p-4 text-white/30 hover:text-white transition-colors"
<div className="flex items-center"> >
<Search className={clsx("w-5 h-5 ml-4 transition-colors", searchFocused ? "text-accent" : "text-white/30")} /> <X className="w-5 h-5" />
<input </button>
ref={searchInputRef} )}
type="text" <button
value={searchQuery} onClick={() => handleSearch(searchQuery)}
onChange={(e) => setSearchQuery(e.target.value)} disabled={!searchQuery.trim()}
onFocus={() => setSearchFocused(true)} className={clsx(
onBlur={() => setSearchFocused(false)} "h-full px-4 py-4 text-sm font-bold uppercase tracking-wider transition-all",
onKeyDown={(e) => e.key === 'Enter' && handleSearch(searchQuery)} searchQuery.trim() ? "bg-accent text-black hover:bg-white" : "bg-white/5 text-white/20"
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 && (
<button
onClick={() => { setSearchQuery(''); setSearchResult(null) }}
className="p-4 text-white/30 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
)}
</div>
</div>
{!searchResult && (
<p className="text-[10px] text-white/30 mt-3 font-mono">
Enter a domain to check availability, WHOIS data, and registration options
</p>
)} )}
</div> >
Check
</button>
</div> </div>
</div> </div>
{/* Stats Bar */}
<div className="flex items-center justify-between text-[10px] font-mono text-white/40">
<span>Enter a domain to check availability via RDAP/WHOIS</span>
<span className="flex items-center gap-1">
<Sparkles className="w-3 h-3" />
Instant check
</span>
</div>
{/* Search Result */} {/* Search Result */}
{searchResult && ( {searchResult && (
<div className="animate-in fade-in slide-in-from-bottom-2 duration-200"> <div className="animate-in fade-in slide-in-from-bottom-2 duration-200">
{searchResult.loading ? ( {searchResult.loading ? (
<div className="flex items-center justify-center gap-3 py-12 bg-white/[0.02] border border-white/[0.06]"> <div className="flex items-center justify-center gap-3 py-12 border border-white/[0.08] bg-white/[0.02]">
<Loader2 className="w-6 h-6 animate-spin text-accent" /> <Loader2 className="w-6 h-6 animate-spin text-accent" />
<span className="text-sm text-white/50 font-mono">Checking availability...</span> <span className="text-sm text-white/50 font-mono">Checking availability...</span>
</div> </div>
) : ( ) : (
<div className={clsx( <div className={clsx(
"border-2 overflow-hidden", "border-2 overflow-hidden bg-[#020202]",
searchResult.is_available ? "border-accent/40 bg-accent/[0.03]" : "border-white/[0.08] bg-white/[0.02]" searchResult.is_available ? "border-accent/40" : "border-white/[0.08]"
)}> )}>
{/* Result Header */} {/* Result Row */}
<div className={clsx( <div className="p-4">
"px-4 py-4 flex items-center justify-between", <div className="flex items-center justify-between gap-4">
searchResult.is_available ? "bg-accent/[0.05]" : "bg-white/[0.02]" <div className="flex items-center gap-4 min-w-0 flex-1">
)}> {/* Status Icon */}
<div className="flex items-center gap-3"> <div className={clsx(
{searchResult.is_available ? ( "w-12 h-12 flex items-center justify-center border shrink-0",
<div className="w-12 h-12 bg-accent/20 border border-accent/30 flex items-center justify-center"> searchResult.is_available ? "bg-accent/10 border-accent/30" : "bg-white/[0.02] border-white/[0.08]"
<CheckCircle2 className="w-6 h-6 text-accent" /> )}>
{searchResult.is_available ? (
<CheckCircle2 className="w-6 h-6 text-accent" />
) : (
<XCircle className="w-6 h-6 text-white/30" />
)}
</div> </div>
) : (
<div className="w-12 h-12 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center"> {/* Domain Info */}
<XCircle className="w-6 h-6 text-white/30" /> <div className="min-w-0 flex-1">
</div> <div className="text-lg font-bold text-white font-mono truncate">{searchResult.domain}</div>
)} <div className="flex items-center gap-3 text-[10px] font-mono text-white/40 mt-1">
<div> <span className={clsx(
<div className="text-lg font-bold text-white font-mono">{searchResult.domain}</div> "px-2 py-0.5 uppercase font-bold",
<div className="text-[10px] text-white/40 font-mono uppercase tracking-wider"> searchResult.is_available ? "bg-accent/20 text-accent" : "bg-white/10 text-white/50"
{searchResult.is_available ? 'Available for registration' : 'Already registered'} )}>
{searchResult.is_available ? 'Available' : 'Taken'}
</span>
{searchResult.registrar && (
<>
<span className="text-white/10">|</span>
<span className="flex items-center gap-1">
<Building className="w-3 h-3" />
{searchResult.registrar}
</span>
</>
)}
{searchResult.expiration_date && (
<>
<span className="text-white/10">|</span>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
Expires {new Date(searchResult.expiration_date).toLocaleDateString()}
</span>
</>
)}
</div>
</div> </div>
</div> </div>
{/* Actions */}
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => openAnalyze(searchResult.domain)}
className="w-9 h-9 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/10 transition-colors"
title="Deep Analysis"
>
<Shield className="w-4 h-4" />
</button>
<button
onClick={handleAddToWatchlist}
disabled={addingToWatchlist}
className={clsx(
"w-9 h-9 flex items-center justify-center border transition-colors",
searchResult.is_available
? "border-white/10 text-white/30 hover:text-white hover:bg-white/5"
: "border-accent/30 text-accent hover:bg-accent/10"
)}
title={searchResult.is_available ? "Track" : "Monitor for drops"}
>
{addingToWatchlist ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
</button>
{searchResult.is_available ? (
<a
href={`https://www.namecheap.com/domains/registration/results/?domain=${searchResult.domain}`}
target="_blank"
rel="noopener noreferrer"
className="h-9 px-4 bg-accent text-black text-xs font-bold uppercase tracking-wider flex items-center gap-2 hover:bg-white transition-colors"
>
Register
<ArrowRight className="w-3.5 h-3.5" />
</a>
) : (
<a
href={`https://www.expireddomains.net/domain-name-search/?q=${searchResult.domain.split('.')[0]}`}
target="_blank"
rel="noopener noreferrer"
className="h-9 px-4 bg-white/10 text-white text-xs font-bold uppercase tracking-wider flex items-center gap-2 hover:bg-white/20 transition-colors"
>
Find Similar
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
</div> </div>
<span className={clsx(
"text-xs font-bold px-3 py-1.5 uppercase tracking-wider",
searchResult.is_available ? "bg-accent text-black" : "bg-white/10 text-white/50"
)}>
{searchResult.is_available ? 'Available' : 'Taken'}
</span>
</div>
{/* WHOIS Info (if taken) */}
{!searchResult.is_available && (searchResult.registrar || searchResult.expiration_date) && (
<div className="px-4 py-3 border-t border-white/[0.06] grid grid-cols-2 gap-4">
{searchResult.registrar && (
<div className="flex items-center gap-2">
<Building className="w-4 h-4 text-white/30" />
<div>
<div className="text-[9px] font-mono text-white/30 uppercase">Registrar</div>
<div className="text-xs text-white/60 font-mono">{searchResult.registrar}</div>
</div>
</div>
)}
{searchResult.expiration_date && (
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-white/30" />
<div>
<div className="text-[9px] font-mono text-white/30 uppercase">Expires</div>
<div className="text-xs text-white/60 font-mono">
{new Date(searchResult.expiration_date).toLocaleDateString()}
</div>
</div>
</div>
)}
</div>
)}
{/* Actions */}
<div className="p-4 flex gap-3 border-t border-white/[0.06]">
<button
onClick={() => openAnalyze(searchResult.domain)}
className="flex-1 py-3 text-sm font-bold flex items-center justify-center gap-2 transition-all active:scale-[0.98] border border-white/20 text-white hover:bg-white/5"
>
<Shield className="w-4 h-4" />
Analyze
</button>
<button
onClick={handleAddToWatchlist}
disabled={addingToWatchlist}
className={clsx(
"flex-1 py-3 text-sm font-bold flex items-center justify-center gap-2 transition-all active:scale-[0.98]",
searchResult.is_available
? "border border-white/20 text-white hover:bg-white/5"
: "border-2 border-accent text-accent hover:bg-accent/10"
)}
>
{addingToWatchlist ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
{searchResult.is_available ? 'Track' : 'Monitor'}
</button>
{searchResult.is_available && (
<a
href={`https://www.namecheap.com/domains/registration/results/?domain=${searchResult.domain}`}
target="_blank"
rel="noopener noreferrer"
className="flex-1 py-3 bg-accent text-black text-sm font-bold flex items-center justify-center gap-2 hover:bg-white active:scale-[0.98] transition-all"
>
Register
<ArrowRight className="w-4 h-4" />
</a>
)}
</div> </div>
</div> </div>
)} )}
@ -294,24 +296,37 @@ export function SearchTab({ showToast }: SearchTabProps) {
{/* Recent Searches */} {/* Recent Searches */}
{!searchResult && recentSearches.length > 0 && ( {!searchResult && recentSearches.length > 0 && (
<div> <div className="border border-white/[0.08] bg-white/[0.02]">
<div className="flex items-center gap-2 mb-3"> <div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between">
<div className="w-1 h-4 bg-white/20" /> <div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-white/30 uppercase tracking-[0.2em]">Recent Searches</span> <History className="w-4 h-4 text-white/30" />
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Recent Searches</span>
</div>
<button
onClick={() => {
setRecentSearches([])
localStorage.removeItem('pounce_recent_searches')
}}
className="text-[10px] font-mono text-white/30 hover:text-white transition-colors"
>
Clear
</button>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="p-3">
{recentSearches.map((domain) => ( <div className="flex flex-wrap gap-2">
<button {recentSearches.map((domain) => (
key={domain} <button
onClick={() => { key={domain}
setSearchQuery(domain) onClick={() => {
handleSearch(domain) setSearchQuery(domain)
}} handleSearch(domain)
className="px-3 py-1.5 border border-white/[0.08] text-xs font-mono text-white/50 hover:text-white hover:border-white/20 transition-colors" }}
> className="group px-3 py-2 border border-white/[0.08] bg-[#020202] hover:border-accent/30 hover:bg-accent/[0.03] transition-all"
{domain} >
</button> <span className="text-xs font-mono text-white/60 group-hover:text-accent transition-colors">{domain}</span>
))} </button>
))}
</div>
</div> </div>
</div> </div>
)} )}
@ -319,27 +334,48 @@ export function SearchTab({ showToast }: SearchTabProps) {
{/* Quick Tips */} {/* Quick Tips */}
{!searchResult && ( {!searchResult && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
<div className="border border-white/[0.08] bg-white/[0.02] p-4"> <div className="border border-white/[0.08] bg-[#020202] p-4 group hover:border-accent/20 transition-colors">
<Globe className="w-5 h-5 text-accent mb-2" /> <div className="flex items-center gap-3 mb-3">
<div className="text-sm font-bold text-white mb-1">Instant Check</div> <div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center group-hover:bg-accent/20 transition-colors">
<p className="text-xs text-white/30"> <Globe className="w-5 h-5 text-accent" />
Check any domain's availability in real-time using RDAP/WHOIS. </div>
<div>
<div className="text-sm font-bold text-white">Instant Check</div>
<div className="text-[10px] font-mono text-white/40">RDAP/WHOIS</div>
</div>
</div>
<p className="text-xs text-white/30 leading-relaxed">
Check any domain's availability in real-time using RDAP/WHOIS protocols.
</p> </p>
</div> </div>
<div className="border border-white/[0.08] bg-white/[0.02] p-4"> <div className="border border-white/[0.08] bg-[#020202] p-4 group hover:border-white/20 transition-colors">
<Eye className="w-5 h-5 text-white/40 mb-2" /> <div className="flex items-center gap-3 mb-3">
<div className="text-sm font-bold text-white mb-1">Track Changes</div> <div className="w-10 h-10 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center group-hover:bg-white/[0.05] transition-colors">
<p className="text-xs text-white/30"> <Eye className="w-5 h-5 text-white/40" />
Add domains to your watchlist to monitor when they become available. </div>
<div>
<div className="text-sm font-bold text-white">Monitor Drops</div>
<div className="text-[10px] font-mono text-white/40">Watchlist</div>
</div>
</div>
<p className="text-xs text-white/30 leading-relaxed">
Add taken domains to your watchlist. Get alerted when they become available.
</p> </p>
</div> </div>
<div className="border border-white/[0.08] bg-white/[0.02] p-4"> <div className="border border-white/[0.08] bg-[#020202] p-4 group hover:border-white/20 transition-colors">
<Shield className="w-5 h-5 text-white/40 mb-2" /> <div className="flex items-center gap-3 mb-3">
<div className="text-sm font-bold text-white mb-1">Deep Analysis</div> <div className="w-10 h-10 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center group-hover:bg-white/[0.05] transition-colors">
<p className="text-xs text-white/30"> <Shield className="w-5 h-5 text-white/40" />
Run full analysis to check backlinks, SEO metrics, and domain history. </div>
<div>
<div className="text-sm font-bold text-white">Deep Analysis</div>
<div className="text-[10px] font-mono text-white/40">Full report</div>
</div>
</div>
<p className="text-xs text-white/30 leading-relaxed">
Run full analysis: backlinks, SEO metrics, history, trademark checks.
</p> </p>
</div> </div>
</div> </div>

View File

@ -2,31 +2,62 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import clsx from 'clsx' 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 { api } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store' import { useAnalyzePanelStore } from '@/lib/analyze-store'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
// ============================================================================
// HELPERS
// ============================================================================
function normalizeKeyword(s: string) { function normalizeKeyword(s: string) {
return s.trim().replace(/\s+/g, ' ') return s.trim().replace(/\s+/g, ' ')
} }
// ============================================================================
// COMPONENT
// ============================================================================
export function TrendSurferTab({ showToast }: { showToast: (message: string, type?: any) => void }) { export function TrendSurferTab({ showToast }: { showToast: (message: string, type?: any) => void }) {
const openAnalyze = useAnalyzePanelStore((s) => s.open) const openAnalyze = useAnalyzePanelStore((s) => s.open)
const addDomain = useStore((s) => s.addDomain) const addDomain = useStore((s) => s.addDomain)
// Trends State
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [geo, setGeo] = useState('US') const [geo, setGeo] = useState('US')
const [trends, setTrends] = useState<Array<{ title: string; approx_traffic?: string | null; link?: string | null }>>([]) const [trends, setTrends] = useState<Array<{ title: string; approx_traffic?: string | null; link?: string | null }>>([])
const [selected, setSelected] = useState<string>('') const [selected, setSelected] = useState<string>('')
const [refreshing, setRefreshing] = useState(false)
// Keyword Check State
const [keywordInput, setKeywordInput] = useState('') const [keywordInput, setKeywordInput] = useState('')
const [keywordFocused, setKeywordFocused] = useState(false)
const [availability, setAvailability] = useState<Array<{ domain: string; status: string; is_available: boolean | null }>>([]) const [availability, setAvailability] = useState<Array<{ domain: string; status: string; is_available: boolean | null }>>([])
const [checking, setChecking] = useState(false) const [checking, setChecking] = useState(false)
// Typo Check State
const [brand, setBrand] = useState('') const [brand, setBrand] = useState('')
const [brandFocused, setBrandFocused] = useState(false)
const [typos, setTypos] = useState<Array<{ domain: string; status: string }>>([]) const [typos, setTypos] = useState<Array<{ domain: string; status: string }>>([])
const [typoLoading, setTypoLoading] = useState(false) const [typoLoading, setTypoLoading] = useState(false)
// Tracking State
const [tracking, setTracking] = useState<string | null>(null) const [tracking, setTracking] = useState<string | null>(null)
const track = useCallback( const track = useCallback(
@ -45,7 +76,8 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
[addDomain, showToast, tracking] [addDomain, showToast, tracking]
) )
const loadTrends = useCallback(async () => { const loadTrends = useCallback(async (isRefresh = false) => {
if (isRefresh) setRefreshing(true)
setError(null) setError(null)
try { try {
const res = await api.getHuntTrends(geo) const res = await api.getHuntTrends(geo)
@ -56,6 +88,8 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
setError(msg) setError(msg)
showToast(msg, 'error') showToast(msg, 'error')
setTrends([]) setTrends([])
} finally {
if (isRefresh) setRefreshing(false)
} }
}, [geo, selected, showToast]) }, [geo, selected, showToast])
@ -72,9 +106,7 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
} }
} }
run() run()
return () => { return () => { cancelled = true }
cancelled = true
}
}, [loadTrends]) }, [loadTrends])
const keyword = useMemo(() => normalizeKeyword(keywordInput || selected || ''), [keywordInput, selected]) const keyword = useMemo(() => normalizeKeyword(keywordInput || selected || ''), [keywordInput, selected])
@ -113,202 +145,290 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-accent animate-spin" /> <Loader2 className="w-6 h-6 text-accent animate-spin" />
</div> </div>
) )
} }
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-3"> <div className="space-y-4">
{/* Trends Header */}
<div className="border border-white/[0.08] bg-[#020202]"> <div className="border border-white/[0.08] bg-[#020202]">
<div className="px-4 py-3 border-b border-white/[0.08]"> <div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between">
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">Top Google Trends (24h)</div> <div className="flex items-center gap-3">
<div className="mt-2 flex items-center gap-2"> <div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center">
<input <TrendingUp className="w-4 h-4 text-accent" />
</div>
<div>
<div className="text-sm font-bold text-white">Google Trends (24h)</div>
<div className="text-[10px] font-mono text-white/40">Real-time trending topics</div>
</div>
</div>
<div className="flex items-center gap-2">
<select
value={geo} value={geo}
onChange={(e) => setGeo(e.target.value.toUpperCase().slice(0, 2))} onChange={(e) => setGeo(e.target.value)}
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" className="bg-white/[0.02] border border-white/10 px-2 py-1.5 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 <option value="US">🇺🇸 US</option>
<option value="CH">🇨🇭 CH</option>
<option value="DE">🇩🇪 DE</option>
<option value="GB">🇬🇧 UK</option>
<option value="FR">🇫🇷 FR</option>
</select>
<button
onClick={() => loadTrends(true)}
disabled={refreshing}
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-colors"
>
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
</button> </button>
</div> </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>
{error ? (
<div className="p-4 text-xs font-mono text-red-400 bg-red-500/5">{error}</div>
) : (
<div className="p-3 flex flex-wrap gap-2 max-h-[200px] overflow-y-auto">
{trends.slice(0, 20).map((t) => {
const active = selected === t.title
return (
<button
key={t.title}
onClick={() => {
setSelected(t.title)
setKeywordInput('')
setAvailability([])
}}
className={clsx(
'px-3 py-2 border text-xs font-mono transition-all',
active
? 'border-accent bg-accent/10 text-accent'
: 'border-white/[0.08] text-white/60 hover:border-white/20 hover:text-white'
)}
>
<span className="truncate max-w-[150px] block">{t.title}</span>
{t.approx_traffic && (
<span className="text-[9px] text-white/30 block mt-0.5">{t.approx_traffic}</span>
)}
</button>
)
})}
</div>
)}
</div> </div>
<div className="space-y-3"> {/* Keyword Availability Check */}
{/* Keyword availability */} <div className="border border-white/[0.08] bg-[#020202]">
<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 className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center">
<Globe className="w-4 h-4 text-white/40" />
</div>
<div> <div>
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">Keyword Availability</div> <div className="text-sm font-bold text-white">Domain Availability</div>
<div className="text-sm font-bold text-white">Find available domains</div> <div className="text-[10px] font-mono text-white/40">Check {keyword || 'keyword'} across TLDs</div>
</div>
</div>
</div>
<div className="p-4">
<div className="flex gap-2 mb-4">
<div className={clsx(
"flex-1 relative border transition-all",
keywordFocused ? "border-accent/50 bg-accent/[0.02]" : "border-white/[0.08] bg-white/[0.02]"
)}>
<div className="flex items-center">
<Search className={clsx("w-4 h-4 ml-3 transition-colors", keywordFocused ? "text-accent" : "text-white/30")} />
<input
value={keywordInput || selected}
onChange={(e) => 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) && (
<button
onClick={() => { setKeywordInput(''); setSelected(''); setAvailability([]) }}
className="p-3 text-white/30 hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div> </div>
<button <button
onClick={runCheck} onClick={runCheck}
disabled={!keyword || checking} disabled={!keyword || checking}
className={clsx( className={clsx(
'px-3 py-2 border text-[10px] font-bold uppercase tracking-wider font-mono transition-colors', "px-4 py-3 text-xs font-bold uppercase tracking-wider transition-all",
!keyword || checking !keyword || checking
? 'border-white/10 text-white/20' ? "bg-white/5 text-white/20"
: 'border-accent/30 bg-accent/10 text-accent hover:bg-accent/15' : "bg-accent text-black hover:bg-white"
)} )}
> >
{checking ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Check'} {checking ? <Loader2 className="w-4 h-4 animate-spin" /> : "Check"}
</button> </button>
</div> </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"> {/* Results Grid */}
{availability.length > 0 && (
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
{availability.map((a) => ( {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"> <div key={a.domain} className="bg-[#020202] hover:bg-white/[0.02] transition-colors p-3 flex items-center justify-between">
<button <div className="flex items-center gap-3 min-w-0 flex-1">
onClick={() => openAnalyze(a.domain)} <div className={clsx(
className="text-[12px] font-mono text-white/70 hover:text-accent truncate text-left" "w-2 h-2 rounded-full shrink-0",
title="Analyze" a.status === 'available' ? "bg-accent" : "bg-white/20"
> )} />
{a.domain} <button
</button> onClick={() => openAnalyze(a.domain)}
<div className="flex items-center gap-2"> className="text-sm font-mono text-white/70 hover:text-accent truncate text-left"
<span className={clsx('text-[10px] font-mono', a.status === 'available' ? 'text-accent' : 'text-white/30')}> >
{a.domain}
</button>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={clsx(
"text-[10px] font-mono font-bold px-2 py-0.5",
a.status === 'available' ? "text-accent bg-accent/10" : "text-white/30 bg-white/5"
)}>
{a.status.toUpperCase()} {a.status.toUpperCase()}
</span> </span>
<button <button
onClick={() => track(a.domain)} onClick={() => track(a.domain)}
disabled={tracking === 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" className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-colors"
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" />} {tracking === a.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
</button> </button>
<button <button
onClick={() => openAnalyze(a.domain)} 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" 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 transition-colors"
title="Analyze"
> >
<Shield className="w-3.5 h-3.5" /> <Shield className="w-3.5 h-3.5" />
</button> </button>
{a.status === 'available' && (
<a
href={`https://www.namecheap.com/domains/registration/results/?domain=${a.domain}`}
target="_blank"
rel="noopener noreferrer"
className="h-7 px-2 bg-accent text-black text-[10px] font-bold uppercase flex items-center gap-1 hover:bg-white transition-colors"
>
Buy
</a>
)}
</div> </div>
</div> </div>
))} ))}
{availability.length === 0 ? <div className="text-[12px] font-mono text-white/30">No results yet.</div> : null} </div>
)}
{availability.length === 0 && keyword && !checking && (
<div className="text-center py-8 border border-dashed border-white/[0.08]">
<Zap className="w-6 h-6 text-white/10 mx-auto mb-2" />
<p className="text-white/30 text-xs font-mono">Click "Check" to find available domains</p>
</div>
)}
</div>
</div>
{/* Typo Finder */}
<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 className="flex items-center gap-3">
<div className="w-8 h-8 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center">
<Sparkles className="w-4 h-4 text-white/40" />
</div>
<div>
<div className="text-sm font-bold text-white">Typo Finder</div>
<div className="text-[10px] font-mono text-white/40">Find available typos of big brands</div>
</div> </div>
</div> </div>
</div> </div>
{/* Typo check */} <div className="p-4">
<div className="border border-white/[0.08] bg-[#020202]"> <div className="flex gap-2 mb-4">
<div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between gap-3"> <div className={clsx(
<div> "flex-1 relative border transition-all",
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">Typo Check</div> brandFocused ? "border-accent/50 bg-accent/[0.02]" : "border-white/[0.08] bg-white/[0.02]"
<div className="text-sm font-bold text-white">Find typo domains of big brands</div> )}>
<div className="flex items-center">
<Sparkles className={clsx("w-4 h-4 ml-3 transition-colors", brandFocused ? "text-accent" : "text-white/30")} />
<input
value={brand}
onChange={(e) => 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 && (
<button onClick={() => { setBrand(''); setTypos([]) }} className="p-3 text-white/30 hover:text-white">
<X className="w-4 h-4" />
</button>
)}
</div>
</div> </div>
<button <button
onClick={runTypos} onClick={runTypos}
disabled={!brand.trim() || typoLoading} disabled={!brand.trim() || typoLoading}
className={clsx( className={clsx(
'px-3 py-2 border text-[10px] font-bold uppercase tracking-wider font-mono transition-colors', "px-4 py-3 text-xs font-bold uppercase tracking-wider transition-all",
!brand.trim() || typoLoading !brand.trim() || typoLoading
? 'border-white/10 text-white/20' ? "bg-white/5 text-white/20"
: 'border-white/10 text-white/60 hover:text-white hover:bg-white/5' : "bg-white/10 text-white hover:bg-white/20"
)} )}
> >
{typoLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />} {typoLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Find"}
</button> </button>
</div> </div>
<div className="p-4">
<input {/* Typo Results Grid */}
value={brand} {typos.length > 0 && (
onChange={(e) => setBrand(e.target.value)} <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
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) => ( {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"> <div key={t.domain} className="border border-white/10 bg-white/[0.02] px-3 py-2 flex items-center justify-between group hover:border-accent/20 transition-colors">
<button <button
onClick={() => openAnalyze(t.domain)} onClick={() => openAnalyze(t.domain)}
className="text-[12px] font-mono text-white/70 hover:text-accent truncate text-left" className="text-xs font-mono text-white/70 group-hover:text-accent truncate text-left transition-colors"
title="Analyze"
> >
{t.domain} {t.domain}
</button> </button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono text-accent">{t.status.toUpperCase()}</span> <span className="text-[9px] font-mono text-accent bg-accent/10 px-1.5 py-0.5">
{t.status.toUpperCase()}
</span>
<button <button
onClick={() => track(t.domain)} onClick={() => track(t.domain)}
disabled={tracking === 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" className="w-6 h-6 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-colors"
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" />} {tracking === t.domain ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />}
</button> </button>
<button <a
onClick={() => openAnalyze(t.domain)} href={`https://www.namecheap.com/domains/registration/results/?domain=${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" target="_blank"
title="Analyze" rel="noopener noreferrer"
className="w-6 h-6 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-colors"
> >
<Shield className="w-3.5 h-3.5" /> <ExternalLink className="w-3 h-3" />
</button> </a>
</div> </div>
</div> </div>
))} ))}
{typos.length === 0 ? <div className="text-[12px] font-mono text-white/30">No typo results yet.</div> : null}
</div> </div>
</div> )}
{typos.length === 0 && !typoLoading && (
<div className="text-xs font-mono text-white/30 text-center py-4">
Enter a brand name to find available typo domains
</div>
)}
</div> </div>
</div> </div>
</div> </div>
) )
} }

View File

@ -1830,6 +1830,64 @@ class AdminApiClient extends ApiClient {
}> }>
}>(`/telemetry/referrals?${query}`) }>(`/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 // Yield Types