Fix server-side API URL construction and improve UX
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

- Fix double /api/v1 bug in buy/blog/discover pages causing 500 errors
- Add auto-load health checks on Portfolio page (like Watchlist)
- Add subscription cancellation UI in Settings with trust-building design
- Remove SMS notifications from Sniper alerts
- Fix Sniper alert matching for drops and auctions
- Improve Trend Surfer and Brandable Forge UI/UX
- Match Portfolio tabs to Hunt page design
- Update Hunt page header style consistency
This commit is contained in:
2025-12-16 14:44:48 +01:00
parent 5b99145fb2
commit 7d68266745
17 changed files with 2371 additions and 690 deletions

View File

@ -185,6 +185,11 @@ def _format_time_remaining(end_time: datetime, now: Optional[datetime] = None) -
def _get_affiliate_url(platform: str, domain: str, auction_url: str) -> str:
"""Get affiliate URL for a platform - links directly to the auction page with affiliate tracking."""
# SEDO SPECIAL CASE: Always use direct Sedo link with partner ID
# This ensures we get affiliate revenue even from scraped data
if platform == "Sedo":
return f"https://sedo.com/search/details/?domain={domain}&partnerid=335830"
# Import here to avoid circular imports
from app.services.hidden_api_scrapers import build_affiliate_url
@ -200,7 +205,6 @@ def _get_affiliate_url(platform: str, domain: str, auction_url: str) -> str:
# Fallback to platform-specific search/listing pages (without affiliate tracking)
platform_urls = {
"GoDaddy": f"https://auctions.godaddy.com/trpItemListing.aspx?domain={domain}",
"Sedo": f"https://sedo.com/search/details/?domain={domain}&partnerid=335830",
"NameJet": f"https://www.namejet.com/Pages/Auctions/BackorderSearch.aspx?q={domain}",
"DropCatch": f"https://www.dropcatch.com/domain/{domain}",
"ExpiredDomains": f"https://www.expireddomains.net/domain-name-search/?q={domain}",
@ -625,6 +629,50 @@ async def trigger_scrape(
raise HTTPException(status_code=500, detail=f"Scrape failed: {str(e)}")
@router.get("/sedo")
async def get_sedo_listings(
keyword: Optional[str] = Query(None, description="Search keyword"),
tld: Optional[str] = Query(None, description="Filter by TLD"),
limit: int = Query(50, le=100),
current_user: Optional[User] = Depends(get_current_user_optional),
):
"""
Get live domain listings from Sedo marketplace.
Returns real-time data from Sedo API with affiliate tracking.
All links include Pounce partner ID for commission tracking.
"""
from app.services.sedo_api import sedo_client
if not sedo_client.is_configured:
return {
"items": [],
"error": "Sedo API not configured",
"source": "sedo"
}
try:
listings = await sedo_client.get_listings_for_display(
keyword=keyword,
tld=tld,
page_size=limit
)
return {
"items": listings,
"count": len(listings),
"source": "sedo",
"affiliate_note": "All links include Pounce partner ID for commission tracking"
}
except Exception as e:
logger.error(f"Sedo API error: {e}")
return {
"items": [],
"error": str(e),
"source": "sedo"
}
@router.get("/opportunities")
async def get_smart_opportunities(
current_user: User = Depends(get_current_user),
@ -1004,7 +1052,7 @@ async def get_market_feed(
)
built.append({"item": item, "newest_ts": listing.updated_at or listing.created_at or datetime.min})
# External auctions
# External auctions (from DB)
if source in ["all", "external"]:
auction_query = select(DomainAuction).where(and_(*auction_filters))
@ -1063,6 +1111,93 @@ async def get_market_feed(
pounce_score=pounce_score,
)
built.append({"item": item, "newest_ts": auction.updated_at or auction.scraped_at or datetime.min})
# =========================================================================
# LIVE SEDO DATA - Fetch and merge real-time listings from Sedo API
# =========================================================================
try:
from app.services.sedo_api import sedo_client
if sedo_client.is_configured:
# Use search keyword or fall back to popular terms for discovery
sedo_keyword = keyword
if not sedo_keyword:
# Fetch popular domains when no specific search
import random
popular_terms = ["ai", "tech", "crypto", "app", "cloud", "digital", "smart", "pro"]
sedo_keyword = random.choice(popular_terms)
# Fetch live Sedo listings (limit to avoid slow responses)
sedo_listings = await sedo_client.get_listings_for_display(
keyword=sedo_keyword,
tld=tld_clean,
page_size=min(30, limit) # Cap at 30 to avoid slow API calls
)
# Track domains already in results to avoid duplicates
existing_domains = {item["item"].domain.lower() for item in built}
for sedo_item in sedo_listings:
domain = sedo_item.get("domain", "").lower()
# Skip if already have this domain from scraped data
if domain in existing_domains:
continue
# Apply vanity filter for anonymous users
if current_user is None and not _is_premium_domain(domain):
continue
# Apply price filters
price = sedo_item.get("price", 0)
if min_price is not None and price < min_price and price > 0:
continue
if max_price is not None and price > max_price:
continue
domain_tld = sedo_item.get("tld", "")
pounce_score = _calculate_pounce_score_v2(
domain,
domain_tld,
num_bids=0,
age_years=0,
is_pounce=False,
)
if pounce_score < min_score:
continue
# Determine price type
price_type = "bid" if sedo_item.get("is_auction") else (
"negotiable" if price == 0 else "fixed"
)
item = MarketFeedItem(
id=f"sedo-live-{hash(domain) % 1000000}",
domain=domain,
tld=domain_tld,
price=price,
currency="USD",
price_type=price_type,
status="auction" if sedo_item.get("is_auction") else "instant",
source="Sedo",
is_pounce=False,
verified=False,
time_remaining=None,
end_time=None,
num_bids=None,
url=sedo_item.get("url", ""),
is_external=True,
pounce_score=pounce_score,
)
built.append({"item": item, "newest_ts": now})
existing_domains.add(domain)
# Update auction count
auction_total += 1
except Exception as e:
logger.warning(f"Failed to fetch live Sedo data: {e}")
# -----------------------------
# Merge sort (Python) + paginate

View File

@ -869,6 +869,33 @@ async def compare_tld_prices(
}
def get_marketplace_links(tld: str) -> list:
"""Get marketplace links for buying existing domains on this TLD."""
# Sedo partner ID for affiliate tracking
SEDO_PARTNER_ID = "335830"
return [
{
"name": "Sedo",
"description": "World's largest domain marketplace",
"url": f"https://sedo.com/search/?keyword=.{tld}&partnerid={SEDO_PARTNER_ID}",
"type": "marketplace",
},
{
"name": "Afternic",
"description": "GoDaddy's premium marketplace",
"url": f"https://www.afternic.com/search?k=.{tld}",
"type": "marketplace",
},
{
"name": "Dan.com",
"description": "Fast domain transfers",
"url": f"https://dan.com/search?query=.{tld}",
"type": "marketplace",
},
]
@router.get("/{tld}")
async def get_tld_details(
tld: str,
@ -877,6 +904,9 @@ async def get_tld_details(
"""Get complete details for a specific TLD."""
tld_clean = tld.lower().lstrip(".")
# Marketplace links (same for all TLDs)
marketplace_links = get_marketplace_links(tld_clean)
# Try static data first
if tld_clean in TLD_DATA:
data = TLD_DATA[tld_clean]
@ -906,6 +936,7 @@ async def get_tld_details(
},
"registrars": registrars,
"cheapest_registrar": registrars[0]["name"],
"marketplace_links": marketplace_links,
}
# Fall back to database
@ -942,6 +973,7 @@ async def get_tld_details(
},
"registrars": registrars,
"cheapest_registrar": registrars[0]["name"] if registrars else "N/A",
"marketplace_links": marketplace_links,
}

View File

@ -933,11 +933,12 @@ async def sync_czds_zones():
async def match_sniper_alerts():
"""Match active sniper alerts against current auctions and notify users."""
"""Match active sniper alerts against auctions AND drops and notify users."""
from app.models.sniper_alert import SniperAlert, SniperAlertMatch
from app.models.auction import DomainAuction
from app.models.zone_file import DroppedDomain
logger.info("Matching sniper alerts against new auctions...")
logger.info("Matching sniper alerts against auctions and drops...")
try:
async with AsyncSessionLocal() as db:
@ -952,39 +953,65 @@ async def match_sniper_alerts():
return
# Get recent auctions (added in last 2 hours)
cutoff = datetime.utcnow() - timedelta(hours=2)
auction_cutoff = datetime.utcnow() - timedelta(hours=2)
auctions_result = await db.execute(
select(DomainAuction).where(
and_(
DomainAuction.is_active == True,
DomainAuction.scraped_at >= cutoff,
DomainAuction.scraped_at >= auction_cutoff,
)
)
)
auctions = auctions_result.scalars().all()
if not auctions:
logger.info("No recent auctions to match against")
return
# Get recent drops (last 24 hours)
drop_cutoff = datetime.utcnow() - timedelta(hours=24)
drops_result = await db.execute(
select(DroppedDomain).where(DroppedDomain.dropped_date >= drop_cutoff)
)
drops = drops_result.scalars().all()
logger.info(f"Checking {len(alerts)} alerts against {len(auctions)} auctions and {len(drops)} drops")
matches_created = 0
notifications_sent = 0
for alert in alerts:
matching_auctions = []
matching_items = []
# Match against auctions
for auction in auctions:
if _auction_matches_alert(auction, alert):
matching_auctions.append(auction)
matching_items.append({
'domain': auction.domain,
'source': 'auction',
'platform': auction.platform,
'price': auction.current_bid,
'end_time': auction.end_time,
'url': auction.auction_url,
})
if matching_auctions:
for auction in matching_auctions:
# Match against drops
for drop in drops:
if _drop_matches_alert(drop, alert):
full_domain = f"{drop.domain}.{drop.tld}"
matching_items.append({
'domain': full_domain,
'source': 'drop',
'platform': f'.{drop.tld} zone',
'price': 0,
'end_time': None,
'url': f"https://pounce.ch/terminal/hunt?tab=drops&search={drop.domain}",
})
if matching_items:
for item in matching_items:
# Check if this match already exists
existing = await db.execute(
select(SniperAlertMatch).where(
and_(
SniperAlertMatch.alert_id == alert.id,
SniperAlertMatch.domain == auction.domain,
SniperAlertMatch.domain == item['domain'],
)
)
)
@ -994,48 +1021,61 @@ async def match_sniper_alerts():
# Create new match
match = SniperAlertMatch(
alert_id=alert.id,
domain=auction.domain,
platform=auction.platform,
current_bid=auction.current_bid,
end_time=auction.end_time,
auction_url=auction.auction_url,
domain=item['domain'],
platform=item['platform'],
current_bid=item['price'],
end_time=item['end_time'] or datetime.utcnow(),
auction_url=item['url'],
matched_at=datetime.utcnow(),
)
db.add(match)
matches_created += 1
# Update alert stats
alert.matches_count = (alert.matches_count or 0) + 1
alert.last_matched_at = datetime.utcnow()
# Update alert last_triggered
alert.last_triggered = datetime.utcnow()
# Send notification if enabled
if alert.notify_email:
# Send notification if enabled (batch notification)
if alert.notify_email and matching_items:
try:
user_result = await db.execute(
select(User).where(User.id == alert.user_id)
)
user = user_result.scalar_one_or_none()
if user and email_service.is_enabled:
# Send email with matching domains
domains_list = ", ".join([a.domain for a in matching_auctions[:5]])
if user and email_service.is_configured():
auction_matches = [m for m in matching_items if m['source'] == 'auction']
drop_matches = [m for m in matching_items if m['source'] == 'drop']
# Build HTML content
html_parts = [f'<h2>Your Sniper Alert "{alert.name}" matched!</h2>']
if auction_matches:
html_parts.append(f'<h3>🎯 {len(auction_matches)} Auction Match{"es" if len(auction_matches) > 1 else ""}</h3><ul>')
for m in auction_matches[:10]:
html_parts.append(f'<li><strong>{m["domain"]}</strong> - ${m["price"]:.0f} on {m["platform"]}</li>')
html_parts.append('</ul>')
if drop_matches:
html_parts.append(f'<h3>🔥 {len(drop_matches)} Fresh Drop{"s" if len(drop_matches) > 1 else ""}</h3><ul>')
for m in drop_matches[:10]:
html_parts.append(f'<li><strong>{m["domain"]}</strong> - Just dropped!</li>')
html_parts.append('</ul>')
html_parts.append('<p><a href="https://pounce.ch/terminal/sniper">View all matches in Pounce</a></p>')
await email_service.send_email(
to_email=user.email,
subject=f"🎯 Sniper Alert: {len(matching_auctions)} matching domains found!",
html_content=f"""
<h2>Your Sniper Alert "{alert.name}" matched!</h2>
<p>We found {len(matching_auctions)} domains matching your criteria:</p>
<ul>
{"".join(f"<li><strong>{a.domain}</strong> - ${a.current_bid:.0f} on {a.platform}</li>" for a in matching_auctions[:10])}
</ul>
<p><a href="https://pounce.ch/command/alerts">View all matches in your Command Center</a></p>
"""
subject=f"🎯 Sniper Alert: {len(matching_items)} matching domains found!",
html_content=''.join(html_parts),
)
notifications_sent += 1
alert.notifications_sent = (alert.notifications_sent or 0) + 1
except Exception as e:
logger.error(f"Failed to send sniper alert notification: {e}")
await db.commit()
logger.info(f"Sniper alert matching complete: {matches_created} matches created, {notifications_sent} notifications sent")
logger.info(f"Sniper alert matching complete: {matches_created} matches, {notifications_sent} notifications")
except Exception as e:
logger.exception(f"Sniper alert matching failed: {e}")
@ -1045,9 +1085,16 @@ def _auction_matches_alert(auction: "DomainAuction", alert: "SniperAlert") -> bo
"""Check if an auction matches the criteria of a sniper alert."""
domain_name = auction.domain.rsplit('.', 1)[0] if '.' in auction.domain else auction.domain
# Check keyword filter
if alert.keyword:
if alert.keyword.lower() not in domain_name.lower():
# Check keyword filter (must contain any of the keywords)
if alert.keywords:
required = [k.strip().lower() for k in alert.keywords.split(',')]
if not any(kw in domain_name.lower() for kw in required):
return False
# Check exclude keywords
if alert.exclude_keywords:
excluded = [k.strip().lower() for k in alert.exclude_keywords.split(',')]
if any(kw in domain_name.lower() for kw in excluded):
return False
# Check TLD filter
@ -1056,6 +1103,12 @@ def _auction_matches_alert(auction: "DomainAuction", alert: "SniperAlert") -> bo
if auction.tld.lower() not in allowed_tlds:
return False
# Check platform filter
if alert.platforms:
allowed_platforms = [p.strip().lower() for p in alert.platforms.split(',')]
if auction.platform.lower() not in allowed_platforms:
return False
# Check length filters
if alert.min_length and len(domain_name) < alert.min_length:
return False
@ -1068,17 +1121,68 @@ def _auction_matches_alert(auction: "DomainAuction", alert: "SniperAlert") -> bo
if alert.max_price and auction.current_bid > alert.max_price:
return False
# Check exclusion filters
if alert.exclude_numbers:
# Check bids filter (low competition)
if alert.max_bids and auction.num_bids and auction.num_bids > alert.max_bids:
return False
# Check no_numbers filter
if alert.no_numbers:
if any(c.isdigit() for c in domain_name):
return False
if alert.exclude_hyphens:
# Check no_hyphens filter
if alert.no_hyphens:
if '-' in domain_name:
return False
# Check exclude_chars
if alert.exclude_chars:
excluded = set(alert.exclude_chars.lower())
excluded = set(c.strip().lower() for c in alert.exclude_chars.split(','))
if any(c in excluded for c in domain_name.lower()):
return False
return True
def _drop_matches_alert(drop, alert: "SniperAlert") -> bool:
"""Check if a dropped domain matches the criteria of a sniper alert."""
domain_name = drop.domain # Already just the name without TLD
# Check keyword filter
if alert.keywords:
required = [k.strip().lower() for k in alert.keywords.split(',')]
if not any(kw in domain_name.lower() for kw in required):
return False
# Check exclude keywords
if alert.exclude_keywords:
excluded = [k.strip().lower() for k in alert.exclude_keywords.split(',')]
if any(kw in domain_name.lower() for kw in excluded):
return False
# Check TLD filter
if alert.tlds:
allowed_tlds = [t.strip().lower() for t in alert.tlds.split(',')]
if drop.tld.lower() not in allowed_tlds:
return False
# Check length filters
if alert.min_length and len(domain_name) < alert.min_length:
return False
if alert.max_length and len(domain_name) > alert.max_length:
return False
# Check no_numbers filter (use drop.is_numeric)
if alert.no_numbers and drop.is_numeric:
return False
# Check no_hyphens filter (use drop.has_hyphen)
if alert.no_hyphens and drop.has_hyphen:
return False
# Check exclude_chars
if alert.exclude_chars:
excluded = set(c.strip().lower() for c in alert.exclude_chars.split(','))
if any(c in excluded for c in domain_name.lower()):
return False

View File

@ -140,10 +140,41 @@ class SedoAPIClient:
"""Parse XML response from Sedo API."""
try:
root = ElementTree.fromstring(xml_text)
# Check for error response
if root.tag == "fault" or root.find(".//faultcode") is not None:
fault_code = root.findtext(".//faultcode") or root.findtext("faultcode")
fault_string = root.findtext(".//faultstring") or root.findtext("faultstring")
return {"error": True, "faultcode": fault_code, "faultstring": fault_string}
# Parse SEDOSEARCH response (domain listings)
if root.tag == "SEDOSEARCH":
items = []
for item in root.findall("item"):
domain_data = {}
for child in item:
# Get the text content, handle type attribute
value = child.text
type_attr = child.get("type", "")
# Convert types
if "double" in type_attr or "int" in type_attr:
try:
value = float(value) if value else 0
except:
pass
domain_data[child.tag] = value
items.append(domain_data)
return {"items": items, "count": len(items)}
# Generic XML to dict fallback
return self._xml_to_dict(root)
except Exception as e:
logger.warning(f"Failed to parse XML: {e}")
return {"raw": xml_text}
return {"raw": xml_text, "error": str(e)}
def _xml_to_dict(self, element) -> Dict[str, Any]:
"""Convert XML element to dictionary."""
@ -171,20 +202,18 @@ class SedoAPIClient:
"""
Search for domains listed on Sedo marketplace.
Returns domains for sale (not auctions).
Returns domains for sale (XML parsed to dict).
"""
params = {
"output_method": "json", # Request JSON response
}
params = {}
if keyword:
params["keyword"] = keyword
if tld:
params["tld"] = tld.lstrip(".")
if min_price is not None:
params["minprice"] = min_price
params["minprice"] = int(min_price)
if max_price is not None:
params["maxprice"] = max_price
params["maxprice"] = int(max_price)
if page:
params["page"] = page
if page_size:
@ -202,11 +231,11 @@ class SedoAPIClient:
) -> Dict[str, Any]:
"""
Search for active domain auctions on Sedo.
Note: Sedo API doesn't have a dedicated auction filter.
We filter by type='A' (auction) in post-processing.
"""
params = {
"output_method": "json",
"auction": "true", # Only auctions
}
params = {}
if keyword:
params["keyword"] = keyword
@ -217,7 +246,72 @@ class SedoAPIClient:
if page_size:
params["pagesize"] = min(page_size, 100)
return await self._request("DomainSearch", params)
result = await self._request("DomainSearch", params)
# Filter to only show auctions (type='A')
if "items" in result:
result["items"] = [
item for item in result["items"]
if item.get("type") == "A"
]
result["count"] = len(result["items"])
return result
async def get_listings_for_display(
self,
keyword: Optional[str] = None,
tld: Optional[str] = None,
page_size: int = 50,
) -> List[Dict[str, Any]]:
"""
Get Sedo listings formatted for display in Pounce.
Returns a list of domains with affiliate URLs.
"""
result = await self.search_domains(
keyword=keyword,
tld=tld,
page_size=page_size
)
if "error" in result or "items" not in result:
logger.warning(f"Sedo API error: {result}")
return []
listings = []
for item in result.get("items", []):
domain = item.get("domain", "")
if not domain:
continue
# Get price (Sedo returns 0 for "Make Offer")
price = item.get("price", 0)
if isinstance(price, str):
try:
price = float(price)
except:
price = 0
# Use the URL from Sedo (includes partner ID and tracking)
url = item.get("url", f"https://sedo.com/search/details/?domain={domain}&partnerid={self.partner_id}")
# Determine listing type
listing_type = item.get("type", "D") # D=Direct, A=Auction
is_auction = listing_type == "A"
listings.append({
"domain": domain,
"tld": domain.rsplit(".", 1)[1] if "." in domain else "",
"price": price,
"price_type": "bid" if is_auction else ("make_offer" if price == 0 else "fixed"),
"is_auction": is_auction,
"platform": "Sedo",
"url": url,
"rank": item.get("rank", 0),
})
return listings
async def get_domain_details(self, domain: str) -> Dict[str, Any]:
"""Get detailed information about a specific domain."""

View File

@ -0,0 +1,59 @@
#!/bin/bash
# Setup cron job for automated zone file synchronization
# Run this script on the server to install the daily sync job
set -e
echo "🔧 Setting up Pounce Zone Sync Cron Job"
echo "========================================"
# Create log directory
mkdir -p /home/user/logs
touch /home/user/logs/zone_sync.log
# Create the cron wrapper script
cat > /home/user/pounce/backend/scripts/run_zone_sync.sh << 'EOF'
#!/bin/bash
# Wrapper script for zone sync with proper environment
cd /home/user/pounce/backend
source venv/bin/activate
# Run sync with timeout (max 2 hours)
timeout 7200 python scripts/sync_all_zones.py >> /home/user/logs/zone_sync.log 2>&1
# Rotate log if too big (keep last 10MB)
if [ -f /home/user/logs/zone_sync.log ]; then
size=$(stat -f%z /home/user/logs/zone_sync.log 2>/dev/null || stat -c%s /home/user/logs/zone_sync.log 2>/dev/null)
if [ "$size" -gt 10485760 ]; then
tail -c 5242880 /home/user/logs/zone_sync.log > /home/user/logs/zone_sync.log.tmp
mv /home/user/logs/zone_sync.log.tmp /home/user/logs/zone_sync.log
fi
fi
EOF
chmod +x /home/user/pounce/backend/scripts/run_zone_sync.sh
# Add cron job (runs daily at 06:00 UTC - after most registry updates)
# Remove existing pounce zone sync jobs first
crontab -l 2>/dev/null | grep -v "run_zone_sync.sh" > /tmp/crontab.tmp || true
# Add new job
echo "# Pounce Zone File Sync - Daily at 06:00 UTC" >> /tmp/crontab.tmp
echo "0 6 * * * /home/user/pounce/backend/scripts/run_zone_sync.sh" >> /tmp/crontab.tmp
# Install crontab
crontab /tmp/crontab.tmp
rm /tmp/crontab.tmp
echo ""
echo "✅ Cron job installed!"
echo ""
echo "Schedule: Daily at 06:00 UTC"
echo "Log file: /home/user/logs/zone_sync.log"
echo ""
echo "Current crontab:"
crontab -l
echo ""
echo "To run manually: /home/user/pounce/backend/scripts/run_zone_sync.sh"
echo "To view logs: tail -f /home/user/logs/zone_sync.log"

View File

@ -0,0 +1,594 @@
#!/usr/bin/env python3
"""
Pounce Zone File Sync - Daily Automated Zone File Synchronization
This script:
1. Downloads zone files from ICANN CZDS (app, dev, info, online, org, xyz)
2. Downloads zone files from Switch.ch via AXFR (.ch, .li)
3. Compares with yesterday's data to detect drops
4. Stores drops in the database for the Drops tab
5. Cleans up all temporary files and compresses domain lists
STORAGE STRATEGY (Ultra-Efficient):
- Raw zone files: DELETED immediately after parsing
- Domain lists: Stored COMPRESSED (.gz) - ~80% size reduction
- Only keeps current snapshot (no history)
- Drops stored in DB for 48h only
Run daily via cron at 06:00 UTC (after most registries update)
"""
import asyncio
import gzip
import logging
import subprocess
import sys
import shutil
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, Set
# Add parent to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
# Configuration
CZDS_DIR = Path("/home/user/pounce_czds")
SWITCH_DIR = Path("/home/user/pounce_switch")
LOG_FILE = Path("/home/user/logs/zone_sync.log")
# Storage efficiency: compress domain lists
COMPRESS_DOMAIN_LISTS = True
# CZDS TLDs we have access to
CZDS_TLDS = ["app", "dev", "info", "online", "org", "xyz"]
# Switch.ch AXFR config
SWITCH_CONFIG = {
"ch": {
"server": "zonedata.switch.ch",
"key_name": "tsig-zonedata-ch-public-21-01.",
"key_secret": "stZwEGApYumtXkh73qMLPqfbIDozWKZLkqRvcjKSpRnsor6A6MxixRL6C2HeSVBQNfMW4wer+qjS0ZSfiWiJ3Q=="
},
"li": {
"server": "zonedata.switch.ch",
"key_name": "tsig-zonedata-li-public-21-01.",
"key_secret": "t8GgeCn+fhPaj+cRy/lakQPb6M45xz/NZwmcp4iqbBxKFCCH0/k3xNGe6sf3ObmoaKDBedge/La4cpPfLqtFkw=="
}
}
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.StreamHandler(),
logging.FileHandler(LOG_FILE) if LOG_FILE.parent.exists() else logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
class ZoneSyncResult:
"""Result of a zone sync operation"""
def __init__(self, tld: str):
self.tld = tld
self.success = False
self.domain_count = 0
self.drops_count = 0
self.error: Optional[str] = None
self.duration_seconds = 0
async def get_db_session():
"""Create async database session"""
from app.config import settings
engine = create_async_engine(settings.database_url.replace("sqlite://", "sqlite+aiosqlite://"))
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
return async_session()
def download_czds_zone(tld: str) -> Optional[Path]:
"""Download a single CZDS zone file using pyCZDS"""
try:
from pyczds.client import CZDSClient
# Read credentials from .env
env_file = Path(__file__).parent.parent / ".env"
if not env_file.exists():
env_file = Path("/home/user/pounce/backend/.env")
env_content = env_file.read_text()
username = password = None
for line in env_content.splitlines():
if line.startswith("CZDS_USERNAME="):
username = line.split("=", 1)[1].strip()
elif line.startswith("CZDS_PASSWORD="):
password = line.split("=", 1)[1].strip()
if not username or not password:
logger.error(f"CZDS credentials not found in .env")
return None
client = CZDSClient(username, password)
urls = client.get_zonefiles_list()
# Find URL for this TLD
target_url = None
for url in urls:
if f"{tld}.zone" in url or f"/{tld}." in url:
target_url = url
break
if not target_url:
logger.warning(f"No access to .{tld} zone file")
return None
logger.info(f"Downloading .{tld} from CZDS...")
result = client.get_zonefile(target_url, download_dir=str(CZDS_DIR))
# Find the downloaded file
gz_file = CZDS_DIR / f"{tld}.txt.gz"
if gz_file.exists():
return gz_file
# Try alternative naming
for f in CZDS_DIR.glob(f"*{tld}*.gz"):
return f
return None
except Exception as e:
logger.error(f"CZDS download failed for .{tld}: {e}")
return None
def download_switch_zone(tld: str) -> Optional[Path]:
"""Download zone file from Switch.ch via AXFR"""
config = SWITCH_CONFIG.get(tld)
if not config:
return None
try:
output_file = SWITCH_DIR / f"{tld}_zone.txt"
cmd = [
"dig", "@" + config["server"],
f"{tld}.", "AXFR",
"-y", f"hmac-sha512:{config['key_name']}:{config['key_secret']}"
]
logger.info(f"Downloading .{tld} via AXFR from Switch.ch...")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
if result.returncode != 0:
logger.error(f"AXFR failed for .{tld}: {result.stderr}")
return None
output_file.write_text(result.stdout)
return output_file
except subprocess.TimeoutExpired:
logger.error(f"AXFR timeout for .{tld}")
return None
except Exception as e:
logger.error(f"AXFR failed for .{tld}: {e}")
return None
def parse_czds_zone(gz_file: Path, tld: str) -> set:
"""Parse a gzipped CZDS zone file and extract unique root domains"""
domains = set()
try:
with gzip.open(gz_file, 'rt', encoding='utf-8', errors='ignore') as f:
for line in f:
if line.startswith(';') or not line.strip():
continue
parts = line.split()
if len(parts) >= 4:
name = parts[0].rstrip('.')
if name.lower().endswith(f'.{tld}'):
domain_name = name[:-(len(tld) + 1)]
# Only root domains (no subdomains)
if domain_name and '.' not in domain_name:
domains.add(domain_name.lower())
return domains
except Exception as e:
logger.error(f"Failed to parse .{tld} zone file: {e}")
return set()
def parse_switch_zone(zone_file: Path, tld: str) -> set:
"""Parse Switch.ch AXFR output and extract unique root domains"""
domains = set()
try:
content = zone_file.read_text()
for line in content.splitlines():
if line.startswith(';') or not line.strip():
continue
parts = line.split()
if len(parts) >= 4:
name = parts[0].rstrip('.')
# Skip the TLD itself
if name.lower() == tld:
continue
if name.lower().endswith(f'.{tld}'):
domain_name = name[:-(len(tld) + 1)]
# Only root domains
if domain_name and '.' not in domain_name:
domains.add(domain_name.lower())
return domains
except Exception as e:
logger.error(f"Failed to parse .{tld} zone file: {e}")
return set()
def save_domains(tld: str, domains: Set[str], directory: Path) -> Path:
"""Save domain list to compressed file for storage efficiency"""
if COMPRESS_DOMAIN_LISTS:
out_file = directory / f"{tld}_domains.txt.gz"
# Remove old uncompressed file if exists
old_file = directory / f"{tld}_domains.txt"
if old_file.exists():
old_file.unlink()
# Write compressed
with gzip.open(out_file, 'wt', encoding='utf-8', compresslevel=9) as f:
f.write('\n'.join(sorted(domains)))
return out_file
else:
out_file = directory / f"{tld}_domains.txt"
out_file.write_text('\n'.join(sorted(domains)))
return out_file
def load_previous_domains(tld: str, directory: Path) -> Set[str]:
"""Load previous day's domain list (compressed or uncompressed)"""
# Try compressed first
gz_file = directory / f"{tld}_domains.txt.gz"
if gz_file.exists():
try:
with gzip.open(gz_file, 'rt', encoding='utf-8') as f:
return set(f.read().splitlines())
except Exception as e:
logger.warning(f"Failed to read compressed domains for .{tld}: {e}")
return set()
# Fallback to uncompressed
txt_file = directory / f"{tld}_domains.txt"
if txt_file.exists():
try:
return set(txt_file.read_text().splitlines())
except Exception:
return set()
return set()
def detect_drops(tld: str, today_domains: set, yesterday_domains: set) -> set:
"""Detect domains that were dropped (present yesterday, missing today)"""
if not yesterday_domains:
logger.info(f".{tld}: No previous data for comparison (first run)")
return set()
drops = yesterday_domains - today_domains
return drops
async def store_drops_in_db(drops: list[tuple[str, str]], session: AsyncSession):
"""Store dropped domains in database using existing DroppedDomain model"""
if not drops:
return 0
now = datetime.utcnow()
# Delete old drops (older than 48 hours)
cutoff = now - timedelta(hours=48)
await session.execute(
text("DELETE FROM dropped_domains WHERE dropped_date < :cutoff"),
{"cutoff": cutoff}
)
# Insert new drops
count = 0
for domain, tld in drops:
try:
# Calculate domain properties
length = len(domain)
is_numeric = domain.isdigit()
has_hyphen = '-' in domain
await session.execute(
text("""
INSERT OR REPLACE INTO dropped_domains
(domain, tld, dropped_date, length, is_numeric, has_hyphen, created_at)
VALUES (:domain, :tld, :dropped_date, :length, :is_numeric, :has_hyphen, :created_at)
"""),
{
"domain": domain,
"tld": tld,
"dropped_date": now,
"length": length,
"is_numeric": is_numeric,
"has_hyphen": has_hyphen,
"created_at": now
}
)
count += 1
except Exception as e:
logger.debug(f"Failed to insert drop {domain}.{tld}: {e}")
await session.commit()
return count
async def sync_czds_tld(tld: str) -> ZoneSyncResult:
"""Sync a single CZDS TLD"""
result = ZoneSyncResult(tld)
start = datetime.now()
try:
# Load previous domains for comparison
yesterday_domains = load_previous_domains(tld, CZDS_DIR)
# Download new zone file
gz_file = download_czds_zone(tld)
if not gz_file:
result.error = "Download failed"
return result
# Parse zone file
logger.info(f"Parsing .{tld} zone file...")
today_domains = parse_czds_zone(gz_file, tld)
if not today_domains:
result.error = "Parsing failed - no domains extracted"
return result
result.domain_count = len(today_domains)
# Detect drops
drops = detect_drops(tld, today_domains, yesterday_domains)
result.drops_count = len(drops)
# Save current domains for tomorrow's comparison
save_domains(tld, today_domains, CZDS_DIR)
# Cleanup gz file
if gz_file.exists():
gz_file.unlink()
# Update last download marker
marker = CZDS_DIR / f".{tld}_last_download"
marker.write_text(datetime.utcnow().isoformat())
result.success = True
logger.info(f"✅ .{tld}: {result.domain_count:,} domains, {result.drops_count:,} drops")
# Return drops for DB storage
result.drops = [(d, tld) for d in drops]
except Exception as e:
result.error = str(e)
logger.error(f"❌ .{tld} sync failed: {e}")
result.duration_seconds = (datetime.now() - start).total_seconds()
return result
async def sync_switch_tld(tld: str) -> ZoneSyncResult:
"""Sync a single Switch.ch TLD"""
result = ZoneSyncResult(tld)
start = datetime.now()
try:
# Load previous domains for comparison
yesterday_domains = load_previous_domains(tld, SWITCH_DIR)
# Download new zone file
zone_file = download_switch_zone(tld)
if not zone_file:
result.error = "AXFR failed"
return result
# Parse zone file
logger.info(f"Parsing .{tld} zone file...")
today_domains = parse_switch_zone(zone_file, tld)
if not today_domains:
result.error = "Parsing failed - no domains extracted"
return result
result.domain_count = len(today_domains)
# Detect drops
drops = detect_drops(tld, today_domains, yesterday_domains)
result.drops_count = len(drops)
# Save current domains for tomorrow's comparison
save_domains(tld, today_domains, SWITCH_DIR)
# Cleanup raw zone file (keep only domain list)
if zone_file.exists():
zone_file.unlink()
result.success = True
logger.info(f"✅ .{tld}: {result.domain_count:,} domains, {result.drops_count:,} drops")
# Return drops for DB storage
result.drops = [(d, tld) for d in drops]
except Exception as e:
result.error = str(e)
logger.error(f"❌ .{tld} sync failed: {e}")
result.duration_seconds = (datetime.now() - start).total_seconds()
return result
def cleanup_stray_files(directory: Path, keep_extensions: list = None):
"""Remove any stray/temporary files to save space"""
if keep_extensions is None:
keep_extensions = ['.txt.gz', '.txt'] # Only keep domain lists
removed_count = 0
removed_size = 0
for f in directory.iterdir():
if f.is_file():
# Keep marker files
if f.name.startswith('.'):
continue
# Keep domain list files
if any(f.name.endswith(ext) for ext in keep_extensions):
continue
# Remove everything else (raw zone files, temp files)
try:
size = f.stat().st_size
f.unlink()
removed_count += 1
removed_size += size
logger.info(f"🗑️ Removed stray file: {f.name} ({size / (1024*1024):.1f} MB)")
except Exception as e:
logger.warning(f"Failed to remove {f.name}: {e}")
return removed_count, removed_size
def get_directory_size(directory: Path) -> int:
"""Get total size of directory in bytes"""
total = 0
for f in directory.rglob('*'):
if f.is_file():
total += f.stat().st_size
return total
def log_storage_stats():
"""Log current storage usage"""
czds_size = get_directory_size(CZDS_DIR) if CZDS_DIR.exists() else 0
switch_size = get_directory_size(SWITCH_DIR) if SWITCH_DIR.exists() else 0
total = czds_size + switch_size
logger.info(f"💾 STORAGE: CZDS={czds_size/(1024*1024):.1f}MB, Switch={switch_size/(1024*1024):.1f}MB, Total={total/(1024*1024):.1f}MB")
return total
async def main():
"""Main sync process"""
logger.info("=" * 60)
logger.info("🚀 POUNCE ZONE SYNC - Starting daily synchronization")
logger.info("=" * 60)
start_time = datetime.now()
# Ensure directories exist
CZDS_DIR.mkdir(parents=True, exist_ok=True)
SWITCH_DIR.mkdir(parents=True, exist_ok=True)
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
# Log initial storage
logger.info("\n📊 Initial storage check...")
initial_storage = log_storage_stats()
all_drops = []
results = []
# Sync CZDS TLDs (sequentially to respect rate limits)
logger.info("\n📦 Syncing ICANN CZDS zone files...")
for tld in CZDS_TLDS:
result = await sync_czds_tld(tld)
results.append(result)
if hasattr(result, 'drops'):
all_drops.extend(result.drops)
# Rate limit: wait between downloads
if tld != CZDS_TLDS[-1]:
logger.info("⏳ Waiting 5 seconds (rate limit)...")
await asyncio.sleep(5)
# Sync Switch.ch TLDs
logger.info("\n🇨🇭 Syncing Switch.ch zone files...")
for tld in ["ch", "li"]:
result = await sync_switch_tld(tld)
results.append(result)
if hasattr(result, 'drops'):
all_drops.extend(result.drops)
# Store drops in database
if all_drops:
logger.info(f"\n💾 Storing {len(all_drops)} drops in database...")
try:
session = await get_db_session()
stored = await store_drops_in_db(all_drops, session)
await session.close()
logger.info(f"✅ Stored {stored} drops in database")
except Exception as e:
logger.error(f"❌ Failed to store drops: {e}")
# Cleanup stray files
logger.info("\n🧹 Cleaning up temporary files...")
czds_removed, czds_freed = cleanup_stray_files(CZDS_DIR)
switch_removed, switch_freed = cleanup_stray_files(SWITCH_DIR)
total_freed = czds_freed + switch_freed
if total_freed > 0:
logger.info(f"✅ Freed {total_freed / (1024*1024):.1f} MB ({czds_removed + switch_removed} files)")
else:
logger.info("✅ No stray files found")
# Summary
duration = (datetime.now() - start_time).total_seconds()
logger.info("\n" + "=" * 60)
logger.info("📊 SYNC SUMMARY")
logger.info("=" * 60)
total_domains = 0
total_drops = 0
success_count = 0
for r in results:
status = "" if r.success else ""
logger.info(f" {status} .{r.tld}: {r.domain_count:,} domains, {r.drops_count:,} drops ({r.duration_seconds:.1f}s)")
if r.success:
total_domains += r.domain_count
total_drops += r.drops_count
success_count += 1
logger.info("-" * 60)
logger.info(f" TOTAL: {total_domains:,} domains across {success_count}/{len(results)} TLDs")
logger.info(f" DROPS: {total_drops:,} new drops detected")
logger.info(f" TIME: {duration:.1f} seconds")
# Final storage stats
logger.info("-" * 60)
final_storage = log_storage_stats()
if initial_storage > 0:
change = final_storage - initial_storage
logger.info(f" CHANGE: {'+' if change > 0 else ''}{change/(1024*1024):.1f} MB")
logger.info("=" * 60)
return 0 if success_count == len(results) else 1
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)

View File

@ -6,13 +6,34 @@ import BlogPostClient from './BlogPostClient'
import type { BlogPost } from './types'
async function fetchPostMeta(slug: string): Promise<BlogPost | null> {
const baseUrl = (process.env.BACKEND_URL || process.env.NEXT_PUBLIC_SITE_URL || SITE_URL).replace(/\/$/, '')
const res = await fetch(`${baseUrl}/api/v1/blog/posts/${encodeURIComponent(slug)}/meta`, {
next: { revalidate: 3600 },
})
if (res.status === 404) return null
if (!res.ok) throw new Error(`Failed to load blog post meta: ${res.status}`)
return (await res.json()) as BlogPost
try {
// Build API URL correctly:
// - BACKEND_URL is just the host (e.g. http://127.0.0.1:8000)
// - NEXT_PUBLIC_API_URL already includes /api/v1 (e.g. https://pounce.ch/api/v1)
// - SITE_URL is just the frontend host (e.g. https://pounce.ch)
let apiUrl: string
if (process.env.BACKEND_URL) {
apiUrl = `${process.env.BACKEND_URL.replace(/\/$/, '')}/api/v1/blog/posts/${encodeURIComponent(slug)}/meta`
} else if (process.env.NEXT_PUBLIC_API_URL) {
apiUrl = `${process.env.NEXT_PUBLIC_API_URL.replace(/\/$/, '')}/blog/posts/${encodeURIComponent(slug)}/meta`
} else {
apiUrl = `${SITE_URL.replace(/\/$/, '')}/api/v1/blog/posts/${encodeURIComponent(slug)}/meta`
}
const res = await fetch(apiUrl, {
next: { revalidate: 3600 },
})
if (res.status === 404) return null
if (!res.ok) {
console.error(`[fetchPostMeta] Failed: ${res.status} from ${apiUrl}`)
return null
}
return (await res.json()) as BlogPost
} catch (error) {
console.error(`[fetchPostMeta] Error fetching ${slug}:`, error)
return null
}
}
export async function generateMetadata({

View File

@ -6,13 +6,41 @@ import BuyDomainClient from './BuyDomainClient'
import type { Listing } from './types'
async function fetchListing(slug: string): Promise<Listing | null> {
const baseUrl = (process.env.BACKEND_URL || process.env.NEXT_PUBLIC_SITE_URL || SITE_URL).replace(/\/$/, '')
const res = await fetch(`${baseUrl}/api/v1/listings/${encodeURIComponent(slug)}`, {
next: { revalidate: 60 },
})
if (res.status === 404) return null
if (!res.ok) throw new Error(`Failed to load listing: ${res.status}`)
return (await res.json()) as Listing
try {
// Build API URL correctly:
// - BACKEND_URL is just the host (e.g. http://127.0.0.1:8000)
// - NEXT_PUBLIC_API_URL already includes /api/v1 (e.g. https://pounce.ch/api/v1)
// - SITE_URL is just the frontend host (e.g. https://pounce.ch)
let apiUrl: string
if (process.env.BACKEND_URL) {
// Internal backend URL (no /api/v1 suffix)
apiUrl = `${process.env.BACKEND_URL.replace(/\/$/, '')}/api/v1/listings/${encodeURIComponent(slug)}`
} else if (process.env.NEXT_PUBLIC_API_URL) {
// Already includes /api/v1
apiUrl = `${process.env.NEXT_PUBLIC_API_URL.replace(/\/$/, '')}/listings/${encodeURIComponent(slug)}`
} else {
// Fallback to site URL
apiUrl = `${SITE_URL.replace(/\/$/, '')}/api/v1/listings/${encodeURIComponent(slug)}`
}
const res = await fetch(apiUrl, {
next: { revalidate: 60 },
headers: {
'Accept': 'application/json',
},
})
if (res.status === 404) return null
if (!res.ok) {
console.error(`[fetchListing] Failed to load listing ${slug}: ${res.status} from ${apiUrl}`)
return null
}
return (await res.json()) as Listing
} catch (error) {
console.error(`[fetchListing] Error fetching listing ${slug}:`, error)
return null
}
}
export async function generateMetadata({

View File

@ -22,13 +22,30 @@ type TldCompareResponse = {
}
async function fetchTldCompare(tld: string): Promise<TldCompareResponse | null> {
const baseUrl = (process.env.BACKEND_URL || process.env.NEXT_PUBLIC_SITE_URL || SITE_URL).replace(/\/$/, '')
const res = await fetch(`${baseUrl}/api/v1/tld-prices/${encodeURIComponent(tld)}/compare`, {
next: { revalidate: 3600 },
})
if (res.status === 404) return null
if (!res.ok) throw new Error(`Failed to fetch tld compare: ${res.status}`)
return (await res.json()) as TldCompareResponse
try {
// Build API URL correctly
let apiUrl: string
if (process.env.BACKEND_URL) {
apiUrl = `${process.env.BACKEND_URL.replace(/\/$/, '')}/api/v1/tld-prices/${encodeURIComponent(tld)}/compare`
} else if (process.env.NEXT_PUBLIC_API_URL) {
apiUrl = `${process.env.NEXT_PUBLIC_API_URL.replace(/\/$/, '')}/tld-prices/${encodeURIComponent(tld)}/compare`
} else {
apiUrl = `${SITE_URL.replace(/\/$/, '')}/api/v1/tld-prices/${encodeURIComponent(tld)}/compare`
}
const res = await fetch(apiUrl, {
next: { revalidate: 3600 },
})
if (res.status === 404) return null
if (!res.ok) {
console.error(`[fetchTldCompare] Failed: ${res.status} from ${apiUrl}`)
return null
}
return (await res.json()) as TldCompareResponse
} catch (error) {
console.error(`[fetchTldCompare] Error fetching ${tld}:`, error)
return null
}
}
export async function generateMetadata({

View File

@ -58,7 +58,7 @@ const TABS: Array<{ key: HuntTab; label: string; shortLabel: string; icon: any;
// ============================================================================
export default function HuntPage() {
const { user, subscription, logout, checkAuth, domains } = useStore()
const { user, subscription, logout, checkAuth } = useStore()
const { toast, showToast, hideToast } = useToast()
const [tab, setTab] = useState<HuntTab>('auctions')
@ -70,10 +70,6 @@ export default function HuntPage() {
checkAuth()
}, [checkAuth])
// Computed
const availableDomains = domains?.filter((d) => d.is_available) || []
const totalDomains = domains?.length || 0
// Nav Items for Mobile Bottom Bar
const mobileNavItems = [
{ href: '/terminal/hunt', label: 'Hunt', icon: Crosshair, active: true },
@ -131,14 +127,9 @@ export default function HuntPage() {
>
<div className="px-4 py-3">
{/* Top Row */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Crosshair className="w-4 h-4 text-accent" />
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Domain Hunt</span>
</div>
<div className="text-[10px] font-mono text-white/40">
{totalDomains} tracked · {availableDomains.length} available
</div>
<div className="flex items-center gap-2 mb-3">
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Domain Hunt</span>
</div>
{/* Tab Bar - Scrollable */}
@ -179,10 +170,10 @@ export default function HuntPage() {
{/* DESKTOP HEADER + TAB BAR */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
<section className="hidden lg:block px-10 pt-10 pb-6 border-b border-white/[0.08]">
<div className="flex items-end justify-between gap-6 mb-6">
<div>
<div className="flex items-center gap-3 mb-3">
<Crosshair className="w-5 h-5 text-accent" />
<div className="mb-6">
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Discovery Hub</span>
</div>
<h1 className="font-display text-[2.5rem] leading-[1] tracking-[-0.02em] text-white">Domain Hunt</h1>
@ -190,17 +181,6 @@ export default function HuntPage() {
Search domains, browse auctions, discover drops, ride trends, or generate brandables.
</p>
</div>
<div className="flex gap-6">
<div className="text-right">
<div className="text-2xl font-bold text-accent font-mono">{totalDomains}</div>
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Tracked</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-white font-mono">{availableDomains.length}</div>
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Available</div>
</div>
</div>
</div>
{/* Desktop Tab Bar */}

View File

@ -57,6 +57,13 @@ function getTierLevel(tier: UserTier): number {
}
}
interface MarketplaceLink {
name: string
description: string
url: string
type: string
}
interface TldDetails {
tld: string
type: string
@ -78,6 +85,7 @@ interface TldDetails {
price_change_3y: number
risk_level: 'low' | 'medium' | 'high'
risk_reason: string
marketplace_links?: MarketplaceLink[]
}
interface TldHistory {
@ -775,6 +783,37 @@ export default function TldDetailPage() {
</div>
)}
</div>
{/* Marketplace Links */}
{details.marketplace_links && details.marketplace_links.length > 0 && (
<div className="border border-white/[0.08] bg-white/[0.01] h-fit mt-4">
<div className="p-4 border-b border-white/[0.06]">
<h3 className="text-xs font-mono text-white/40 uppercase tracking-wider">Buy Existing Domains</h3>
</div>
<div className="divide-y divide-white/[0.05]">
{details.marketplace_links.map((marketplace) => (
<a
key={marketplace.name}
href={marketplace.url}
target="_blank"
rel="noopener noreferrer"
className="p-4 hover:bg-white/[0.02] transition-colors flex items-center justify-between group"
>
<div>
<div className="text-sm text-white font-mono group-hover:text-accent transition-colors">
{marketplace.name}
</div>
<div className="text-[10px] text-white/40 font-mono">
{marketplace.description}
</div>
</div>
<ExternalLink className="w-4 h-4 text-white/20 group-hover:text-accent transition-colors" />
</a>
))}
</div>
</div>
)}
</div>
</section>

View File

@ -677,6 +677,7 @@ export default function PortfolioPage() {
// Health data
const [healthByDomain, setHealthByDomain] = useState<Record<string, DomainHealthReport>>({})
const [checkingHealth, setCheckingHealth] = useState<Set<string>>(new Set())
const [healthLoadStarted, setHealthLoadStarted] = useState(false)
// External status (Yield, Listed)
const [yieldByDomain, setYieldByDomain] = useState<Record<string, { id: number; status: string; dns_verified: boolean }>>({})
@ -750,6 +751,46 @@ export default function PortfolioPage() {
}
}, [activeTab, cfoData, cfoLoading, loadCfoData])
// Auto-load health data for all domains when domains are first loaded
useEffect(() => {
// Only run once when domains are first loaded
if (!domains.length || healthLoadStarted) return
setHealthLoadStarted(true)
const loadHealthForDomains = async () => {
// Load health for up to 20 domains to avoid too many requests
const domainsToCheck = domains.slice(0, 20)
for (const domain of domainsToCheck) {
const key = domain.domain.toLowerCase()
// Skip if already have health data
if (healthByDomain[key]) continue
// Add to checking set
setCheckingHealth(prev => new Set(prev).add(key))
try {
const report = await api.quickHealthCheck(domain.domain)
setHealthByDomain(prev => ({ ...prev, [key]: report }))
} catch {
// Silently fail for individual domains
} finally {
setCheckingHealth(prev => {
const next = new Set(prev)
next.delete(key)
return next
})
}
// Small delay to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 300))
}
}
loadHealthForDomains()
}, [domains, healthLoadStarted])
// Stats
const stats = useMemo(() => {
const active = domains.filter(d => !d.is_sold).length
@ -806,7 +847,7 @@ export default function PortfolioPage() {
}
// Actions
const handleHealthCheck = async (domainName: string) => {
const handleHealthCheck = async (domainName: string, showError = true) => {
const key = domainName.toLowerCase()
if (checkingHealth.has(key)) return
setCheckingHealth(prev => new Set(prev).add(key))
@ -814,7 +855,9 @@ export default function PortfolioPage() {
const report = await api.quickHealthCheck(domainName)
setHealthByDomain(prev => ({ ...prev, [key]: report }))
} catch (err: any) {
showToast(err?.message || 'Health check failed', 'error')
if (showError) {
showToast(err?.message || 'Health check failed', 'error')
}
} finally {
setCheckingHealth(prev => {
const next = new Set(prev)
@ -1012,10 +1055,11 @@ export default function PortfolioPage() {
{/* MOBILE HEADER */}
<header className="lg:hidden sticky top-0 z-40 bg-[#020202]/95 backdrop-blur-md border-b border-white/[0.08]" style={{ paddingTop: 'env(safe-area-inset-top)' }}>
<div className="px-4 py-3">
{/* Top Row */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Briefcase className="w-4 h-4 text-accent" />
<span className="text-sm font-mono text-white font-bold">Portfolio</span>
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Portfolio</span>
</div>
<button
onClick={() => setShowAddModal(true)}
@ -1025,24 +1069,39 @@ export default function PortfolioPage() {
Add
</button>
</div>
<div className="grid grid-cols-4 gap-1.5">
<div className="bg-white/[0.03] border border-white/[0.08] p-2 text-center">
<div className="text-lg font-bold text-white tabular-nums">{stats.active}</div>
<div className="text-[8px] font-mono text-white/30 uppercase">Active</div>
</div>
<div className="bg-accent/5 border border-accent/20 p-2 text-center">
<div className="text-lg font-bold text-accent tabular-nums">{formatCurrency(summary?.total_value || 0).replace('$', '')}</div>
<div className="text-[8px] font-mono text-accent/60 uppercase">Value</div>
</div>
<div className="bg-white/[0.03] border border-white/[0.08] p-2 text-center">
<div className={clsx("text-lg font-bold tabular-nums", (summary?.overall_roi || 0) >= 0 ? "text-accent" : "text-rose-400")}>
{formatROI(summary?.overall_roi || 0)}
</div>
<div className="text-[8px] font-mono text-white/30 uppercase">ROI</div>
</div>
<div className="bg-white/[0.03] border border-white/[0.08] p-2 text-center">
<div className="text-lg font-bold text-white tabular-nums">{stats.verified}</div>
<div className="text-[8px] font-mono text-white/30 uppercase">Verified</div>
{/* Tab Bar - Scrollable */}
<div className="-mx-4 px-4 overflow-x-auto">
<div className="flex gap-1 min-w-max pb-1">
<button
onClick={() => setActiveTab('assets')}
className={clsx(
'flex items-center gap-1.5 px-3 py-2 border transition-all shrink-0',
activeTab === 'assets'
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-transparent text-white/40 active:bg-white/5'
)}
>
<Briefcase className="w-3.5 h-3.5" />
<span className="text-[10px] font-bold uppercase tracking-wider font-mono">Assets</span>
</button>
<button
onClick={() => setActiveTab('financials')}
className={clsx(
'flex items-center gap-1.5 px-3 py-2 border transition-all shrink-0',
activeTab === 'financials'
? 'border-orange-500/40 bg-orange-500/10 text-orange-400'
: 'border-transparent text-white/40 active:bg-white/5'
)}
>
<Wallet className="w-3.5 h-3.5" />
<span className="text-[10px] font-bold uppercase tracking-wider font-mono">Financials</span>
{stats.upcoming30dCost > 0 && (
<span className="px-1 py-0.5 text-[8px] bg-orange-500/20 text-orange-400 border border-orange-500/30">
${Math.round(stats.upcoming30dCost)}
</span>
)}
</button>
</div>
</div>
</div>
@ -1092,34 +1151,34 @@ export default function PortfolioPage() {
</div>
</section>
{/* TABS */}
{/* TABS - Matching Hunt page style */}
<section className="px-4 lg:px-10 py-4 border-y border-white/[0.08] bg-white/[0.01]">
<div className="flex items-center gap-4 mb-4">
<div className="flex gap-2 mb-4">
<button
onClick={() => setActiveTab('assets')}
className={clsx(
"flex items-center gap-2 px-4 py-2 text-xs font-bold uppercase tracking-wider border transition-colors",
activeTab === 'assets'
? "bg-accent/10 text-accent border-accent/30"
: "text-white/50 border-white/[0.08] hover:text-white hover:bg-white/[0.02]"
'flex items-center gap-2 px-4 py-2.5 border transition-all',
activeTab === 'assets'
? 'border-accent bg-accent/10 text-accent'
: 'border-white/[0.08] text-white/50 hover:text-white hover:bg-white/[0.02]'
)}
>
<Briefcase className="w-4 h-4" />
Assets
<span className="text-xs font-bold uppercase tracking-wider">Assets</span>
</button>
<button
onClick={() => setActiveTab('financials')}
className={clsx(
"flex items-center gap-2 px-4 py-2 text-xs font-bold uppercase tracking-wider border transition-colors",
activeTab === 'financials'
? "bg-orange-500/10 text-orange-400 border-orange-500/30"
: "text-white/50 border-white/[0.08] hover:text-white hover:bg-white/[0.02]"
'flex items-center gap-2 px-4 py-2.5 border transition-all',
activeTab === 'financials'
? 'border-orange-500 bg-orange-500/10 text-orange-400'
: 'border-white/[0.08] text-white/50 hover:text-white hover:bg-white/[0.02]'
)}
>
<Wallet className="w-4 h-4" />
Financials
<span className="text-xs font-bold uppercase tracking-wider">Financials</span>
{stats.upcoming30dCost > 0 && (
<span className="ml-1 px-1.5 py-0.5 text-[9px] bg-orange-500/20 text-orange-400 border border-orange-500/20">
<span className="px-1.5 py-0.5 text-[9px] bg-orange-500/20 text-orange-400 border border-orange-500/30">
${Math.round(stats.upcoming30dCost)}
</span>
)}
@ -1128,7 +1187,7 @@ export default function PortfolioPage() {
{/* Asset Filters - only show when assets tab active */}
{activeTab === 'assets' && (
<div className="flex items-center gap-3 overflow-x-auto">
<div className="flex items-center gap-2 overflow-x-auto">
{[
{ value: 'all', label: 'All', count: stats.total },
{ value: 'active', label: 'Active', count: stats.active },

View File

@ -106,6 +106,8 @@ export default function SettingsPage() {
const [error, setError] = useState<string | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const [changingPlan, setChangingPlan] = useState<string | null>(null)
const [showCancelModal, setShowCancelModal] = useState(false)
const [cancelling, setCancelling] = useState(false)
const [profileForm, setProfileForm] = useState({ name: '', email: '' })
const [inviteLink, setInviteLink] = useState<string | null>(null)
@ -232,9 +234,10 @@ export default function SettingsPage() {
setChangingPlan(planId); setError(null)
try {
if (planId === 'scout') {
await api.cancelSubscription()
setSuccess('Downgraded to Scout')
await checkAuth()
// Use the cancel modal instead of direct downgrade
setShowCancelModal(true)
setChangingPlan(null)
return
} else {
const { checkout_url } = await api.createCheckoutSession(
planId,
@ -247,6 +250,19 @@ export default function SettingsPage() {
finally { setChangingPlan(null) }
}
const handleCancelSubscription = async () => {
setCancelling(true); setError(null)
try {
await api.cancelSubscription()
setSuccess('Your subscription has been cancelled. You are now on the Scout plan.')
setShowCancelModal(false)
await checkAuth()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to cancel')
}
finally { setCancelling(false) }
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#020202]">
@ -665,6 +681,44 @@ export default function SettingsPage() {
Manage Billing & Invoices
</button>
)}
{/* Simple Cancel Section - Only for paid users */}
{isProOrHigher && (
<div className="bg-[#0A0A0A] border border-white/[0.08] mt-6">
<div className="px-4 py-2 border-b border-white/[0.06] bg-black/40">
<span className="text-[10px] font-mono text-white/40">Cancel Subscription</span>
</div>
<div className="p-4 lg:p-6">
<p className="text-sm text-white/60 mb-4">
Not what you need right now? No problem. Cancel anytime no hidden fees, no questions asked.
</p>
<div className="space-y-2 mb-4">
<div className="flex items-center gap-2 text-xs text-white/40">
<Check className="w-3.5 h-3.5 text-accent" />
You won't be charged again
</div>
<div className="flex items-center gap-2 text-xs text-white/40">
<Check className="w-3.5 h-3.5 text-accent" />
Keep access until current period ends
</div>
<div className="flex items-center gap-2 text-xs text-white/40">
<Check className="w-3.5 h-3.5 text-accent" />
Switch back to Scout (free forever)
</div>
<div className="flex items-center gap-2 text-xs text-white/40">
<Check className="w-3.5 h-3.5 text-accent" />
Re-subscribe anytime if you change your mind
</div>
</div>
<button
onClick={() => setShowCancelModal(true)}
className="px-4 py-2.5 border border-white/10 text-white/60 text-xs font-mono hover:border-white/20 hover:text-white transition-colors"
>
Cancel my subscription
</button>
</div>
</div>
)}
</div>
)}
@ -755,6 +809,69 @@ export default function SettingsPage() {
</div>
</nav>
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* CANCEL SUBSCRIPTION MODAL */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{showCancelModal && (
<div className="fixed inset-0 z-[110] bg-black/80 flex items-center justify-center p-4" onClick={() => !cancelling && setShowCancelModal(false)}>
<div
className="w-full max-w-md bg-[#0A0A0A] border border-white/[0.08]"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 text-center">
<div className="w-12 h-12 mx-auto mb-4 border border-white/10 flex items-center justify-center">
<Zap className="w-6 h-6 text-white/30" />
</div>
<h3 className="text-lg font-display text-white mb-2">Cancel Subscription?</h3>
<p className="text-sm text-white/50 mb-6">
You'll be moved to the free Scout plan. You can re-subscribe anytime.
</p>
<div className="space-y-2 text-left mb-6 p-4 bg-white/[0.02] border border-white/[0.06]">
<p className="text-[10px] font-mono text-white/40 uppercase tracking-wider mb-2">What happens next:</p>
<div className="flex items-start gap-2 text-xs text-white/60">
<Check className="w-3.5 h-3.5 text-accent shrink-0 mt-0.5" />
<span>Your subscription ends immediately</span>
</div>
<div className="flex items-start gap-2 text-xs text-white/60">
<Check className="w-3.5 h-3.5 text-accent shrink-0 mt-0.5" />
<span>No more charges guaranteed</span>
</div>
<div className="flex items-start gap-2 text-xs text-white/60">
<Check className="w-3.5 h-3.5 text-accent shrink-0 mt-0.5" />
<span>Your data stays safe nothing is deleted</span>
</div>
<div className="flex items-start gap-2 text-xs text-white/60">
<Check className="w-3.5 h-3.5 text-accent shrink-0 mt-0.5" />
<span>Come back anytime to upgrade again</span>
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowCancelModal(false)}
disabled={cancelling}
className="flex-1 py-3 border border-white/10 text-white text-xs font-bold uppercase tracking-wider hover:bg-white/5 disabled:opacity-50"
>
Keep Plan
</button>
<button
onClick={handleCancelSubscription}
disabled={cancelling}
className="flex-1 py-3 bg-white/10 text-white/60 text-xs font-mono hover:bg-white/20 hover:text-white disabled:opacity-50 flex items-center justify-center gap-2"
>
{cancelling ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Yes, cancel'
)}
</button>
</div>
</div>
</div>
</div>
)}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* MOBILE DRAWER */}
{/* ═══════════════════════════════════════════════════════════════════════ */}

View File

@ -13,14 +13,11 @@ import {
Power,
PowerOff,
Bell,
MessageSquare,
Loader2,
X,
AlertCircle,
CheckCircle,
Clock,
DollarSign,
Hash,
Crown,
Eye,
Gavel,
@ -88,7 +85,6 @@ export default function SniperAlertsPage() {
const alertLimits: Record<string, number> = { scout: 2, trader: 10, tycoon: 50 }
const maxAlerts = alertLimits[tier] || 2
const canAddMore = alerts.length < maxAlerts
const isTycoon = tier === 'tycoon'
const activeAlerts = alerts.filter(a => a.is_active).length
const totalMatches = alerts.reduce((sum, a) => sum + a.matches_count, 0)
@ -217,7 +213,7 @@ export default function SniperAlertsPage() {
<span className="text-white/30 ml-3 font-mono text-[2rem]">{alerts.length}/{maxAlerts}</span>
</h1>
<p className="text-sm text-white/40 font-mono mt-2 max-w-lg">
Set up keyword alerts. Get notified when matching domains drop or go to auction.
Set up keyword alerts. Get notified when matching domains appear in auctions or zone file drops.
</p>
</div>
@ -291,11 +287,6 @@ export default function SniperAlertsPage() {
) : (
<span className="px-1.5 py-0.5 text-[9px] font-mono bg-white/5 text-white/40 border border-white/10">Paused</span>
)}
{isTycoon && alert.notify_sms && (
<span className="px-1.5 py-0.5 text-[9px] font-mono bg-amber-400/10 text-amber-400 border border-amber-400/20 flex items-center gap-1">
<Crown className="w-3 h-3" />SMS
</span>
)}
</div>
<div className="flex flex-wrap gap-1 mb-2">
@ -446,7 +437,6 @@ export default function SniperAlertsPage() {
alert={editingAlert}
onClose={() => { setShowCreateModal(false); setEditingAlert(null) }}
onSuccess={() => { loadAlerts(); setShowCreateModal(false); setEditingAlert(null) }}
isTycoon={isTycoon}
/>
)}
</div>
@ -457,11 +447,10 @@ export default function SniperAlertsPage() {
// CREATE/EDIT MODAL
// ============================================================================
function CreateEditModal({ alert, onClose, onSuccess, isTycoon }: {
function CreateEditModal({ alert, onClose, onSuccess }: {
alert: SniperAlert | null
onClose: () => void
onSuccess: () => void
isTycoon: boolean
}) {
const isEditing = !!alert
const [loading, setLoading] = useState(false)
@ -484,7 +473,6 @@ function CreateEditModal({ alert, onClose, onSuccess, isTycoon }: {
no_hyphens: alert?.no_hyphens || false,
exclude_chars: alert?.exclude_chars || '',
notify_email: alert?.notify_email ?? true,
notify_sms: alert?.notify_sms || false,
})
const handleSubmit = async (e: React.FormEvent) => {
@ -510,7 +498,6 @@ function CreateEditModal({ alert, onClose, onSuccess, isTycoon }: {
no_hyphens: form.no_hyphens,
exclude_chars: form.exclude_chars || null,
notify_email: form.notify_email,
notify_sms: form.notify_sms && isTycoon,
}
if (isEditing && alert) {
@ -584,18 +571,27 @@ function CreateEditModal({ alert, onClose, onSuccess, isTycoon }: {
</div>
</div>
{/* Sources info */}
<div className="p-3 bg-accent/5 border border-accent/20">
<div className="text-[9px] font-mono text-accent uppercase mb-2">Monitors</div>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 text-[10px] font-mono bg-white/5 border border-white/10 text-white/60 flex items-center gap-1.5">
<Gavel className="w-3 h-3" />
Auctions
</span>
<span className="px-2 py-1 text-[10px] font-mono bg-white/5 border border-white/10 text-white/60 flex items-center gap-1.5">
<Zap className="w-3 h-3" />
Zone Drops
</span>
</div>
</div>
<div className="space-y-2">
<label className="flex items-center gap-3 p-2.5 border border-white/[0.06] cursor-pointer hover:bg-white/[0.02]">
<input type="checkbox" checked={form.notify_email} onChange={(e) => setForm({ ...form, notify_email: e.target.checked })} className="w-4 h-4" />
<Bell className="w-4 h-4 text-accent" />
<span className="text-sm text-white/60">Email notifications</span>
</label>
<label className={clsx("flex items-center gap-3 p-2.5 border cursor-pointer", isTycoon ? "border-amber-400/20 hover:bg-amber-400/[0.02]" : "border-white/[0.06] opacity-50")}>
<input type="checkbox" checked={form.notify_sms} onChange={(e) => isTycoon && setForm({ ...form, notify_sms: e.target.checked })} disabled={!isTycoon} className="w-4 h-4" />
<MessageSquare className="w-4 h-4 text-amber-400" />
<span className="text-sm text-white/60 flex-1">SMS notifications</span>
{!isTycoon && <Crown className="w-4 h-4 text-amber-400" />}
</label>
</div>
<div className="flex gap-3 pt-2">

View File

@ -35,12 +35,86 @@ import {
Search,
ChevronUp,
ChevronDown,
Briefcase
Briefcase,
ShoppingCart,
Crosshair
} from 'lucide-react'
import clsx from 'clsx'
import Link from 'next/link'
import Image from 'next/image'
// ============================================================================
// ADD MODAL COMPONENT (like Portfolio)
// ============================================================================
function AddModal({
onClose,
onAdd
}: {
onClose: () => void
onAdd: (domain: string) => Promise<void>
}) {
const [domain, setDomain] = useState('')
const [adding, setAdding] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!domain.trim()) return
setAdding(true)
try {
await onAdd(domain.trim().toLowerCase())
onClose()
} finally {
setAdding(false)
}
}
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm" onClick={onClose}>
<div
className="w-full max-w-md bg-[#0a0a0a] border border-white/10"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-white/10">
<div className="flex items-center gap-2">
<Eye className="w-4 h-4 text-accent" />
<span className="text-sm font-mono text-white uppercase tracking-wider">Add to Watchlist</span>
</div>
<button onClick={onClose} className="p-2 text-white/40 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
<div>
<label className="block text-[10px] font-mono text-white/40 mb-2 uppercase tracking-wider">Domain Name</label>
<input
type="text"
value={domain}
onChange={e => setDomain(e.target.value)}
placeholder="example.com"
autoFocus
className="w-full px-4 py-3 bg-white/5 border border-white/10 text-white text-lg font-mono focus:border-accent/50 focus:outline-none placeholder:text-white/20"
/>
<p className="text-[10px] font-mono text-white/30 mt-2">
We'll check availability and notify you when it becomes available.
</p>
</div>
<button
type="submit"
disabled={adding || !domain.trim()}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-accent text-black text-sm font-bold uppercase tracking-wider hover:bg-white transition-colors disabled:opacity-50"
>
{adding ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
Add to Watchlist
</button>
</form>
</div>
</div>
)
}
// ============================================================================
// HELPERS
// ============================================================================
@ -75,9 +149,8 @@ export default function WatchlistPage() {
const { toast, showToast, hideToast } = useToast()
const openAnalyze = useAnalyzePanelStore((s) => s.open)
const [newDomain, setNewDomain] = useState('')
const [adding, setAdding] = useState(false)
const [searchFocused, setSearchFocused] = useState(false)
// Modal state
const [showAddModal, setShowAddModal] = useState(false)
const [refreshingId, setRefreshingId] = useState<number | null>(null)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [togglingNotifyId, setTogglingNotifyId] = useState<number | null>(null)
@ -155,21 +228,15 @@ export default function WatchlistPage() {
}, [sortField])
// Handlers
const handleAdd = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
if (!newDomain.trim()) return
const domainName = newDomain.trim().toLowerCase()
setAdding(true)
const handleAdd = useCallback(async (domainName: string) => {
try {
await addDomain(domainName)
showToast(`Added: ${domainName}`, 'success')
setNewDomain('')
} catch (err: any) {
showToast(err.message || 'Failed', 'error')
} finally {
setAdding(false)
throw err
}
}, [newDomain, addDomain, showToast])
}, [addDomain, showToast])
// Auto-trigger health check for newly added domains
useEffect(() => {
@ -247,7 +314,7 @@ export default function WatchlistPage() {
// Mobile Nav
const mobileNavItems = [
{ href: '/terminal/hunt', label: 'Hunt', icon: Target, active: false },
{ href: '/terminal/hunt', label: 'Hunt', icon: Crosshair, active: false },
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: true },
{ href: '/terminal/portfolio', label: 'Portfolio', icon: Briefcase, active: false },
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
@ -260,14 +327,14 @@ export default function WatchlistPage() {
{
title: 'Discover',
items: [
{ href: '/terminal/hunt', label: 'Hunt', icon: Target },
{ href: '/terminal/hunt', label: 'Hunt', icon: Crosshair },
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
]
},
{
title: 'Manage',
items: [
{ href: '/terminal/watchlist', label: 'Watchlist', icon: Eye },
{ href: '/terminal/watchlist', label: 'Watchlist', icon: Eye, active: true },
{ href: '/terminal/portfolio', label: 'Portfolio', icon: Briefcase },
{ href: '/terminal/sniper', label: 'Sniper', icon: Target },
]
@ -275,7 +342,7 @@ export default function WatchlistPage() {
{
title: 'Monetize',
items: [
{ href: '/terminal/yield', label: 'Yield', icon: Coins, isNew: true },
{ href: '/terminal/yield', label: 'Yield', icon: Coins },
{ href: '/terminal/listing', label: 'For Sale', icon: Tag },
]
}
@ -302,28 +369,31 @@ export default function WatchlistPage() {
{/* Top Row */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Watchlist</span>
</div>
<div className="flex items-center gap-2 text-[10px] font-mono text-white/40">
<span>{stats.total} domains</span>
<span className="text-accent">{stats.available} available</span>
<Eye className="w-4 h-4 text-accent" />
<span className="text-sm font-mono text-white font-bold">Watchlist</span>
</div>
<button
onClick={() => setShowAddModal(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent text-black text-[10px] font-bold uppercase"
>
<Plus className="w-3.5 h-3.5" />
Add
</button>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-2">
<div className="bg-white/[0.02] border border-white/[0.08] p-2">
<div className="bg-white/[0.02] border border-white/[0.08] p-2 text-center">
<div className="text-lg font-bold text-white tabular-nums">{stats.total}</div>
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Tracked</div>
<div className="text-[8px] font-mono text-white/30 uppercase">Tracked</div>
</div>
<div className="bg-accent/[0.05] border border-accent/20 p-2">
<div className="bg-accent/[0.05] border border-accent/20 p-2 text-center">
<div className="text-lg font-bold text-accent tabular-nums">{stats.available}</div>
<div className="text-[9px] font-mono text-accent/60 uppercase tracking-wider">Available</div>
<div className="text-[8px] font-mono text-accent/60 uppercase">Available</div>
</div>
<div className="bg-orange-500/[0.05] border border-orange-500/20 p-2">
<div className="bg-orange-500/[0.05] border border-orange-500/20 p-2 text-center">
<div className="text-lg font-bold text-orange-400 tabular-nums">{stats.expiring}</div>
<div className="text-[9px] font-mono text-orange-400/60 uppercase tracking-wider">Expiring</div>
<div className="text-[8px] font-mono text-orange-400/60 uppercase">Expiring</div>
</div>
</div>
</div>
@ -333,23 +403,26 @@ export default function WatchlistPage() {
{/* DESKTOP HEADER */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
<section className="hidden lg:block px-10 pt-10 pb-6">
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6">
<div className="flex items-end justify-between gap-8">
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Watchlist</span>
</div>
<h1 className="font-display text-[2.5rem] leading-[1] tracking-[-0.02em]">
<span className="text-white">Watchlist</span>
<span className="text-white/30 ml-3 font-mono text-[2rem]">{stats.total}</span>
<h1 className="font-display text-[2.5rem] leading-[1] tracking-[-0.02em] text-white">
Watchlist
</h1>
<p className="text-sm text-white/40 font-mono mt-2 max-w-md">
Track domains you want. Get alerts when they become available or expire.
</p>
</div>
<div className="flex gap-8">
<div className="flex items-center gap-8">
<div className="text-right">
<div className="text-2xl font-bold text-white font-mono">{stats.total}</div>
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Tracked</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-accent font-mono">{stats.available}</div>
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Available</div>
@ -358,43 +431,24 @@ export default function WatchlistPage() {
<div className="text-2xl font-bold text-orange-400 font-mono">{stats.expiring}</div>
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Expiring</div>
</div>
<div className="pl-6 border-l border-white/10">
<button
onClick={() => setShowAddModal(true)}
className="flex items-center gap-2 px-5 py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-white transition-colors"
>
<Plus className="w-4 h-4" />
Add Domain
</button>
</div>
</div>
</div>
</section>
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* ADD DOMAIN + FILTERS */}
{/* FILTERS */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
<section className="px-4 lg:px-10 py-4 border-b border-white/[0.08]">
{/* Add Domain Form - Always visible with accent border */}
<form onSubmit={handleAdd} className="relative mb-4">
<div className={clsx(
"flex items-center border-2 transition-all duration-200",
"border-accent/50 bg-accent/[0.03]",
searchFocused && "border-accent bg-accent/[0.05]"
)}>
<Plus className="w-4 h-4 ml-4 text-accent transition-colors" />
<input
type="text"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
placeholder="Add domain to watch..."
className="flex-1 bg-transparent px-3 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono"
/>
<button
type="submit"
disabled={adding || !newDomain.trim()}
className="h-full px-4 py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-white transition-colors disabled:opacity-30 flex items-center gap-2"
>
{adding ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Add'}
</button>
</div>
</form>
{/* Filters */}
<div className="flex items-center gap-1">
<section className="px-4 lg:px-10 py-4 border-b border-white/[0.08] bg-white/[0.01]">
<div className="flex items-center gap-2 overflow-x-auto">
{[
{ value: 'all', label: 'All', count: stats.total },
{ value: 'available', label: 'Available', count: stats.available },
@ -404,7 +458,7 @@ export default function WatchlistPage() {
key={item.value}
onClick={() => setFilter(item.value as typeof filter)}
className={clsx(
"px-3 py-2 text-[10px] font-mono uppercase tracking-wider border transition-colors",
"shrink-0 px-3 py-2 text-[10px] font-mono uppercase tracking-wider border transition-colors",
filter === item.value
? "bg-white/10 text-white border-white/20"
: "text-white/40 border-transparent hover:text-white/60"
@ -427,9 +481,9 @@ export default function WatchlistPage() {
<p className="text-white/25 text-xs font-mono mt-1">Add a domain above to start monitoring</p>
</div>
) : (
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
<div className="space-y-px">
{/* Desktop Table Header */}
<div className="hidden lg:grid grid-cols-[1fr_80px_90px_90px_60px_100px] gap-4 px-3 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
<div className="hidden lg:grid grid-cols-[1.5fr_100px_100px_100px_80px_160px] gap-4 px-4 py-2.5 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08] bg-white/[0.02]">
<button onClick={() => handleSortWatch('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" />)}
@ -462,13 +516,18 @@ export default function WatchlistPage() {
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={clsx(
"lg:hidden p-3 border border-white/[0.06]",
domain.is_available
? "bg-accent/[0.02] border-accent/20"
: "bg-[#020202]"
)}>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<div className={clsx(
"w-8 h-8 flex items-center justify-center border shrink-0",
"w-9 h-9 flex items-center justify-center border shrink-0",
domain.is_available
? "bg-accent/10 border-accent/20"
? "bg-accent/10 border-accent/30"
: "bg-white/[0.02] border-white/[0.06]"
)}>
{domain.is_available ? (
@ -493,14 +552,16 @@ export default function WatchlistPage() {
<div className="text-right shrink-0">
<div className={clsx(
"text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 mb-1",
domain.is_available ? "text-accent bg-accent/10" : "text-white/30 bg-white/5"
"text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 mb-1 border",
domain.is_available
? "text-accent bg-accent/10 border-accent/30"
: "text-white/40 bg-white/5 border-white/10"
)}>
{domain.is_available ? 'AVAIL' : 'TAKEN'}
{domain.is_available ? ' AVAIL' : 'TAKEN'}
</div>
<button
onClick={() => { setSelectedDomain(domain.id); handleHealthCheck(domain.id) }}
className="flex items-center gap-1"
className="flex items-center gap-1 justify-end"
>
{loadingHealth[domain.id] ? (
<Loader2 className="w-3 h-3 animate-spin text-white/30" />
@ -514,6 +575,14 @@ export default function WatchlistPage() {
</div>
</div>
{/* Expiry Info */}
{days !== null && days <= 30 && days > 0 && !domain.is_available && (
<div className="mb-3 text-[10px] font-mono text-orange-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Expires in {days} days
</div>
)}
{/* Actions */}
<div className="flex gap-2">
{domain.is_available ? (
@ -521,44 +590,44 @@ export default function WatchlistPage() {
href={`https://www.namecheap.com/domains/registration/results/?domain=${domain.name}`}
target="_blank"
rel="noopener noreferrer"
className="flex-1 py-2.5 bg-accent text-black text-[10px] font-bold uppercase tracking-wider flex items-center justify-center gap-1.5"
className="flex-1 py-3 bg-accent text-black text-[11px] font-bold uppercase tracking-wider flex items-center justify-center gap-2"
>
<ExternalLink className="w-3 h-3" />
Register
<ShoppingCart className="w-4 h-4" />
Buy Now
</a>
) : (
<button
onClick={() => handleToggleNotify(domain.id, domain.notify_on_available)}
disabled={togglingNotifyId === domain.id}
className={clsx(
"flex-1 py-2 text-[10px] font-bold uppercase tracking-wider border flex items-center justify-center gap-1.5 transition-all",
"flex-1 py-2.5 text-[10px] font-bold uppercase tracking-wider border flex items-center justify-center gap-1.5 transition-all",
domain.notify_on_available
? "border-accent bg-accent/10 text-accent"
: "border-white/[0.08] text-white/40"
? "border-accent/30 bg-accent/10 text-accent"
: "border-white/10 bg-white/[0.02] text-white/40"
)}
>
{togglingNotifyId === domain.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : domain.notify_on_available ? (
<Bell className="w-3 h-3" />
<Bell className="w-3.5 h-3.5" />
) : (
<BellOff className="w-3 h-3" />
<BellOff className="w-3.5 h-3.5" />
)}
Alert
{domain.notify_on_available ? 'Alert ON' : 'Set Alert'}
</button>
)}
<button
onClick={() => handleRefresh(domain.id)}
disabled={refreshingId === domain.id}
className="px-4 py-2 border border-white/[0.08] text-white/40"
className="px-3 py-2 border border-white/10 text-white/40 hover:bg-white/5"
>
<RefreshCw className={clsx("w-4 h-4", refreshingId === domain.id && "animate-spin")} />
</button>
<button
onClick={() => openAnalyze(domain.name)}
className="px-4 py-2 border border-white/[0.08] text-white/40 hover:text-white hover:bg-white/5"
className="px-3 py-2 border border-white/10 text-white/40 hover:text-accent hover:border-accent/20 hover:bg-accent/10"
title="Analyze"
>
<Shield className="w-4 h-4" />
@ -567,7 +636,7 @@ export default function WatchlistPage() {
<button
onClick={() => handleDelete(domain.id, domain.name)}
disabled={deletingId === domain.id}
className="px-4 py-2 border border-white/[0.08] text-white/40 hover:text-rose-400 hover:border-rose-400/20 hover:bg-rose-400/5"
className="px-3 py-2 border border-white/10 text-white/40 hover:text-rose-400 hover:border-rose-400/20 hover:bg-rose-400/5"
>
{deletingId === domain.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
@ -579,21 +648,27 @@ export default function WatchlistPage() {
</div>
{/* Desktop Row */}
<div className="hidden lg:grid grid-cols-[1fr_80px_90px_90px_60px_100px] gap-4 items-center p-3 group">
<div className={clsx(
"hidden lg:grid grid-cols-[1.5fr_100px_100px_100px_80px_160px] gap-4 items-center p-4 group border border-white/[0.06] transition-all",
domain.is_available
? "bg-accent/[0.02] hover:bg-accent/[0.05] border-accent/20"
: "bg-[#020202] hover:bg-white/[0.02]"
)}>
{/* Domain */}
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className={clsx(
"w-8 h-8 flex items-center justify-center border shrink-0",
"w-10 h-10 flex items-center justify-center border shrink-0",
domain.is_available
? "bg-accent/10 border-accent/20"
? "bg-accent/10 border-accent/30"
: "bg-white/[0.02] border-white/[0.06]"
)}>
{domain.is_available ? (
<CheckCircle2 className="w-4 h-4 text-accent" />
<CheckCircle2 className="w-5 h-5 text-accent" />
) : (
<Eye className="w-4 h-4 text-white/30" />
)}
</div>
<div className="min-w-0">
<div className="min-w-0 flex-1">
<button
onClick={() => openAnalyze(domain.name)}
className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left"
@ -602,99 +677,115 @@ export default function WatchlistPage() {
{domain.name}
</button>
<div className="text-[10px] font-mono text-white/30">
{domain.registrar || 'Unknown'}
{domain.registrar || 'Unknown registrar'}
</div>
</div>
<a href={`https://${domain.name}`} target="_blank" className="opacity-0 group-hover:opacity-50 hover:!opacity-100 transition-opacity ml-2">
<a href={`https://${domain.name}`} target="_blank" className="opacity-0 group-hover:opacity-50 hover:!opacity-100 transition-opacity">
<ExternalLink className="w-3.5 h-3.5 text-white/40" />
</a>
</div>
{/* Status */}
<div className="w-20 shrink-0">
<div className="flex justify-center">
<span className={clsx(
"text-[10px] font-mono font-bold uppercase px-2 py-0.5",
domain.is_available ? "text-accent bg-accent/10" : "text-white/30 bg-white/5"
"text-[10px] font-mono font-bold uppercase px-2.5 py-1 border",
domain.is_available
? "text-accent bg-accent/10 border-accent/30"
: "text-white/40 bg-white/5 border-white/10"
)}>
{domain.is_available ? 'AVAIL' : 'TAKEN'}
{domain.is_available ? ' AVAIL' : 'TAKEN'}
</span>
</div>
{/* Health */}
<button
onClick={() => { setSelectedDomain(domain.id); handleHealthCheck(domain.id) }}
className="w-24 flex items-center gap-1.5 hover:opacity-80 transition-opacity shrink-0"
>
{loadingHealth[domain.id] ? (
<Loader2 className="w-3.5 h-3.5 animate-spin text-white/30" />
) : (
<>
<Activity className={clsx("w-3.5 h-3.5", config.color)} />
<span className={clsx("text-xs font-mono", config.color)}>{config.label}</span>
</>
)}
</button>
<div className="flex justify-center">
<button
onClick={() => { setSelectedDomain(domain.id); handleHealthCheck(domain.id) }}
className={clsx(
"flex items-center gap-1.5 px-2 py-1 text-[10px] font-mono uppercase border transition-colors hover:opacity-80",
config.color,
config.bg.replace('bg-', 'bg-'),
"border-white/10"
)}
>
{loadingHealth[domain.id] ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<>
<Activity className="w-3 h-3" />
{config.label}
</>
)}
</button>
</div>
{/* Expires */}
<div className="w-24 text-xs font-mono text-white/50 shrink-0">
<div className="text-center text-xs font-mono">
{days !== null && days <= 30 && days > 0 ? (
<span className="text-orange-400 font-bold">{days}d</span>
<span className="text-orange-400 font-bold">{days}d left</span>
) : (
formatExpiryDate(domain.expiration_date)
<span className="text-white/50">{formatExpiryDate(domain.expiration_date)}</span>
)}
</div>
{/* Alert */}
<button
onClick={() => handleToggleNotify(domain.id, domain.notify_on_available)}
disabled={togglingNotifyId === domain.id}
className={clsx(
"w-8 h-8 flex items-center justify-center border transition-colors shrink-0",
domain.notify_on_available
? "text-accent border-accent/20 bg-accent/10"
: "text-white/20 border-white/10 hover:text-white/40"
)}
>
{togglingNotifyId === domain.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : domain.notify_on_available ? (
<Bell className="w-3.5 h-3.5" />
) : (
<BellOff className="w-3.5 h-3.5" />
)}
</button>
<div className="flex justify-center">
<button
onClick={() => handleToggleNotify(domain.id, domain.notify_on_available)}
disabled={togglingNotifyId === domain.id}
className={clsx(
"w-9 h-9 flex items-center justify-center border transition-colors",
domain.notify_on_available
? "text-accent border-accent/30 bg-accent/10"
: "text-white/20 border-white/10 hover:text-white/40 hover:bg-white/5"
)}
>
{togglingNotifyId === domain.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : domain.notify_on_available ? (
<Bell className="w-4 h-4" />
) : (
<BellOff className="w-4 h-4" />
)}
</button>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0 opacity-50 group-hover:opacity-100 transition-opacity">
{domain.is_available && (
<div className="flex items-center justify-end gap-1.5">
{domain.is_available ? (
<a
href={`https://www.namecheap.com/domains/registration/results/?domain=${domain.name}`}
target="_blank"
rel="noopener noreferrer"
className="h-7 px-3 bg-accent text-black text-xs font-bold flex items-center gap-1.5 hover:bg-white transition-colors"
className="h-9 px-4 bg-accent text-black text-[10px] font-bold uppercase tracking-wider flex items-center gap-2 hover:bg-white transition-colors"
>
Register
<ExternalLink className="w-3 h-3" />
<ShoppingCart className="w-3.5 h-3.5" />
Buy Now
</a>
) : (
<>
<button
onClick={() => handleRefresh(domain.id)}
disabled={refreshingId === domain.id}
title="Refresh"
className="w-8 h-8 flex items-center justify-center text-white/30 hover:text-white border border-white/10 hover:bg-white/5 transition-all"
>
<RefreshCw className={clsx("w-3.5 h-3.5", refreshingId === domain.id && "animate-spin")} />
</button>
<button
onClick={() => openAnalyze(domain.name)}
title="Analyze"
className="w-8 h-8 flex items-center justify-center text-white/30 hover:text-accent border border-white/10 hover:bg-accent/10 hover:border-accent/20 transition-all"
>
<Shield className="w-3.5 h-3.5" />
</button>
</>
)}
<button
onClick={() => handleRefresh(domain.id)}
disabled={refreshingId === domain.id}
className="w-7 h-7 flex items-center justify-center text-white/20 hover:text-white border border-white/10 hover:bg-white/5 transition-all"
>
<RefreshCw className={clsx("w-3.5 h-3.5", refreshingId === domain.id && "animate-spin")} />
</button>
<button
onClick={() => openAnalyze(domain.name)}
className="w-7 h-7 flex items-center justify-center text-white/20 hover:text-accent border border-white/10 hover:bg-accent/10 hover:border-accent/20 transition-all"
title="Analyze"
>
<Shield className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDelete(domain.id, domain.name)}
disabled={deletingId === domain.id}
className="w-7 h-7 flex items-center justify-center text-white/20 hover:text-rose-400 border border-white/10 hover:border-rose-400/20 hover:bg-rose-500/10 transition-all"
title="Remove"
className="w-8 h-8 flex items-center justify-center text-white/30 hover:text-rose-400 border border-white/10 hover:border-rose-400/20 hover:bg-rose-500/10 transition-all"
>
{deletingId === domain.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
@ -995,6 +1086,14 @@ export default function WatchlistPage() {
)}
</main>
{/* ADD MODAL */}
{showAddModal && (
<AddModal
onClose={() => setShowAddModal(false)}
onAdd={handleAdd}
/>
)}
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
</div>
)

View File

@ -8,12 +8,15 @@ import {
Shield,
Sparkles,
Eye,
RefreshCw,
Wand2,
Settings,
ChevronRight,
Zap,
Filter,
Copy,
Check,
ShoppingCart,
Star,
Lightbulb,
RefreshCw,
} from 'lucide-react'
import { api } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store'
@ -24,24 +27,39 @@ import { useStore } from '@/lib/store'
// ============================================================================
const PATTERNS = [
{ key: 'cvcvc', label: 'CVCVC', desc: '5-letter brandables (Zalor, Mivex)' },
{ key: 'cvccv', label: 'CVCCV', desc: '5-letter variants (Bento, Salvo)' },
{ key: 'human', label: 'Human', desc: '2-syllable names (Siri, Alexa)' },
{
key: 'cvcvc',
label: 'CVCVC',
desc: 'Classic 5-letter brandables',
examples: ['Zalor', 'Mivex', 'Ronix'],
color: 'accent'
},
{
key: 'cvccv',
label: 'CVCCV',
desc: 'Punchy 5-letter names',
examples: ['Bento', 'Salvo', 'Vento'],
color: 'blue'
},
{
key: 'human',
label: 'Human',
desc: 'AI agent ready names',
examples: ['Siri', 'Alexa', 'Levi'],
color: 'purple'
},
]
const TLDS = ['com', 'io', 'ai', 'co', 'net', 'org']
// ============================================================================
// HELPERS
// ============================================================================
function parseTlds(input: string): string[] {
return input
.split(',')
.map((t) => t.trim().toLowerCase().replace(/^\./, ''))
.filter(Boolean)
.slice(0, 10)
}
const TLDS = [
{ tld: 'com', premium: true, label: '.com' },
{ tld: 'io', premium: true, label: '.io' },
{ tld: 'ai', premium: true, label: '.ai' },
{ tld: 'co', premium: false, label: '.co' },
{ tld: 'net', premium: false, label: '.net' },
{ tld: 'org', premium: false, label: '.org' },
{ tld: 'app', premium: false, label: '.app' },
{ tld: 'dev', premium: false, label: '.dev' },
]
// ============================================================================
// COMPONENT
@ -53,7 +71,7 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
// Config State
const [pattern, setPattern] = useState('cvcvc')
const [selectedTlds, setSelectedTlds] = useState<string[]>(['com'])
const [selectedTlds, setSelectedTlds] = useState<string[]>(['com', 'io'])
const [limit, setLimit] = useState(30)
const [showConfig, setShowConfig] = useState(false)
@ -62,6 +80,7 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
const [items, setItems] = useState<Array<{ domain: string; status: string }>>([])
const [error, setError] = useState<string | null>(null)
const [tracking, setTracking] = useState<string | null>(null)
const [copied, setCopied] = useState<string | null>(null)
const toggleTld = useCallback((tld: string) => {
setSelectedTlds((prev) =>
@ -69,6 +88,18 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
)
}, [])
const copyDomain = useCallback((domain: string) => {
navigator.clipboard.writeText(domain)
setCopied(domain)
setTimeout(() => setCopied(null), 1500)
}, [])
const copyAll = useCallback(() => {
if (items.length === 0) return
navigator.clipboard.writeText(items.map(i => i.domain).join('\n'))
showToast(`Copied ${items.length} domains to clipboard`, 'success')
}, [items, showToast])
const run = useCallback(async () => {
if (selectedTlds.length === 0) {
showToast('Select at least one TLD', 'error')
@ -76,11 +107,14 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
}
setLoading(true)
setError(null)
setItems([])
try {
const res = await api.huntBrandables({ pattern, tlds: selectedTlds, limit, max_checks: 400 })
setItems(res.items.map((i) => ({ domain: i.domain, status: i.status })))
if (res.items.length === 0) {
showToast('No available domains found. Try different settings.', 'info')
} else {
showToast(`Found ${res.items.length} available brandable domains!`, 'success')
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
@ -98,7 +132,7 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
setTracking(domain)
try {
await addDomain(domain)
showToast(`Tracked ${domain}`, 'success')
showToast(`Added to watchlist: ${domain}`, 'success')
} catch (e) {
showToast(e instanceof Error ? e.message : 'Failed to track domain', 'error')
} finally {
@ -108,248 +142,343 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
[addDomain, showToast, tracking]
)
const currentPattern = PATTERNS.find(p => p.key === pattern)
return (
<div className="space-y-4">
{/* Header with Generate Button */}
<div className="space-y-6">
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* MAIN GENERATOR CARD */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
<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-accent/10 border border-accent/20 flex items-center justify-center">
<Wand2 className="w-4 h-4 text-accent" />
{/* Header */}
<div className="px-4 py-4 border-b border-white/[0.08]">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-accent/20 to-purple-500/10 border border-accent/30 flex items-center justify-center">
<Wand2 className="w-5 h-5 text-accent" />
</div>
<div>
<h3 className="text-base font-bold text-white">Brandable Forge</h3>
<p className="text-[11px] font-mono text-white/40">
AI-powered brandable name generator
</p>
</div>
</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 className="flex items-center gap-2">
<button
onClick={() => setShowConfig(!showConfig)}
className={clsx(
"w-9 h-9 flex items-center justify-center border transition-all",
showConfig
? "border-accent/30 bg-accent/10 text-accent"
: "border-white/10 text-white/40 hover:text-white hover:bg-white/5"
)}
title="Settings"
>
<Settings className="w-4 h-4" />
</button>
<button
onClick={run}
disabled={loading || selectedTlds.length === 0}
className={clsx(
"h-9 px-5 text-sm font-bold uppercase tracking-wider transition-all flex items-center gap-2",
loading || selectedTlds.length === 0
? "bg-white/5 text-white/20 cursor-not-allowed"
: "bg-accent text-black hover:bg-white"
)}
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Generating...
</>
) : (
<>
<Sparkles className="w-4 h-4" />
Generate
</>
)}
</button>
</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 className="p-4 border-b border-white/[0.08]">
<div className="flex items-center gap-2 mb-3">
<Lightbulb className="w-3.5 h-3.5 text-white/30" />
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Choose Pattern</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{PATTERNS.map((p) => {
const isActive = pattern === p.key
const colorClass = p.color === 'accent' ? 'accent' : p.color === 'blue' ? 'blue-400' : 'purple-400'
return (
<button
key={p.key}
onClick={() => setPattern(p.key)}
className={clsx(
"p-4 border text-left transition-all group",
isActive
? `border-${colorClass}/40 bg-${colorClass}/10`
: "border-white/[0.08] hover:border-white/20 bg-white/[0.02] hover:bg-white/[0.04]"
)}
>
<div className="flex items-center justify-between mb-2">
<span className={clsx(
"text-sm font-bold font-mono",
isActive ? `text-${colorClass}` : "text-white/70 group-hover:text-white"
)}>
{p.label}
</span>
{isActive && (
<div className={`w-2 h-2 rounded-full bg-${colorClass}`} />
)}
</div>
<p className="text-[11px] text-white/40 mb-2">{p.desc}</p>
<div className="flex items-center gap-1.5">
{p.examples.map((ex, i) => (
<span
key={ex}
className={clsx(
"text-[10px] font-mono px-1.5 py-0.5 border",
isActive
? "text-white/60 border-white/20 bg-white/5"
: "text-white/30 border-white/10"
)}
>
{ex}
</span>
))}
</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 className="p-4 border-b border-white/[0.08]">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Select TLDs</span>
<span className="text-[10px] font-mono text-white/20">({selectedTlds.length} selected)</span>
</div>
<button
onClick={() => setSelectedTlds(selectedTlds.length === TLDS.length ? ['com'] : TLDS.map(t => t.tld))}
className="text-[10px] font-mono text-accent hover:text-white transition-colors"
>
{selectedTlds.length === TLDS.length ? 'Select .com only' : 'Select all'}
</button>
</div>
<div className="flex gap-2 flex-wrap">
{TLDS.map((tld) => (
<div className="flex flex-wrap gap-2">
{TLDS.map((t) => (
<button
key={tld}
onClick={() => toggleTld(tld)}
key={t.tld}
onClick={() => toggleTld(t.tld)}
className={clsx(
"px-3 py-1.5 text-[10px] font-mono uppercase border transition-colors",
selectedTlds.includes(tld)
"px-3 py-2 text-[11px] font-mono uppercase border transition-all flex items-center gap-1.5",
selectedTlds.includes(t.tld)
? "border-accent bg-accent/10 text-accent"
: "border-white/[0.08] text-white/40 hover:text-white/60"
: "border-white/[0.08] text-white/40 hover:text-white/60 hover:border-white/20"
)}
>
.{tld}
{t.premium && <Star className="w-3 h-3" />}
{t.label}
</button>
))}
</div>
</div>
{/* Advanced Config (collapsed) */}
{/* Advanced Config */}
{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 className="p-4 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-6">
<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}
/>
<label className="block text-[10px] font-mono text-white/40 mb-1.5 uppercase tracking-wider">Results Count</label>
<div className="flex items-center gap-2">
<input
type="range"
value={limit}
onChange={(e) => setLimit(Number(e.target.value))}
min={10}
max={100}
step={10}
className="w-32 accent-accent"
/>
<span className="text-sm font-mono text-white w-8">{limit}</span>
</div>
</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 className="flex-1 text-[10px] font-mono text-white/30 border-l border-white/10 pl-6">
<p>We'll check up to 400 random combinations and return the first {limit} verified available domains.</p>
</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
<div className="px-4 py-3 flex items-center justify-between bg-white/[0.01]">
<span className="text-[11px] font-mono text-white/40">
{items.length > 0 ? (
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent animate-pulse" />
{items.length} brandable domains ready
</span>
) : (
'Configure settings and click Generate'
)}
</span>
{items.length > 0 && (
<button
onClick={copyAll}
className="flex items-center gap-1.5 text-[10px] font-mono text-accent hover:text-white transition-colors"
>
<Copy className="w-3 h-3" />
Copy All
</button>
)}
</div>
</div>
{/* Error Message */}
{error && (
<div className="p-3 border border-red-500/20 bg-red-500/5 text-xs font-mono text-red-400">
{error}
<div className="p-4 border border-rose-500/20 bg-rose-500/5 flex items-center gap-3">
<div className="w-8 h-8 bg-rose-500/10 border border-rose-500/20 flex items-center justify-center shrink-0">
<Zap className="w-4 h-4 text-rose-400" />
</div>
<div>
<p className="text-xs font-mono text-rose-400">{error}</p>
<button onClick={run} className="text-[10px] font-mono text-rose-400/60 hover:text-rose-400 mt-1">
Try again →
</button>
</div>
</div>
)}
{/* Results Grid */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* RESULTS */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{items.length > 0 && (
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
{/* Desktop Header */}
<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]">
<span>Domain</span>
<span className="text-center">Status</span>
<span className="text-right">Actions</span>
<div className="space-y-2">
<div className="flex items-center justify-between px-1">
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">
Generated Domains
</span>
<button
onClick={run}
disabled={loading}
className="flex items-center gap-1.5 text-[10px] font-mono text-white/40 hover:text-accent transition-colors"
>
<RefreshCw className={clsx("w-3 h-3", loading && "animate-spin")} />
Regenerate
</button>
</div>
{items.map((i) => (
<div key={i.domain} className="bg-[#020202] hover:bg-white/[0.02] transition-all">
{/* Mobile Row */}
<div className="lg:hidden p-3">
<div className="flex items-center justify-between gap-3 mb-3">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
{items.map((i, idx) => (
<div
key={i.domain}
className={clsx(
"group p-3 border bg-[#020202] hover:bg-accent/[0.03] transition-all",
"border-white/[0.06] hover:border-accent/20"
)}
>
<div className="flex items-center justify-between gap-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 className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center shrink-0 text-[10px] font-mono text-accent font-bold">
{String(idx + 1).padStart(2, '0')}
</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="flex items-center gap-1.5 shrink-0">
<span className="hidden sm:inline-flex text-[9px] font-mono font-bold text-accent bg-accent/10 px-2 py-1 border border-accent/20">
AVAIL
</span>
<button
onClick={() => copyDomain(i.domain)}
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-all"
title="Copy"
>
{copied === i.domain ? <Check className="w-3.5 h-3.5 text-accent" /> : <Copy className="w-3.5 h-3.5" />}
</button>
<button
onClick={() => track(i.domain)}
disabled={tracking === i.domain}
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-all"
title="Add to Watchlist"
>
{tracking === i.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
</button>
<button
onClick={() => openAnalyze(i.domain)}
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/10 transition-all"
title="Analyze"
>
<Shield className="w-3.5 h-3.5" />
</button>
<a
href={`https://www.namecheap.com/domains/registration/results/?domain=${encodeURIComponent(i.domain)}`}
target="_blank"
rel="noopener noreferrer"
className="h-8 px-3 bg-accent text-black text-[10px] font-bold uppercase flex items-center gap-1.5 hover:bg-white transition-colors"
>
<ShoppingCart className="w-3 h-3" />
<span className="hidden sm:inline">Buy</span>
</a>
</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
href={`https://www.namecheap.com/domains/registration/results/?domain=${encodeURIComponent(i.domain)}`}
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>
))}
</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>
{/* Empty State */}
{items.length === 0 && !loading && (
<div className="text-center py-16 border border-dashed border-white/[0.08] bg-white/[0.01]">
<div className="w-16 h-16 mx-auto mb-4 bg-accent/5 border border-accent/20 flex items-center justify-center">
<Wand2 className="w-8 h-8 text-accent/40" />
</div>
<h3 className="text-white/60 text-sm font-medium mb-1">Ready to forge</h3>
<p className="text-white/30 text-xs font-mono max-w-xs mx-auto">
Select a pattern and TLDs, then click "Generate" to discover available brandable domain names
</p>
<div className="mt-6 flex items-center justify-center gap-3 text-[10px] font-mono text-white/20">
<span className="flex items-center gap-1"><Zap className="w-3 h-3" /> Verified available</span>
<span></span>
<span className="flex items-center gap-1"><Shield className="w-3 h-3" /> DNS checked</span>
</div>
</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
onClick={() => track(i.domain)}
disabled={tracking === i.domain}
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-colors"
>
{tracking === i.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
</button>
<button
onClick={() => openAnalyze(i.domain)}
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/10 transition-colors"
>
<Shield className="w-3.5 h-3.5" />
</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>
{/* Loading State */}
{loading && items.length === 0 && (
<div className="space-y-2">
{[...Array(6)].map((_, i) => (
<div key={i} className="p-3 border border-white/[0.06] bg-[#020202] animate-pulse">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-white/10 rounded" />
<div className="h-4 w-32 bg-white/10 rounded" />
<div className="ml-auto flex gap-2">
<div className="w-8 h-8 bg-white/5 rounded" />
<div className="w-8 h-8 bg-white/5 rounded" />
<div className="w-16 h-8 bg-white/5 rounded" />
</div>
</div>
</div>
))}
</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>
)}
</div>
)
}

View File

@ -11,16 +11,36 @@ import {
Eye,
TrendingUp,
RefreshCw,
Filter,
ChevronRight,
Globe,
Zap,
X
X,
Check,
Copy,
ShoppingCart,
Flame,
ArrowRight,
AlertCircle
} from 'lucide-react'
import { api } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store'
import { useStore } from '@/lib/store'
// ============================================================================
// TYPES & CONSTANTS
// ============================================================================
const GEO_OPTIONS = [
{ value: 'US', label: 'United States', flag: '🇺🇸' },
{ value: 'CH', label: 'Switzerland', flag: '🇨🇭' },
{ value: 'DE', label: 'Germany', flag: '🇩🇪' },
{ value: 'GB', label: 'United Kingdom', flag: '🇬🇧' },
{ value: 'FR', label: 'France', flag: '🇫🇷' },
{ value: 'CA', label: 'Canada', flag: '🇨🇦' },
{ value: 'AU', label: 'Australia', flag: '🇦🇺' },
]
const POPULAR_TLDS = ['com', 'io', 'ai', 'co', 'net', 'org', 'app', 'dev']
// ============================================================================
// HELPERS
// ============================================================================
@ -48,6 +68,7 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
// Keyword Check State
const [keywordInput, setKeywordInput] = useState('')
const [keywordFocused, setKeywordFocused] = useState(false)
const [selectedTlds, setSelectedTlds] = useState<string[]>(['com', 'io', 'ai'])
const [availability, setAvailability] = useState<Array<{ domain: string; status: string; is_available: boolean | null }>>([])
const [checking, setChecking] = useState(false)
@ -57,8 +78,15 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
const [typos, setTypos] = useState<Array<{ domain: string; status: string }>>([])
const [typoLoading, setTypoLoading] = useState(false)
// Tracking State
// Tracking & Copy State
const [tracking, setTracking] = useState<string | null>(null)
const [copied, setCopied] = useState<string | null>(null)
const copyDomain = useCallback((domain: string) => {
navigator.clipboard.writeText(domain)
setCopied(domain)
setTimeout(() => setCopied(null), 1500)
}, [])
const track = useCallback(
async (domain: string) => {
@ -66,7 +94,7 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
setTracking(domain)
try {
await addDomain(domain)
showToast(`Tracked ${domain}`, 'success')
showToast(`Added to watchlist: ${domain}`, 'success')
} catch (e) {
showToast(e instanceof Error ? e.message : 'Failed to track domain', 'error')
} finally {
@ -86,12 +114,11 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
setError(msg)
showToast(msg, 'error')
setTrends([])
} finally {
if (isRefresh) setRefreshing(false)
}
}, [geo, selected, showToast])
}, [geo, selected])
useEffect(() => {
let cancelled = false
@ -111,12 +138,22 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
const keyword = useMemo(() => normalizeKeyword(keywordInput || selected || ''), [keywordInput, selected])
const toggleTld = useCallback((tld: string) => {
setSelectedTlds(prev =>
prev.includes(tld) ? prev.filter(t => t !== tld) : [...prev, tld]
)
}, [])
const runCheck = useCallback(async () => {
if (!keyword) return
if (selectedTlds.length === 0) {
showToast('Select at least one TLD', 'error')
return
}
setChecking(true)
try {
const kw = keyword.toLowerCase().replace(/\s+/g, '')
const res = await api.huntKeywords({ keywords: [kw], tlds: ['com', 'io', 'ai', 'net', 'org'] })
const res = await api.huntKeywords({ keywords: [kw], tlds: selectedTlds })
setAvailability(res.items.map((r) => ({ domain: r.domain, status: r.status, is_available: r.is_available })))
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to check availability'
@ -125,7 +162,7 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
} finally {
setChecking(false)
}
}, [keyword, showToast])
}, [keyword, selectedTlds, showToast])
const runTypos = useCallback(async () => {
const b = brand.trim()
@ -134,6 +171,9 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
try {
const res = await api.huntTypos({ brand: b, tlds: ['com'], limit: 50 })
setTypos(res.items.map((i) => ({ domain: i.domain, status: i.status })))
if (res.items.length === 0) {
showToast('No available typo domains found', 'info')
}
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to run typo check'
showToast(msg, 'error')
@ -143,116 +183,169 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
}
}, [brand, showToast])
const availableCount = useMemo(() => availability.filter(a => a.status === 'available').length, [availability])
const currentGeo = GEO_OPTIONS.find(g => g.value === geo)
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
<div className="space-y-4">
{/* Skeleton Loader */}
<div className="border border-white/[0.08] bg-[#020202] animate-pulse">
<div className="px-4 py-4 border-b border-white/[0.08]">
<div className="h-5 w-48 bg-white/10 rounded mb-2" />
<div className="h-3 w-32 bg-white/5 rounded" />
</div>
<div className="p-4 flex flex-wrap gap-2">
{[...Array(8)].map((_, i) => (
<div key={i} className="h-10 w-24 bg-white/5 rounded" />
))}
</div>
</div>
</div>
)
}
return (
<div className="space-y-4">
{/* Trends Header */}
<div className="space-y-6">
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* TRENDING TOPICS */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
<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-accent/10 border border-accent/20 flex items-center justify-center">
<TrendingUp className="w-4 h-4 text-accent" />
<div className="px-4 py-4 border-b border-white/[0.08]">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-accent/20 to-accent/5 border border-accent/30 flex items-center justify-center">
<Flame className="w-5 h-5 text-accent" />
</div>
<div>
<h3 className="text-base font-bold text-white">Trending Now</h3>
<p className="text-[11px] font-mono text-white/40">
Real-time Google Trends {currentGeo?.flag} {currentGeo?.label}
</p>
</div>
</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 className="flex items-center gap-2">
<select
value={geo}
onChange={(e) => { setGeo(e.target.value); setSelected(''); setAvailability([]) }}
className="bg-white/[0.03] border border-white/10 px-3 py-2 text-xs font-mono text-white/70 outline-none focus:border-accent/40 cursor-pointer hover:bg-white/[0.05] transition-colors"
>
{GEO_OPTIONS.map(g => (
<option key={g.value} value={g.value}>{g.flag} {g.label}</option>
))}
</select>
<button
onClick={() => loadTrends(true)}
disabled={refreshing}
className="w-9 h-9 flex items-center justify-center border border-white/10 text-white/40 hover:text-white hover:bg-white/5 transition-all"
>
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
</button>
</div>
</div>
<div className="flex items-center gap-2">
<select
value={geo}
onChange={(e) => setGeo(e.target.value)}
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"
>
<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>
</div>
</div>
{error ? (
<div className="p-4 text-xs font-mono text-red-400 bg-red-500/5">{error}</div>
<div className="p-4 flex items-center gap-3 bg-rose-500/5 border-b border-rose-500/20">
<AlertCircle className="w-4 h-4 text-rose-400 shrink-0" />
<p className="text-xs font-mono text-rose-400">{error}</p>
<button
onClick={() => loadTrends(true)}
className="ml-auto text-[10px] font-mono text-rose-400 underline hover:no-underline"
>
Retry
</button>
</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 className="p-4">
<div className="flex flex-wrap gap-2">
{trends.slice(0, 16).map((t, idx) => {
const active = selected === t.title
const isHot = idx < 3
return (
<button
key={t.title}
onClick={() => {
setSelected(t.title)
setKeywordInput('')
setAvailability([])
}}
className={clsx(
'group relative px-4 py-2.5 border text-left transition-all',
active
? 'border-accent bg-accent/10'
: 'border-white/[0.08] hover:border-white/20 bg-white/[0.02] hover:bg-white/[0.04]'
)}
>
<div className="flex items-center gap-2">
{isHot && (
<span className="text-[9px] font-bold text-orange-400 bg-orange-400/10 px-1 py-0.5">
🔥
</span>
)}
<span className={clsx(
"text-xs font-medium truncate max-w-[140px]",
active ? "text-accent" : "text-white/70 group-hover:text-white"
)}>
{t.title}
</span>
</div>
{t.approx_traffic && (
<div className="text-[9px] text-white/30 mt-0.5 font-mono">{t.approx_traffic}</div>
)}
</button>
)
})}
</div>
{trends.length === 0 && (
<div className="text-center py-6 text-white/30 text-xs font-mono">
No trends available for this region
</div>
)}
</div>
)}
</div>
{/* Keyword Availability Check */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* DOMAIN AVAILABILITY CHECKER */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
<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-4 border-b border-white/[0.08]">
<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 className="w-10 h-10 bg-white/[0.03] border border-white/[0.08] flex items-center justify-center">
<Globe className="w-5 h-5 text-white/50" />
</div>
<div>
<div className="text-sm font-bold text-white">Domain Availability</div>
<div className="text-[10px] font-mono text-white/40">Check {keyword || 'keyword'} across TLDs</div>
<h3 className="text-base font-bold text-white">Check Availability</h3>
<p className="text-[11px] font-mono text-white/40">
{keyword ? `Find ${keyword.toLowerCase().replace(/\s+/g, '')} across multiple TLDs` : 'Select a trend or enter a keyword'}
</p>
</div>
</div>
</div>
<div className="p-4">
<div className="flex gap-2 mb-4">
<div className="p-4 space-y-4">
{/* Keyword Input */}
<div className="flex gap-2">
<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]"
"flex-1 relative border-2 transition-all",
keywordFocused ? "border-accent bg-accent/[0.03]" : "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")} />
<Search className={clsx("w-4 h-4 ml-4 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"
onKeyDown={(e) => e.key === 'Enter' && runCheck()}
placeholder="Enter keyword or select trend above..."
className="flex-1 bg-transparent px-3 py-3.5 text-sm text-white placeholder:text-white/25 outline-none font-mono"
/>
{(keywordInput || selected) && (
<button
onClick={() => { setKeywordInput(''); setSelected(''); setAvailability([]) }}
className="p-3 text-white/30 hover:text-white"
className="p-3 text-white/30 hover:text-white transition-colors"
>
<X className="w-4 h-4" />
</button>
@ -263,107 +356,179 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
onClick={runCheck}
disabled={!keyword || checking}
className={clsx(
"px-4 py-3 text-xs font-bold uppercase tracking-wider transition-all",
"px-6 py-3 text-sm font-bold uppercase tracking-wider transition-all flex items-center gap-2",
!keyword || checking
? "bg-white/5 text-white/20"
? "bg-white/5 text-white/20 cursor-not-allowed"
: "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" /> : <Zap className="w-4 h-4" />}
Check
</button>
</div>
{/* Results Grid */}
{availability.length > 0 && (
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
{availability.map((a) => (
<div key={a.domain} className="bg-[#020202] hover:bg-white/[0.02] transition-colors p-3 flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className={clsx(
"w-2 h-2 rounded-full shrink-0",
a.status === 'available' ? "bg-accent" : "bg-white/20"
)} />
<button
onClick={() => openAnalyze(a.domain)}
className="text-sm font-mono text-white/70 hover:text-accent truncate text-left"
>
{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()}
</span>
<button
onClick={() => track(a.domain)}
disabled={tracking === a.domain}
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-colors"
>
{tracking === a.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
</button>
<button
onClick={() => openAnalyze(a.domain)}
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/10 transition-colors"
>
<Shield className="w-3.5 h-3.5" />
</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>
{/* TLD Selection */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Select TLDs</span>
<span className="text-[10px] font-mono text-white/20">({selectedTlds.length} selected)</span>
</div>
<div className="flex flex-wrap gap-1.5">
{POPULAR_TLDS.map(tld => (
<button
key={tld}
onClick={() => toggleTld(tld)}
className={clsx(
"px-3 py-1.5 text-[11px] font-mono uppercase border transition-all",
selectedTlds.includes(tld)
? "border-accent bg-accent/10 text-accent"
: "border-white/[0.08] text-white/40 hover:text-white/60 hover:border-white/20"
)}
>
.{tld}
</button>
))}
</div>
</div>
{/* Results */}
{availability.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">
Results {availableCount} available
</span>
</div>
<div className="space-y-1">
{availability.map((a) => {
const isAvailable = a.status === 'available'
return (
<div
key={a.domain}
className={clsx(
"p-3 flex items-center justify-between gap-3 border transition-all",
isAvailable
? "bg-accent/[0.03] border-accent/20 hover:bg-accent/[0.06]"
: "bg-white/[0.02] border-white/[0.06] hover:bg-white/[0.04]"
)}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className={clsx(
"w-2.5 h-2.5 rounded-full shrink-0",
isAvailable ? "bg-accent" : "bg-white/20"
)} />
<button
onClick={() => openAnalyze(a.domain)}
className={clsx(
"text-sm font-mono truncate text-left transition-colors",
isAvailable ? "text-white hover:text-accent" : "text-white/50"
)}
>
{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-1 border",
isAvailable
? "text-accent bg-accent/10 border-accent/30"
: "text-white/30 bg-white/5 border-white/10"
)}>
{isAvailable ? '✓ AVAIL' : 'TAKEN'}
</span>
<button
onClick={() => copyDomain(a.domain)}
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-all"
title="Copy"
>
{copied === a.domain ? <Check className="w-3.5 h-3.5 text-accent" /> : <Copy className="w-3.5 h-3.5" />}
</button>
<button
onClick={() => track(a.domain)}
disabled={tracking === a.domain}
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-all"
title="Add to Watchlist"
>
{tracking === a.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
</button>
<button
onClick={() => openAnalyze(a.domain)}
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/10 transition-all"
title="Analyze"
>
<Shield className="w-3.5 h-3.5" />
</button>
{isAvailable && (
<a
href={`https://www.namecheap.com/domains/registration/results/?domain=${a.domain}`}
target="_blank"
rel="noopener noreferrer"
className="h-8 px-3 bg-accent text-black text-[10px] font-bold uppercase flex items-center gap-1.5 hover:bg-white transition-colors"
>
<ShoppingCart className="w-3 h-3" />
Buy
</a>
)}
</div>
</div>
)
})}
</div>
</div>
)}
{/* Empty State */}
{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 className="text-center py-10 border border-dashed border-white/[0.08] bg-white/[0.01]">
<Zap className="w-8 h-8 text-white/10 mx-auto mb-3" />
<p className="text-white/40 text-sm font-mono mb-1">Ready to check</p>
<p className="text-white/25 text-xs font-mono">
Click "Check" to find available domains for <span className="text-accent">{keyword.toLowerCase().replace(/\s+/g, '')}</span>
</p>
</div>
)}
</div>
</div>
{/* Typo Finder */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* 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="px-4 py-4 border-b border-white/[0.08]">
<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 className="w-10 h-10 bg-purple-500/10 border border-purple-500/20 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-purple-400" />
</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>
<h3 className="text-base font-bold text-white">Typo Finder</h3>
<p className="text-[11px] font-mono text-white/40">
Find available misspellings of popular brands
</p>
</div>
</div>
</div>
<div className="p-4">
<div className="flex gap-2 mb-4">
<div className="p-4 space-y-4">
<div className="flex gap-2">
<div className={clsx(
"flex-1 relative border transition-all",
brandFocused ? "border-accent/50 bg-accent/[0.02]" : "border-white/[0.08] bg-white/[0.02]"
"flex-1 relative border-2 transition-all",
brandFocused ? "border-purple-400/50 bg-purple-400/[0.03]" : "border-white/[0.08] bg-white/[0.02]"
)}>
<div className="flex items-center">
<Sparkles className={clsx("w-4 h-4 ml-3 transition-colors", brandFocused ? "text-accent" : "text-white/30")} />
<Sparkles className={clsx("w-4 h-4 ml-4 transition-colors", brandFocused ? "text-purple-400" : "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"
onKeyDown={(e) => e.key === 'Enter' && runTypos()}
placeholder="Enter a brand name (e.g. Google, Amazon, Shopify)..."
className="flex-1 bg-transparent px-3 py-3.5 text-sm text-white placeholder:text-white/25 outline-none font-mono"
/>
{brand && (
<button onClick={() => { setBrand(''); setTypos([]) }} className="p-3 text-white/30 hover:text-white">
@ -376,35 +541,44 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
onClick={runTypos}
disabled={!brand.trim() || typoLoading}
className={clsx(
"px-4 py-3 text-xs font-bold uppercase tracking-wider transition-all",
"px-6 py-3 text-sm font-bold uppercase tracking-wider transition-all flex items-center gap-2",
!brand.trim() || typoLoading
? "bg-white/5 text-white/20"
: "bg-white/10 text-white hover:bg-white/20"
? "bg-white/5 text-white/20 cursor-not-allowed"
: "bg-purple-500 text-white hover:bg-purple-400"
)}
>
{typoLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Find"}
{typoLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowRight className="w-4 h-4" />}
Find
</button>
</div>
{/* Typo Results Grid */}
{/* Typo Results */}
{typos.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{typos.map((t) => (
<div key={t.domain} className="border border-white/10 bg-white/[0.02] px-3 py-2 flex items-center justify-between group hover:border-accent/20 transition-colors">
<div
key={t.domain}
className="group border border-white/[0.08] bg-white/[0.02] px-3 py-2.5 flex items-center justify-between hover:border-purple-400/30 hover:bg-purple-400/[0.03] transition-all"
>
<button
onClick={() => openAnalyze(t.domain)}
className="text-xs font-mono text-white/70 group-hover:text-accent truncate text-left transition-colors"
className="text-xs font-mono text-white/70 group-hover:text-purple-400 truncate text-left transition-colors"
>
{t.domain}
</button>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[9px] font-mono text-accent bg-accent/10 px-1.5 py-0.5">
{t.status.toUpperCase()}
</span>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
<button
onClick={() => copyDomain(t.domain)}
className="w-6 h-6 flex items-center justify-center text-white/30 hover:text-white transition-colors"
title="Copy"
>
{copied === t.domain ? <Check className="w-3 h-3 text-accent" /> : <Copy className="w-3 h-3" />}
</button>
<button
onClick={() => track(t.domain)}
disabled={tracking === t.domain}
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"
className="w-6 h-6 flex items-center justify-center text-white/30 hover:text-white transition-colors"
title="Track"
>
{tracking === t.domain ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />}
</button>
@ -412,7 +586,8 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
href={`https://www.namecheap.com/domains/registration/results/?domain=${t.domain}`}
target="_blank"
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"
className="w-6 h-6 flex items-center justify-center text-white/30 hover:text-accent transition-colors"
title="Buy"
>
<ExternalLink className="w-3 h-3" />
</a>
@ -422,9 +597,12 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
</div>
)}
{/* Empty State */}
{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 className="text-center py-8 border border-dashed border-white/[0.08] bg-white/[0.01]">
<p className="text-white/30 text-xs font-mono">
Enter a brand name to discover available typo domains
</p>
</div>
)}
</div>