Fix server-side API URL construction and improve UX
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled

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

View File

@ -185,6 +185,11 @@ def _format_time_remaining(end_time: datetime, now: Optional[datetime] = None) -
def _get_affiliate_url(platform: str, domain: str, auction_url: str) -> str: 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.""" """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 # Import here to avoid circular imports
from app.services.hidden_api_scrapers import build_affiliate_url 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) # Fallback to platform-specific search/listing pages (without affiliate tracking)
platform_urls = { platform_urls = {
"GoDaddy": f"https://auctions.godaddy.com/trpItemListing.aspx?domain={domain}", "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}", "NameJet": f"https://www.namejet.com/Pages/Auctions/BackorderSearch.aspx?q={domain}",
"DropCatch": f"https://www.dropcatch.com/domain/{domain}", "DropCatch": f"https://www.dropcatch.com/domain/{domain}",
"ExpiredDomains": f"https://www.expireddomains.net/domain-name-search/?q={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)}") 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") @router.get("/opportunities")
async def get_smart_opportunities( async def get_smart_opportunities(
current_user: User = Depends(get_current_user), 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}) 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"]: if source in ["all", "external"]:
auction_query = select(DomainAuction).where(and_(*auction_filters)) auction_query = select(DomainAuction).where(and_(*auction_filters))
@ -1064,6 +1112,93 @@ async def get_market_feed(
) )
built.append({"item": item, "newest_ts": auction.updated_at or auction.scraped_at or datetime.min}) 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 # Merge sort (Python) + paginate
# ----------------------------- # -----------------------------

View File

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

View File

@ -933,11 +933,12 @@ async def sync_czds_zones():
async def match_sniper_alerts(): 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.sniper_alert import SniperAlert, SniperAlertMatch
from app.models.auction import DomainAuction 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: try:
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
@ -952,39 +953,65 @@ async def match_sniper_alerts():
return return
# Get recent auctions (added in last 2 hours) # 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( auctions_result = await db.execute(
select(DomainAuction).where( select(DomainAuction).where(
and_( and_(
DomainAuction.is_active == True, DomainAuction.is_active == True,
DomainAuction.scraped_at >= cutoff, DomainAuction.scraped_at >= auction_cutoff,
) )
) )
) )
auctions = auctions_result.scalars().all() auctions = auctions_result.scalars().all()
if not auctions: # Get recent drops (last 24 hours)
logger.info("No recent auctions to match against") drop_cutoff = datetime.utcnow() - timedelta(hours=24)
return 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 matches_created = 0
notifications_sent = 0 notifications_sent = 0
for alert in alerts: for alert in alerts:
matching_auctions = [] matching_items = []
# Match against auctions
for auction in auctions: for auction in auctions:
if _auction_matches_alert(auction, alert): 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: # Match against drops
for auction in matching_auctions: 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 # Check if this match already exists
existing = await db.execute( existing = await db.execute(
select(SniperAlertMatch).where( select(SniperAlertMatch).where(
and_( and_(
SniperAlertMatch.alert_id == alert.id, 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 # Create new match
match = SniperAlertMatch( match = SniperAlertMatch(
alert_id=alert.id, alert_id=alert.id,
domain=auction.domain, domain=item['domain'],
platform=auction.platform, platform=item['platform'],
current_bid=auction.current_bid, current_bid=item['price'],
end_time=auction.end_time, end_time=item['end_time'] or datetime.utcnow(),
auction_url=auction.auction_url, auction_url=item['url'],
matched_at=datetime.utcnow(), matched_at=datetime.utcnow(),
) )
db.add(match) db.add(match)
matches_created += 1 matches_created += 1
# Update alert last_triggered # Update alert stats
alert.last_triggered = datetime.utcnow() alert.matches_count = (alert.matches_count or 0) + 1
alert.last_matched_at = datetime.utcnow()
# Send notification if enabled # Send notification if enabled (batch notification)
if alert.notify_email: if alert.notify_email and matching_items:
try: try:
user_result = await db.execute( user_result = await db.execute(
select(User).where(User.id == alert.user_id) select(User).where(User.id == alert.user_id)
) )
user = user_result.scalar_one_or_none() user = user_result.scalar_one_or_none()
if user and email_service.is_enabled: if user and email_service.is_configured():
# Send email with matching domains auction_matches = [m for m in matching_items if m['source'] == 'auction']
domains_list = ", ".join([a.domain for a in matching_auctions[:5]]) 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( await email_service.send_email(
to_email=user.email, to_email=user.email,
subject=f"🎯 Sniper Alert: {len(matching_auctions)} matching domains found!", subject=f"🎯 Sniper Alert: {len(matching_items)} matching domains found!",
html_content=f""" html_content=''.join(html_parts),
<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>
"""
) )
notifications_sent += 1 notifications_sent += 1
alert.notifications_sent = (alert.notifications_sent or 0) + 1
except Exception as e: except Exception as e:
logger.error(f"Failed to send sniper alert notification: {e}") logger.error(f"Failed to send sniper alert notification: {e}")
await db.commit() 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: except Exception as e:
logger.exception(f"Sniper alert matching failed: {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.""" """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 domain_name = auction.domain.rsplit('.', 1)[0] if '.' in auction.domain else auction.domain
# Check keyword filter # Check keyword filter (must contain any of the keywords)
if alert.keyword: if alert.keywords:
if alert.keyword.lower() not in domain_name.lower(): 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 return False
# Check TLD filter # Check TLD filter
@ -1056,6 +1103,12 @@ def _auction_matches_alert(auction: "DomainAuction", alert: "SniperAlert") -> bo
if auction.tld.lower() not in allowed_tlds: if auction.tld.lower() not in allowed_tlds:
return False 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 # Check length filters
if alert.min_length and len(domain_name) < alert.min_length: if alert.min_length and len(domain_name) < alert.min_length:
return False 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: if alert.max_price and auction.current_bid > alert.max_price:
return False return False
# Check exclusion filters # Check bids filter (low competition)
if alert.exclude_numbers: 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): if any(c.isdigit() for c in domain_name):
return False return False
if alert.exclude_hyphens: # Check no_hyphens filter
if alert.no_hyphens:
if '-' in domain_name: if '-' in domain_name:
return False return False
# Check exclude_chars
if alert.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()): if any(c in excluded for c in domain_name.lower()):
return False return False

View File

@ -140,10 +140,41 @@ class SedoAPIClient:
"""Parse XML response from Sedo API.""" """Parse XML response from Sedo API."""
try: try:
root = ElementTree.fromstring(xml_text) 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) return self._xml_to_dict(root)
except Exception as e: except Exception as e:
logger.warning(f"Failed to parse XML: {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]: def _xml_to_dict(self, element) -> Dict[str, Any]:
"""Convert XML element to dictionary.""" """Convert XML element to dictionary."""
@ -171,20 +202,18 @@ class SedoAPIClient:
""" """
Search for domains listed on Sedo marketplace. Search for domains listed on Sedo marketplace.
Returns domains for sale (not auctions). Returns domains for sale (XML parsed to dict).
""" """
params = { params = {}
"output_method": "json", # Request JSON response
}
if keyword: if keyword:
params["keyword"] = keyword params["keyword"] = keyword
if tld: if tld:
params["tld"] = tld.lstrip(".") params["tld"] = tld.lstrip(".")
if min_price is not None: if min_price is not None:
params["minprice"] = min_price params["minprice"] = int(min_price)
if max_price is not None: if max_price is not None:
params["maxprice"] = max_price params["maxprice"] = int(max_price)
if page: if page:
params["page"] = page params["page"] = page
if page_size: if page_size:
@ -202,11 +231,11 @@ class SedoAPIClient:
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Search for active domain auctions on Sedo. 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 = { params = {}
"output_method": "json",
"auction": "true", # Only auctions
}
if keyword: if keyword:
params["keyword"] = keyword params["keyword"] = keyword
@ -217,7 +246,72 @@ class SedoAPIClient:
if page_size: if page_size:
params["pagesize"] = min(page_size, 100) 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]: async def get_domain_details(self, domain: str) -> Dict[str, Any]:
"""Get detailed information about a specific domain.""" """Get detailed information about a specific domain."""

View File

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

View File

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

View File

@ -6,13 +6,34 @@ import BlogPostClient from './BlogPostClient'
import type { BlogPost } from './types' import type { BlogPost } from './types'
async function fetchPostMeta(slug: string): Promise<BlogPost | null> { async function fetchPostMeta(slug: string): Promise<BlogPost | null> {
const baseUrl = (process.env.BACKEND_URL || process.env.NEXT_PUBLIC_SITE_URL || SITE_URL).replace(/\/$/, '') try {
const res = await fetch(`${baseUrl}/api/v1/blog/posts/${encodeURIComponent(slug)}/meta`, { // 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 }, next: { revalidate: 3600 },
}) })
if (res.status === 404) return null if (res.status === 404) return null
if (!res.ok) throw new Error(`Failed to load blog post meta: ${res.status}`) if (!res.ok) {
console.error(`[fetchPostMeta] Failed: ${res.status} from ${apiUrl}`)
return null
}
return (await res.json()) as BlogPost return (await res.json()) as BlogPost
} catch (error) {
console.error(`[fetchPostMeta] Error fetching ${slug}:`, error)
return null
}
} }
export async function generateMetadata({ export async function generateMetadata({

View File

@ -6,13 +6,41 @@ import BuyDomainClient from './BuyDomainClient'
import type { Listing } from './types' import type { Listing } from './types'
async function fetchListing(slug: string): Promise<Listing | null> { async function fetchListing(slug: string): Promise<Listing | null> {
const baseUrl = (process.env.BACKEND_URL || process.env.NEXT_PUBLIC_SITE_URL || SITE_URL).replace(/\/$/, '') try {
const res = await fetch(`${baseUrl}/api/v1/listings/${encodeURIComponent(slug)}`, { // 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 }, next: { revalidate: 60 },
headers: {
'Accept': 'application/json',
},
}) })
if (res.status === 404) return null if (res.status === 404) return null
if (!res.ok) throw new Error(`Failed to load listing: ${res.status}`) if (!res.ok) {
console.error(`[fetchListing] Failed to load listing ${slug}: ${res.status} from ${apiUrl}`)
return null
}
return (await res.json()) as Listing return (await res.json()) as Listing
} catch (error) {
console.error(`[fetchListing] Error fetching listing ${slug}:`, error)
return null
}
} }
export async function generateMetadata({ export async function generateMetadata({

View File

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

View File

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

View File

@ -57,6 +57,13 @@ function getTierLevel(tier: UserTier): number {
} }
} }
interface MarketplaceLink {
name: string
description: string
url: string
type: string
}
interface TldDetails { interface TldDetails {
tld: string tld: string
type: string type: string
@ -78,6 +85,7 @@ interface TldDetails {
price_change_3y: number price_change_3y: number
risk_level: 'low' | 'medium' | 'high' risk_level: 'low' | 'medium' | 'high'
risk_reason: string risk_reason: string
marketplace_links?: MarketplaceLink[]
} }
interface TldHistory { interface TldHistory {
@ -775,6 +783,37 @@ export default function TldDetailPage() {
</div> </div>
)} )}
</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> </div>
</section> </section>

View File

@ -677,6 +677,7 @@ export default function PortfolioPage() {
// Health data // Health data
const [healthByDomain, setHealthByDomain] = useState<Record<string, DomainHealthReport>>({}) const [healthByDomain, setHealthByDomain] = useState<Record<string, DomainHealthReport>>({})
const [checkingHealth, setCheckingHealth] = useState<Set<string>>(new Set()) const [checkingHealth, setCheckingHealth] = useState<Set<string>>(new Set())
const [healthLoadStarted, setHealthLoadStarted] = useState(false)
// External status (Yield, Listed) // External status (Yield, Listed)
const [yieldByDomain, setYieldByDomain] = useState<Record<string, { id: number; status: string; dns_verified: boolean }>>({}) 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]) }, [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 // Stats
const stats = useMemo(() => { const stats = useMemo(() => {
const active = domains.filter(d => !d.is_sold).length const active = domains.filter(d => !d.is_sold).length
@ -806,7 +847,7 @@ export default function PortfolioPage() {
} }
// Actions // Actions
const handleHealthCheck = async (domainName: string) => { const handleHealthCheck = async (domainName: string, showError = true) => {
const key = domainName.toLowerCase() const key = domainName.toLowerCase()
if (checkingHealth.has(key)) return if (checkingHealth.has(key)) return
setCheckingHealth(prev => new Set(prev).add(key)) setCheckingHealth(prev => new Set(prev).add(key))
@ -814,7 +855,9 @@ export default function PortfolioPage() {
const report = await api.quickHealthCheck(domainName) const report = await api.quickHealthCheck(domainName)
setHealthByDomain(prev => ({ ...prev, [key]: report })) setHealthByDomain(prev => ({ ...prev, [key]: report }))
} catch (err: any) { } catch (err: any) {
if (showError) {
showToast(err?.message || 'Health check failed', 'error') showToast(err?.message || 'Health check failed', 'error')
}
} finally { } finally {
setCheckingHealth(prev => { setCheckingHealth(prev => {
const next = new Set(prev) const next = new Set(prev)
@ -1012,10 +1055,11 @@ export default function PortfolioPage() {
{/* MOBILE HEADER */} {/* 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)' }}> <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"> <div className="px-4 py-3">
{/* Top Row */}
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Briefcase className="w-4 h-4 text-accent" /> <div className="w-1.5 h-1.5 bg-accent animate-pulse" />
<span className="text-sm font-mono text-white font-bold">Portfolio</span> <span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Portfolio</span>
</div> </div>
<button <button
onClick={() => setShowAddModal(true)} onClick={() => setShowAddModal(true)}
@ -1025,24 +1069,39 @@ export default function PortfolioPage() {
Add Add
</button> </button>
</div> </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"> {/* Tab Bar - Scrollable */}
<div className="text-lg font-bold text-white tabular-nums">{stats.active}</div> <div className="-mx-4 px-4 overflow-x-auto">
<div className="text-[8px] font-mono text-white/30 uppercase">Active</div> <div className="flex gap-1 min-w-max pb-1">
</div> <button
<div className="bg-accent/5 border border-accent/20 p-2 text-center"> onClick={() => setActiveTab('assets')}
<div className="text-lg font-bold text-accent tabular-nums">{formatCurrency(summary?.total_value || 0).replace('$', '')}</div> className={clsx(
<div className="text-[8px] font-mono text-accent/60 uppercase">Value</div> 'flex items-center gap-1.5 px-3 py-2 border transition-all shrink-0',
</div> activeTab === 'assets'
<div className="bg-white/[0.03] border border-white/[0.08] p-2 text-center"> ? 'border-accent/40 bg-accent/10 text-accent'
<div className={clsx("text-lg font-bold tabular-nums", (summary?.overall_roi || 0) >= 0 ? "text-accent" : "text-rose-400")}> : 'border-transparent text-white/40 active:bg-white/5'
{formatROI(summary?.overall_roi || 0)} )}
</div> >
<div className="text-[8px] font-mono text-white/30 uppercase">ROI</div> <Briefcase className="w-3.5 h-3.5" />
</div> <span className="text-[10px] font-bold uppercase tracking-wider font-mono">Assets</span>
<div className="bg-white/[0.03] border border-white/[0.08] p-2 text-center"> </button>
<div className="text-lg font-bold text-white tabular-nums">{stats.verified}</div> <button
<div className="text-[8px] font-mono text-white/30 uppercase">Verified</div> 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> </div>
</div> </div>
@ -1092,34 +1151,34 @@ export default function PortfolioPage() {
</div> </div>
</section> </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]"> <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 <button
onClick={() => setActiveTab('assets')} onClick={() => setActiveTab('assets')}
className={clsx( className={clsx(
"flex items-center gap-2 px-4 py-2 text-xs font-bold uppercase tracking-wider border transition-colors", 'flex items-center gap-2 px-4 py-2.5 border transition-all',
activeTab === 'assets' activeTab === 'assets'
? "bg-accent/10 text-accent border-accent/30" ? 'border-accent bg-accent/10 text-accent'
: "text-white/50 border-white/[0.08] hover:text-white hover:bg-white/[0.02]" : 'border-white/[0.08] text-white/50 hover:text-white hover:bg-white/[0.02]'
)} )}
> >
<Briefcase className="w-4 h-4" /> <Briefcase className="w-4 h-4" />
Assets <span className="text-xs font-bold uppercase tracking-wider">Assets</span>
</button> </button>
<button <button
onClick={() => setActiveTab('financials')} onClick={() => setActiveTab('financials')}
className={clsx( className={clsx(
"flex items-center gap-2 px-4 py-2 text-xs font-bold uppercase tracking-wider border transition-colors", 'flex items-center gap-2 px-4 py-2.5 border transition-all',
activeTab === 'financials' activeTab === 'financials'
? "bg-orange-500/10 text-orange-400 border-orange-500/30" ? 'border-orange-500 bg-orange-500/10 text-orange-400'
: "text-white/50 border-white/[0.08] hover:text-white hover:bg-white/[0.02]" : 'border-white/[0.08] text-white/50 hover:text-white hover:bg-white/[0.02]'
)} )}
> >
<Wallet className="w-4 h-4" /> <Wallet className="w-4 h-4" />
Financials <span className="text-xs font-bold uppercase tracking-wider">Financials</span>
{stats.upcoming30dCost > 0 && ( {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)} ${Math.round(stats.upcoming30dCost)}
</span> </span>
)} )}
@ -1128,7 +1187,7 @@ export default function PortfolioPage() {
{/* Asset Filters - only show when assets tab active */} {/* Asset Filters - only show when assets tab active */}
{activeTab === 'assets' && ( {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: 'all', label: 'All', count: stats.total },
{ value: 'active', label: 'Active', count: stats.active }, { value: 'active', label: 'Active', count: stats.active },

View File

@ -106,6 +106,8 @@ export default function SettingsPage() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [changingPlan, setChangingPlan] = useState<string | null>(null) const [changingPlan, setChangingPlan] = useState<string | null>(null)
const [showCancelModal, setShowCancelModal] = useState(false)
const [cancelling, setCancelling] = useState(false)
const [profileForm, setProfileForm] = useState({ name: '', email: '' }) const [profileForm, setProfileForm] = useState({ name: '', email: '' })
const [inviteLink, setInviteLink] = useState<string | null>(null) const [inviteLink, setInviteLink] = useState<string | null>(null)
@ -232,9 +234,10 @@ export default function SettingsPage() {
setChangingPlan(planId); setError(null) setChangingPlan(planId); setError(null)
try { try {
if (planId === 'scout') { if (planId === 'scout') {
await api.cancelSubscription() // Use the cancel modal instead of direct downgrade
setSuccess('Downgraded to Scout') setShowCancelModal(true)
await checkAuth() setChangingPlan(null)
return
} else { } else {
const { checkout_url } = await api.createCheckoutSession( const { checkout_url } = await api.createCheckoutSession(
planId, planId,
@ -247,6 +250,19 @@ export default function SettingsPage() {
finally { setChangingPlan(null) } 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) { if (isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-[#020202]"> <div className="min-h-screen flex items-center justify-center bg-[#020202]">
@ -665,6 +681,44 @@ export default function SettingsPage() {
Manage Billing & Invoices Manage Billing & Invoices
</button> </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> </div>
)} )}
@ -755,6 +809,69 @@ export default function SettingsPage() {
</div> </div>
</nav> </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 */} {/* MOBILE DRAWER */}
{/* ═══════════════════════════════════════════════════════════════════════ */} {/* ═══════════════════════════════════════════════════════════════════════ */}

View File

@ -13,14 +13,11 @@ import {
Power, Power,
PowerOff, PowerOff,
Bell, Bell,
MessageSquare,
Loader2, Loader2,
X, X,
AlertCircle, AlertCircle,
CheckCircle, CheckCircle,
Clock,
DollarSign, DollarSign,
Hash,
Crown, Crown,
Eye, Eye,
Gavel, Gavel,
@ -88,7 +85,6 @@ export default function SniperAlertsPage() {
const alertLimits: Record<string, number> = { scout: 2, trader: 10, tycoon: 50 } const alertLimits: Record<string, number> = { scout: 2, trader: 10, tycoon: 50 }
const maxAlerts = alertLimits[tier] || 2 const maxAlerts = alertLimits[tier] || 2
const canAddMore = alerts.length < maxAlerts const canAddMore = alerts.length < maxAlerts
const isTycoon = tier === 'tycoon'
const activeAlerts = alerts.filter(a => a.is_active).length const activeAlerts = alerts.filter(a => a.is_active).length
const totalMatches = alerts.reduce((sum, a) => sum + a.matches_count, 0) 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> <span className="text-white/30 ml-3 font-mono text-[2rem]">{alerts.length}/{maxAlerts}</span>
</h1> </h1>
<p className="text-sm text-white/40 font-mono mt-2 max-w-lg"> <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> </p>
</div> </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> <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>
<div className="flex flex-wrap gap-1 mb-2"> <div className="flex flex-wrap gap-1 mb-2">
@ -446,7 +437,6 @@ export default function SniperAlertsPage() {
alert={editingAlert} alert={editingAlert}
onClose={() => { setShowCreateModal(false); setEditingAlert(null) }} onClose={() => { setShowCreateModal(false); setEditingAlert(null) }}
onSuccess={() => { loadAlerts(); setShowCreateModal(false); setEditingAlert(null) }} onSuccess={() => { loadAlerts(); setShowCreateModal(false); setEditingAlert(null) }}
isTycoon={isTycoon}
/> />
)} )}
</div> </div>
@ -457,11 +447,10 @@ export default function SniperAlertsPage() {
// CREATE/EDIT MODAL // CREATE/EDIT MODAL
// ============================================================================ // ============================================================================
function CreateEditModal({ alert, onClose, onSuccess, isTycoon }: { function CreateEditModal({ alert, onClose, onSuccess }: {
alert: SniperAlert | null alert: SniperAlert | null
onClose: () => void onClose: () => void
onSuccess: () => void onSuccess: () => void
isTycoon: boolean
}) { }) {
const isEditing = !!alert const isEditing = !!alert
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -484,7 +473,6 @@ function CreateEditModal({ alert, onClose, onSuccess, isTycoon }: {
no_hyphens: alert?.no_hyphens || false, no_hyphens: alert?.no_hyphens || false,
exclude_chars: alert?.exclude_chars || '', exclude_chars: alert?.exclude_chars || '',
notify_email: alert?.notify_email ?? true, notify_email: alert?.notify_email ?? true,
notify_sms: alert?.notify_sms || false,
}) })
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@ -510,7 +498,6 @@ function CreateEditModal({ alert, onClose, onSuccess, isTycoon }: {
no_hyphens: form.no_hyphens, no_hyphens: form.no_hyphens,
exclude_chars: form.exclude_chars || null, exclude_chars: form.exclude_chars || null,
notify_email: form.notify_email, notify_email: form.notify_email,
notify_sms: form.notify_sms && isTycoon,
} }
if (isEditing && alert) { if (isEditing && alert) {
@ -584,18 +571,27 @@ function CreateEditModal({ alert, onClose, onSuccess, isTycoon }: {
</div> </div>
</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"> <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]"> <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" /> <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" /> <Bell className="w-4 h-4 text-accent" />
<span className="text-sm text-white/60">Email notifications</span> <span className="text-sm text-white/60">Email notifications</span>
</label> </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>
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">

View File

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

View File

@ -8,12 +8,15 @@ import {
Shield, Shield,
Sparkles, Sparkles,
Eye, Eye,
RefreshCw,
Wand2, Wand2,
Settings, Settings,
ChevronRight,
Zap, Zap,
Filter, Copy,
Check,
ShoppingCart,
Star,
Lightbulb,
RefreshCw,
} from 'lucide-react' } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store' import { useAnalyzePanelStore } from '@/lib/analyze-store'
@ -24,24 +27,39 @@ import { useStore } from '@/lib/store'
// ============================================================================ // ============================================================================
const PATTERNS = [ const PATTERNS = [
{ key: 'cvcvc', label: 'CVCVC', desc: '5-letter brandables (Zalor, Mivex)' }, {
{ key: 'cvccv', label: 'CVCCV', desc: '5-letter variants (Bento, Salvo)' }, key: 'cvcvc',
{ key: 'human', label: 'Human', desc: '2-syllable names (Siri, Alexa)' }, 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'] const TLDS = [
{ tld: 'com', premium: true, label: '.com' },
// ============================================================================ { tld: 'io', premium: true, label: '.io' },
// HELPERS { tld: 'ai', premium: true, label: '.ai' },
// ============================================================================ { tld: 'co', premium: false, label: '.co' },
{ tld: 'net', premium: false, label: '.net' },
function parseTlds(input: string): string[] { { tld: 'org', premium: false, label: '.org' },
return input { tld: 'app', premium: false, label: '.app' },
.split(',') { tld: 'dev', premium: false, label: '.dev' },
.map((t) => t.trim().toLowerCase().replace(/^\./, '')) ]
.filter(Boolean)
.slice(0, 10)
}
// ============================================================================ // ============================================================================
// COMPONENT // COMPONENT
@ -53,7 +71,7 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
// Config State // Config State
const [pattern, setPattern] = useState('cvcvc') const [pattern, setPattern] = useState('cvcvc')
const [selectedTlds, setSelectedTlds] = useState<string[]>(['com']) const [selectedTlds, setSelectedTlds] = useState<string[]>(['com', 'io'])
const [limit, setLimit] = useState(30) const [limit, setLimit] = useState(30)
const [showConfig, setShowConfig] = useState(false) 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 [items, setItems] = useState<Array<{ domain: string; status: string }>>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [tracking, setTracking] = useState<string | null>(null) const [tracking, setTracking] = useState<string | null>(null)
const [copied, setCopied] = useState<string | null>(null)
const toggleTld = useCallback((tld: string) => { const toggleTld = useCallback((tld: string) => {
setSelectedTlds((prev) => 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 () => { const run = useCallback(async () => {
if (selectedTlds.length === 0) { if (selectedTlds.length === 0) {
showToast('Select at least one TLD', 'error') showToast('Select at least one TLD', 'error')
@ -76,11 +107,14 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
} }
setLoading(true) setLoading(true)
setError(null) setError(null)
setItems([])
try { try {
const res = await api.huntBrandables({ pattern, tlds: selectedTlds, limit, max_checks: 400 }) const res = await api.huntBrandables({ pattern, tlds: selectedTlds, limit, max_checks: 400 })
setItems(res.items.map((i) => ({ domain: i.domain, status: i.status }))) setItems(res.items.map((i) => ({ domain: i.domain, status: i.status })))
if (res.items.length === 0) { if (res.items.length === 0) {
showToast('No available domains found. Try different settings.', 'info') showToast('No available domains found. Try different settings.', 'info')
} else {
showToast(`Found ${res.items.length} available brandable domains!`, 'success')
} }
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
@ -98,7 +132,7 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
setTracking(domain) setTracking(domain)
try { try {
await addDomain(domain) await addDomain(domain)
showToast(`Tracked ${domain}`, 'success') showToast(`Added to watchlist: ${domain}`, 'success')
} catch (e) { } catch (e) {
showToast(e instanceof Error ? e.message : 'Failed to track domain', 'error') showToast(e instanceof Error ? e.message : 'Failed to track domain', 'error')
} finally { } finally {
@ -108,194 +142,251 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
[addDomain, showToast, tracking] [addDomain, showToast, tracking]
) )
const currentPattern = PATTERNS.find(p => p.key === pattern)
return ( return (
<div className="space-y-4"> <div className="space-y-6">
{/* Header with Generate Button */} {/* ═══════════════════════════════════════════════════════════════════════ */}
{/* MAIN GENERATOR CARD */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
<div className="border border-white/[0.08] bg-[#020202]"> <div className="border border-white/[0.08] bg-[#020202]">
<div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between"> {/* 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="flex items-center gap-3">
<div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center"> <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-4 h-4 text-accent" /> <Wand2 className="w-5 h-5 text-accent" />
</div> </div>
<div> <div>
<div className="text-sm font-bold text-white">Brandable Forge</div> <h3 className="text-base font-bold text-white">Brandable Forge</h3>
<div className="text-[10px] font-mono text-white/40">Generate available brandable names</div> <p className="text-[11px] font-mono text-white/40">
AI-powered brandable name generator
</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => setShowConfig(!showConfig)} onClick={() => setShowConfig(!showConfig)}
className={clsx( className={clsx(
"w-8 h-8 flex items-center justify-center border transition-colors", "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/30 hover:text-white hover:bg-white/5" 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" /> <Settings className="w-4 h-4" />
</button> </button>
<button <button
onClick={run} onClick={run}
disabled={loading} disabled={loading || selectedTlds.length === 0}
className={clsx( className={clsx(
"h-8 px-4 text-xs font-bold uppercase tracking-wider transition-all flex items-center gap-2", "h-9 px-5 text-sm 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 || 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" /> : <Sparkles className="w-4 h-4" />} {loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Generating...
</>
) : (
<>
<Sparkles className="w-4 h-4" />
Generate Generate
</>
)}
</button> </button>
</div> </div>
</div> </div>
</div>
{/* Pattern Selection */} {/* Pattern Selection */}
<div className="p-3 border-b border-white/[0.08]"> <div className="p-4 border-b border-white/[0.08]">
<div className="flex gap-2 flex-wrap"> <div className="flex items-center gap-2 mb-3">
{PATTERNS.map((p) => ( <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 <button
key={p.key} key={p.key}
onClick={() => setPattern(p.key)} onClick={() => setPattern(p.key)}
className={clsx( className={clsx(
"flex-1 min-w-[120px] px-3 py-2 border transition-all text-left", "p-4 border text-left transition-all group",
pattern === p.key isActive
? "border-accent bg-accent/10" ? `border-${colorClass}/40 bg-${colorClass}/10`
: "border-white/[0.08] hover:border-white/20" : "border-white/[0.08] hover:border-white/20 bg-white/[0.02] hover:bg-white/[0.04]"
)} )}
> >
<div className={clsx("text-xs font-bold font-mono", pattern === p.key ? "text-accent" : "text-white/60")}> <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} {p.label}
</span>
{isActive && (
<div className={`w-2 h-2 rounded-full bg-${colorClass}`} />
)}
</div> </div>
<div className="text-[10px] text-white/30 mt-0.5">{p.desc}</div> <p className="text-[11px] text-white/40 mb-2">{p.desc}</p>
</button> <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> </div>
</button>
)
})}
</div>
</div> </div>
{/* TLD Selection */} {/* TLD Selection */}
<div className="p-3 border-b border-white/[0.08]"> <div className="p-4 border-b border-white/[0.08]">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center justify-between mb-3">
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">TLDs</span> <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> </div>
<div className="flex gap-2 flex-wrap">
{TLDS.map((tld) => (
<button <button
key={tld} onClick={() => setSelectedTlds(selectedTlds.length === TLDS.length ? ['com'] : TLDS.map(t => t.tld))}
onClick={() => toggleTld(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 flex-wrap gap-2">
{TLDS.map((t) => (
<button
key={t.tld}
onClick={() => toggleTld(t.tld)}
className={clsx( className={clsx(
"px-3 py-1.5 text-[10px] font-mono uppercase border transition-colors", "px-3 py-2 text-[11px] font-mono uppercase border transition-all flex items-center gap-1.5",
selectedTlds.includes(tld) selectedTlds.includes(t.tld)
? "border-accent bg-accent/10 text-accent" ? "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> </button>
))} ))}
</div> </div>
</div> </div>
{/* Advanced Config (collapsed) */} {/* Advanced Config */}
{showConfig && ( {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="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-4"> <div className="flex items-center gap-6">
<div> <div>
<label className="block text-[10px] font-mono text-white/40 mb-1">Results Count</label> <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 <input
type="number" type="range"
value={limit} value={limit}
onChange={(e) => setLimit(Math.max(1, Math.min(100, Number(e.target.value) || 30)))} onChange={(e) => setLimit(Number(e.target.value))}
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={10}
min={1}
max={100} 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"> </div>
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> </div>
</div> </div>
)} )}
{/* Stats Bar */} {/* Stats Bar */}
<div className="px-4 py-2 flex items-center justify-between text-[10px] font-mono text-white/40"> <div className="px-4 py-3 flex items-center justify-between bg-white/[0.01]">
<span>{items.length} domains generated</span> <span className="text-[11px] font-mono text-white/40">
<span className="flex items-center gap-1"> {items.length > 0 ? (
<Zap className="w-3 h-3" /> <span className="flex items-center gap-2">
All verified available <span className="w-2 h-2 rounded-full bg-accent animate-pulse" />
{items.length} brandable domains ready
</span> </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>
</div> </div>
{/* Error Message */} {/* Error Message */}
{error && ( {error && (
<div className="p-3 border border-red-500/20 bg-red-500/5 text-xs font-mono text-red-400"> <div className="p-4 border border-rose-500/20 bg-rose-500/5 flex items-center gap-3">
{error} <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> </div>
)} )}
{/* Results Grid */} {/* ═══════════════════════════════════════════════════════════════════════ */}
{/* RESULTS */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{items.length > 0 && ( {items.length > 0 && (
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]"> <div className="space-y-2">
{/* Desktop Header */} <div className="flex items-center justify-between px-1">
<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 className="text-[10px] font-mono text-white/40 uppercase tracking-wider">
<span>Domain</span> Generated Domains
<span className="text-center">Status</span>
<span className="text-right">Actions</span>
</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="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> </span>
</div> <button
</div> 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> </div>
<div className="flex gap-2"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
<button {items.map((i, idx) => (
onClick={() => track(i.domain)} <div
disabled={tracking === i.domain} key={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" className={clsx(
"group p-3 border bg-[#020202] hover:bg-accent/[0.03] transition-all",
"border-white/[0.06] hover:border-accent/20"
)}
> >
{tracking === i.domain ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />} <div className="flex items-center justify-between gap-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>
{/* 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="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"> <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">
<Sparkles className="w-4 h-4 text-accent" /> {String(idx + 1).padStart(2, '0')}
</div> </div>
<button <button
onClick={() => openAnalyze(i.domain)} onClick={() => openAnalyze(i.domain)}
@ -305,51 +396,89 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
</button> </button>
</div> </div>
<div className="text-center"> <div className="flex items-center gap-1.5 shrink-0">
<span className="text-[10px] font-mono font-bold text-accent bg-accent/10 px-2 py-0.5"> <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">
AVAILABLE AVAIL
</span> </span>
</div>
<div className="flex items-center justify-end gap-2 opacity-50 group-hover:opacity-100 transition-opacity"> <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 <button
onClick={() => track(i.domain)} onClick={() => track(i.domain)}
disabled={tracking === i.domain} disabled={tracking === i.domain}
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-colors" 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" />} {tracking === i.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
</button> </button>
<button <button
onClick={() => openAnalyze(i.domain)} onClick={() => openAnalyze(i.domain)}
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/10 transition-colors" 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" /> <Shield className="w-3.5 h-3.5" />
</button> </button>
<a <a
href={`https://www.namecheap.com/domains/registration/results/?domain=${encodeURIComponent(i.domain)}`} href={`https://www.namecheap.com/domains/registration/results/?domain=${encodeURIComponent(i.domain)}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="h-7 px-3 bg-accent text-black text-xs font-bold flex items-center gap-1 hover:bg-white transition-colors" 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"
> >
Register <ShoppingCart className="w-3 h-3" />
<ExternalLink className="w-3 h-3" /> <span className="hidden sm:inline">Buy</span>
</a> </a>
</div> </div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div>
)} )}
{/* Empty State */} {/* Empty State */}
{items.length === 0 && !loading && ( {items.length === 0 && !loading && (
<div className="text-center py-16 border border-dashed border-white/[0.08]"> <div className="text-center py-16 border border-dashed border-white/[0.08] bg-white/[0.01]">
<Wand2 className="w-8 h-8 text-white/10 mx-auto mb-3" /> <div className="w-16 h-16 mx-auto mb-4 bg-accent/5 border border-accent/20 flex items-center justify-center">
<p className="text-white/40 text-sm font-mono">No domains generated yet</p> <Wand2 className="w-8 h-8 text-accent/40" />
<p className="text-white/25 text-xs font-mono mt-1">Click "Generate" to create brandable names</p> </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>
)} )}
{/* 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>
)}
</div> </div>
) )
} }

View File

@ -11,16 +11,36 @@ import {
Eye, Eye,
TrendingUp, TrendingUp,
RefreshCw, RefreshCw,
Filter,
ChevronRight,
Globe, Globe,
Zap, Zap,
X X,
Check,
Copy,
ShoppingCart,
Flame,
ArrowRight,
AlertCircle
} from 'lucide-react' } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store' import { useAnalyzePanelStore } from '@/lib/analyze-store'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
// ============================================================================
// 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 // HELPERS
// ============================================================================ // ============================================================================
@ -48,6 +68,7 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
// Keyword Check State // Keyword Check State
const [keywordInput, setKeywordInput] = useState('') const [keywordInput, setKeywordInput] = useState('')
const [keywordFocused, setKeywordFocused] = useState(false) 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 [availability, setAvailability] = useState<Array<{ domain: string; status: string; is_available: boolean | null }>>([])
const [checking, setChecking] = useState(false) 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 [typos, setTypos] = useState<Array<{ domain: string; status: string }>>([])
const [typoLoading, setTypoLoading] = useState(false) const [typoLoading, setTypoLoading] = useState(false)
// Tracking State // Tracking & Copy State
const [tracking, setTracking] = useState<string | null>(null) 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( const track = useCallback(
async (domain: string) => { async (domain: string) => {
@ -66,7 +94,7 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
setTracking(domain) setTracking(domain)
try { try {
await addDomain(domain) await addDomain(domain)
showToast(`Tracked ${domain}`, 'success') showToast(`Added to watchlist: ${domain}`, 'success')
} catch (e) { } catch (e) {
showToast(e instanceof Error ? e.message : 'Failed to track domain', 'error') showToast(e instanceof Error ? e.message : 'Failed to track domain', 'error')
} finally { } finally {
@ -86,12 +114,11 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
setError(msg) setError(msg)
showToast(msg, 'error')
setTrends([]) setTrends([])
} finally { } finally {
if (isRefresh) setRefreshing(false) if (isRefresh) setRefreshing(false)
} }
}, [geo, selected, showToast]) }, [geo, selected])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -111,12 +138,22 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
const keyword = useMemo(() => normalizeKeyword(keywordInput || selected || ''), [keywordInput, selected]) 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 () => { const runCheck = useCallback(async () => {
if (!keyword) return if (!keyword) return
if (selectedTlds.length === 0) {
showToast('Select at least one TLD', 'error')
return
}
setChecking(true) setChecking(true)
try { try {
const kw = keyword.toLowerCase().replace(/\s+/g, '') 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 }))) setAvailability(res.items.map((r) => ({ domain: r.domain, status: r.status, is_available: r.is_available })))
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to check availability' const msg = e instanceof Error ? e.message : 'Failed to check availability'
@ -125,7 +162,7 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
} finally { } finally {
setChecking(false) setChecking(false)
} }
}, [keyword, showToast]) }, [keyword, selectedTlds, showToast])
const runTypos = useCallback(async () => { const runTypos = useCallback(async () => {
const b = brand.trim() const b = brand.trim()
@ -134,6 +171,9 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
try { try {
const res = await api.huntTypos({ brand: b, tlds: ['com'], limit: 50 }) const res = await api.huntTypos({ brand: b, tlds: ['com'], limit: 50 })
setTypos(res.items.map((i) => ({ domain: i.domain, status: i.status }))) 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) { } catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to run typo check' const msg = e instanceof Error ? e.message : 'Failed to run typo check'
showToast(msg, 'error') showToast(msg, 'error')
@ -143,56 +183,85 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
} }
}, [brand, showToast]) }, [brand, showToast])
const availableCount = useMemo(() => availability.filter(a => a.status === 'available').length, [availability])
const currentGeo = GEO_OPTIONS.find(g => g.value === geo)
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-20"> <div className="space-y-4">
<Loader2 className="w-6 h-6 text-accent animate-spin" /> {/* 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> </div>
) )
} }
return ( return (
<div className="space-y-4"> <div className="space-y-6">
{/* Trends Header */} {/* ═══════════════════════════════════════════════════════════════════════ */}
{/* TRENDING TOPICS */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
<div className="border border-white/[0.08] bg-[#020202]"> <div className="border border-white/[0.08] bg-[#020202]">
<div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between"> <div className="px-4 py-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="flex items-center gap-3">
<div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center"> <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">
<TrendingUp className="w-4 h-4 text-accent" /> <Flame className="w-5 h-5 text-accent" />
</div> </div>
<div> <div>
<div className="text-sm font-bold text-white">Google Trends (24h)</div> <h3 className="text-base font-bold text-white">Trending Now</h3>
<div className="text-[10px] font-mono text-white/40">Real-time trending topics</div> <p className="text-[11px] font-mono text-white/40">
Real-time Google Trends {currentGeo?.flag} {currentGeo?.label}
</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<select <select
value={geo} value={geo}
onChange={(e) => setGeo(e.target.value)} onChange={(e) => { setGeo(e.target.value); setSelected(''); setAvailability([]) }}
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" 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"
> >
<option value="US">🇺🇸 US</option> {GEO_OPTIONS.map(g => (
<option value="CH">🇨🇭 CH</option> <option key={g.value} value={g.value}>{g.flag} {g.label}</option>
<option value="DE">🇩🇪 DE</option> ))}
<option value="GB">🇬🇧 UK</option>
<option value="FR">🇫🇷 FR</option>
</select> </select>
<button <button
onClick={() => loadTrends(true)} onClick={() => loadTrends(true)}
disabled={refreshing} 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" 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")} /> <RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
</button> </button>
</div> </div>
</div> </div>
</div>
{error ? ( {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"> <div className="p-4">
{trends.slice(0, 20).map((t) => { <div className="flex flex-wrap gap-2">
{trends.slice(0, 16).map((t, idx) => {
const active = selected === t.title const active = selected === t.title
const isHot = idx < 3
return ( return (
<button <button
key={t.title} key={t.title}
@ -202,57 +271,81 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
setAvailability([]) setAvailability([])
}} }}
className={clsx( className={clsx(
'px-3 py-2 border text-xs font-mono transition-all', 'group relative px-4 py-2.5 border text-left transition-all',
active active
? 'border-accent bg-accent/10 text-accent' ? 'border-accent bg-accent/10'
: 'border-white/[0.08] text-white/60 hover:border-white/20 hover:text-white' : 'border-white/[0.08] hover:border-white/20 bg-white/[0.02] hover:bg-white/[0.04]'
)} )}
> >
<span className="truncate max-w-[150px] block">{t.title}</span> <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 && ( {t.approx_traffic && (
<span className="text-[9px] text-white/30 block mt-0.5">{t.approx_traffic}</span> <div className="text-[9px] text-white/30 mt-0.5 font-mono">{t.approx_traffic}</div>
)} )}
</button> </button>
) )
})} })}
</div> </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> </div>
{/* Keyword Availability Check */} {/* ═══════════════════════════════════════════════════════════════════════ */}
{/* DOMAIN AVAILABILITY CHECKER */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
<div className="border border-white/[0.08] bg-[#020202]"> <div className="border border-white/[0.08] bg-[#020202]">
<div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between"> <div className="px-4 py-4 border-b border-white/[0.08]">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center"> <div className="w-10 h-10 bg-white/[0.03] border border-white/[0.08] flex items-center justify-center">
<Globe className="w-4 h-4 text-white/40" /> <Globe className="w-5 h-5 text-white/50" />
</div> </div>
<div> <div>
<div className="text-sm font-bold text-white">Domain Availability</div> <h3 className="text-base font-bold text-white">Check Availability</h3>
<div className="text-[10px] font-mono text-white/40">Check {keyword || 'keyword'} across TLDs</div> <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>
</div> </div>
<div className="p-4"> <div className="p-4 space-y-4">
<div className="flex gap-2 mb-4"> {/* Keyword Input */}
<div className="flex gap-2">
<div className={clsx( <div className={clsx(
"flex-1 relative border transition-all", "flex-1 relative border-2 transition-all",
keywordFocused ? "border-accent/50 bg-accent/[0.02]" : "border-white/[0.08] bg-white/[0.02]" keywordFocused ? "border-accent bg-accent/[0.03]" : "border-white/[0.08] bg-white/[0.02]"
)}> )}>
<div className="flex items-center"> <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 <input
value={keywordInput || selected} value={keywordInput || selected}
onChange={(e) => setKeywordInput(e.target.value)} onChange={(e) => setKeywordInput(e.target.value)}
onFocus={() => setKeywordFocused(true)} onFocus={() => setKeywordFocused(true)}
onBlur={() => setKeywordFocused(false)} onBlur={() => setKeywordFocused(false)}
placeholder="Type a keyword..." onKeyDown={(e) => e.key === 'Enter' && runCheck()}
className="flex-1 bg-transparent px-3 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono" 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) && ( {(keywordInput || selected) && (
<button <button
onClick={() => { setKeywordInput(''); setSelected(''); setAvailability([]) }} 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" /> <X className="w-4 h-4" />
</button> </button>
@ -263,107 +356,179 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
onClick={runCheck} onClick={runCheck}
disabled={!keyword || checking} disabled={!keyword || checking}
className={clsx( 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 !keyword || checking
? "bg-white/5 text-white/20" ? "bg-white/5 text-white/20 cursor-not-allowed"
: "bg-accent text-black hover:bg-white" : "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> </button>
</div> </div>
{/* Results Grid */} {/* 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 && ( {availability.length > 0 && (
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]"> <div className="space-y-2">
{availability.map((a) => ( <div className="flex items-center justify-between">
<div key={a.domain} className="bg-[#020202] hover:bg-white/[0.02] transition-colors p-3 flex items-center justify-between"> <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="flex items-center gap-3 min-w-0 flex-1">
<div className={clsx( <div className={clsx(
"w-2 h-2 rounded-full shrink-0", "w-2.5 h-2.5 rounded-full shrink-0",
a.status === 'available' ? "bg-accent" : "bg-white/20" isAvailable ? "bg-accent" : "bg-white/20"
)} /> )} />
<button <button
onClick={() => openAnalyze(a.domain)} onClick={() => openAnalyze(a.domain)}
className="text-sm font-mono text-white/70 hover:text-accent truncate text-left" className={clsx(
"text-sm font-mono truncate text-left transition-colors",
isAvailable ? "text-white hover:text-accent" : "text-white/50"
)}
> >
{a.domain} {a.domain}
</button> </button>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className={clsx( <span className={clsx(
"text-[10px] font-mono font-bold px-2 py-0.5", "text-[10px] font-mono font-bold px-2 py-1 border",
a.status === 'available' ? "text-accent bg-accent/10" : "text-white/30 bg-white/5" isAvailable
? "text-accent bg-accent/10 border-accent/30"
: "text-white/30 bg-white/5 border-white/10"
)}> )}>
{a.status.toUpperCase()} {isAvailable ? '✓ AVAIL' : 'TAKEN'}
</span> </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 <button
onClick={() => track(a.domain)} onClick={() => track(a.domain)}
disabled={tracking === a.domain} disabled={tracking === a.domain}
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-colors" 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" />} {tracking === a.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
</button> </button>
<button <button
onClick={() => openAnalyze(a.domain)} onClick={() => openAnalyze(a.domain)}
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/10 transition-colors" 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" /> <Shield className="w-3.5 h-3.5" />
</button> </button>
{a.status === 'available' && (
{isAvailable && (
<a <a
href={`https://www.namecheap.com/domains/registration/results/?domain=${a.domain}`} href={`https://www.namecheap.com/domains/registration/results/?domain=${a.domain}`}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" 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 Buy
</a> </a>
)} )}
</div> </div>
</div> </div>
))} )
})}
</div>
</div> </div>
)} )}
{/* Empty State */}
{availability.length === 0 && keyword && !checking && ( {availability.length === 0 && keyword && !checking && (
<div className="text-center py-8 border border-dashed border-white/[0.08]"> <div className="text-center py-10 border border-dashed border-white/[0.08] bg-white/[0.01]">
<Zap className="w-6 h-6 text-white/10 mx-auto mb-2" /> <Zap className="w-8 h-8 text-white/10 mx-auto mb-3" />
<p className="text-white/30 text-xs font-mono">Click "Check" to find available domains</p> <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> </div>
</div> </div>
{/* Typo Finder */} {/* ═══════════════════════════════════════════════════════════════════════ */}
{/* TYPO FINDER */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
<div className="border border-white/[0.08] bg-[#020202]"> <div className="border border-white/[0.08] bg-[#020202]">
<div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between"> <div className="px-4 py-4 border-b border-white/[0.08]">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center"> <div className="w-10 h-10 bg-purple-500/10 border border-purple-500/20 flex items-center justify-center">
<Sparkles className="w-4 h-4 text-white/40" /> <Sparkles className="w-5 h-5 text-purple-400" />
</div> </div>
<div> <div>
<div className="text-sm font-bold text-white">Typo Finder</div> <h3 className="text-base font-bold text-white">Typo Finder</h3>
<div className="text-[10px] font-mono text-white/40">Find available typos of big brands</div> <p className="text-[11px] font-mono text-white/40">
Find available misspellings of popular brands
</p>
</div> </div>
</div> </div>
</div> </div>
<div className="p-4"> <div className="p-4 space-y-4">
<div className="flex gap-2 mb-4"> <div className="flex gap-2">
<div className={clsx( <div className={clsx(
"flex-1 relative border transition-all", "flex-1 relative border-2 transition-all",
brandFocused ? "border-accent/50 bg-accent/[0.02]" : "border-white/[0.08] bg-white/[0.02]" brandFocused ? "border-purple-400/50 bg-purple-400/[0.03]" : "border-white/[0.08] bg-white/[0.02]"
)}> )}>
<div className="flex items-center"> <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 <input
value={brand} value={brand}
onChange={(e) => setBrand(e.target.value)} onChange={(e) => setBrand(e.target.value)}
onFocus={() => setBrandFocused(true)} onFocus={() => setBrandFocused(true)}
onBlur={() => setBrandFocused(false)} onBlur={() => setBrandFocused(false)}
placeholder="e.g. Shopify, Amazon, Google..." onKeyDown={(e) => e.key === 'Enter' && runTypos()}
className="flex-1 bg-transparent px-3 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono" 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 && ( {brand && (
<button onClick={() => { setBrand(''); setTypos([]) }} className="p-3 text-white/30 hover:text-white"> <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} onClick={runTypos}
disabled={!brand.trim() || typoLoading} disabled={!brand.trim() || typoLoading}
className={clsx( 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 !brand.trim() || typoLoading
? "bg-white/5 text-white/20" ? "bg-white/5 text-white/20 cursor-not-allowed"
: "bg-white/10 text-white hover:bg-white/20" : "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> </button>
</div> </div>
{/* Typo Results Grid */} {/* Typo Results */}
{typos.length > 0 && ( {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) => ( {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 <button
onClick={() => openAnalyze(t.domain)} 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} {t.domain}
</button> </button>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-1.5 shrink-0 ml-2">
<span className="text-[9px] font-mono text-accent bg-accent/10 px-1.5 py-0.5"> <button
{t.status.toUpperCase()} onClick={() => copyDomain(t.domain)}
</span> 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 <button
onClick={() => track(t.domain)} onClick={() => track(t.domain)}
disabled={tracking === 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" />} {tracking === t.domain ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />}
</button> </button>
@ -412,7 +586,8 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
href={`https://www.namecheap.com/domains/registration/results/?domain=${t.domain}`} href={`https://www.namecheap.com/domains/registration/results/?domain=${t.domain}`}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" /> <ExternalLink className="w-3 h-3" />
</a> </a>
@ -422,9 +597,12 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
</div> </div>
)} )}
{/* Empty State */}
{typos.length === 0 && !typoLoading && ( {typos.length === 0 && !typoLoading && (
<div className="text-xs font-mono text-white/30 text-center py-4"> <div className="text-center py-8 border border-dashed border-white/[0.08] bg-white/[0.01]">
Enter a brand name to find available typo domains <p className="text-white/30 text-xs font-mono">
Enter a brand name to discover available typo domains
</p>
</div> </div>
)} )}
</div> </div>