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
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:
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
59
backend/scripts/setup_zone_cron.sh
Normal file
59
backend/scripts/setup_zone_cron.sh
Normal 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"
|
||||
594
backend/scripts/sync_all_zones.py
Normal file
594
backend/scripts/sync_all_zones.py
Normal 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)
|
||||
@ -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({
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 */}
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user