Fix server-side API URL construction and improve UX
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- Fix double /api/v1 bug in buy/blog/discover pages causing 500 errors - Add auto-load health checks on Portfolio page (like Watchlist) - Add subscription cancellation UI in Settings with trust-building design - Remove SMS notifications from Sniper alerts - Fix Sniper alert matching for drops and auctions - Improve Trend Surfer and Brandable Forge UI/UX - Match Portfolio tabs to Hunt page design - Update Hunt page header style consistency
This commit is contained in:
@ -185,6 +185,11 @@ def _format_time_remaining(end_time: datetime, now: Optional[datetime] = None) -
|
|||||||
|
|
||||||
def _get_affiliate_url(platform: str, domain: str, auction_url: str) -> str:
|
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
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
59
backend/scripts/setup_zone_cron.sh
Normal file
59
backend/scripts/setup_zone_cron.sh
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Setup cron job for automated zone file synchronization
|
||||||
|
# Run this script on the server to install the daily sync job
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔧 Setting up Pounce Zone Sync Cron Job"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
# Create log directory
|
||||||
|
mkdir -p /home/user/logs
|
||||||
|
touch /home/user/logs/zone_sync.log
|
||||||
|
|
||||||
|
# Create the cron wrapper script
|
||||||
|
cat > /home/user/pounce/backend/scripts/run_zone_sync.sh << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
# Wrapper script for zone sync with proper environment
|
||||||
|
|
||||||
|
cd /home/user/pounce/backend
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Run sync with timeout (max 2 hours)
|
||||||
|
timeout 7200 python scripts/sync_all_zones.py >> /home/user/logs/zone_sync.log 2>&1
|
||||||
|
|
||||||
|
# Rotate log if too big (keep last 10MB)
|
||||||
|
if [ -f /home/user/logs/zone_sync.log ]; then
|
||||||
|
size=$(stat -f%z /home/user/logs/zone_sync.log 2>/dev/null || stat -c%s /home/user/logs/zone_sync.log 2>/dev/null)
|
||||||
|
if [ "$size" -gt 10485760 ]; then
|
||||||
|
tail -c 5242880 /home/user/logs/zone_sync.log > /home/user/logs/zone_sync.log.tmp
|
||||||
|
mv /home/user/logs/zone_sync.log.tmp /home/user/logs/zone_sync.log
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x /home/user/pounce/backend/scripts/run_zone_sync.sh
|
||||||
|
|
||||||
|
# Add cron job (runs daily at 06:00 UTC - after most registry updates)
|
||||||
|
# Remove existing pounce zone sync jobs first
|
||||||
|
crontab -l 2>/dev/null | grep -v "run_zone_sync.sh" > /tmp/crontab.tmp || true
|
||||||
|
|
||||||
|
# Add new job
|
||||||
|
echo "# Pounce Zone File Sync - Daily at 06:00 UTC" >> /tmp/crontab.tmp
|
||||||
|
echo "0 6 * * * /home/user/pounce/backend/scripts/run_zone_sync.sh" >> /tmp/crontab.tmp
|
||||||
|
|
||||||
|
# Install crontab
|
||||||
|
crontab /tmp/crontab.tmp
|
||||||
|
rm /tmp/crontab.tmp
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Cron job installed!"
|
||||||
|
echo ""
|
||||||
|
echo "Schedule: Daily at 06:00 UTC"
|
||||||
|
echo "Log file: /home/user/logs/zone_sync.log"
|
||||||
|
echo ""
|
||||||
|
echo "Current crontab:"
|
||||||
|
crontab -l
|
||||||
|
echo ""
|
||||||
|
echo "To run manually: /home/user/pounce/backend/scripts/run_zone_sync.sh"
|
||||||
|
echo "To view logs: tail -f /home/user/logs/zone_sync.log"
|
||||||
594
backend/scripts/sync_all_zones.py
Normal file
594
backend/scripts/sync_all_zones.py
Normal file
@ -0,0 +1,594 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Pounce Zone File Sync - Daily Automated Zone File Synchronization
|
||||||
|
|
||||||
|
This script:
|
||||||
|
1. Downloads zone files from ICANN CZDS (app, dev, info, online, org, xyz)
|
||||||
|
2. Downloads zone files from Switch.ch via AXFR (.ch, .li)
|
||||||
|
3. Compares with yesterday's data to detect drops
|
||||||
|
4. Stores drops in the database for the Drops tab
|
||||||
|
5. Cleans up all temporary files and compresses domain lists
|
||||||
|
|
||||||
|
STORAGE STRATEGY (Ultra-Efficient):
|
||||||
|
- Raw zone files: DELETED immediately after parsing
|
||||||
|
- Domain lists: Stored COMPRESSED (.gz) - ~80% size reduction
|
||||||
|
- Only keeps current snapshot (no history)
|
||||||
|
- Drops stored in DB for 48h only
|
||||||
|
|
||||||
|
Run daily via cron at 06:00 UTC (after most registries update)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import gzip
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Set
|
||||||
|
|
||||||
|
# Add parent to path for imports
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CZDS_DIR = Path("/home/user/pounce_czds")
|
||||||
|
SWITCH_DIR = Path("/home/user/pounce_switch")
|
||||||
|
LOG_FILE = Path("/home/user/logs/zone_sync.log")
|
||||||
|
|
||||||
|
# Storage efficiency: compress domain lists
|
||||||
|
COMPRESS_DOMAIN_LISTS = True
|
||||||
|
|
||||||
|
# CZDS TLDs we have access to
|
||||||
|
CZDS_TLDS = ["app", "dev", "info", "online", "org", "xyz"]
|
||||||
|
|
||||||
|
# Switch.ch AXFR config
|
||||||
|
SWITCH_CONFIG = {
|
||||||
|
"ch": {
|
||||||
|
"server": "zonedata.switch.ch",
|
||||||
|
"key_name": "tsig-zonedata-ch-public-21-01.",
|
||||||
|
"key_secret": "stZwEGApYumtXkh73qMLPqfbIDozWKZLkqRvcjKSpRnsor6A6MxixRL6C2HeSVBQNfMW4wer+qjS0ZSfiWiJ3Q=="
|
||||||
|
},
|
||||||
|
"li": {
|
||||||
|
"server": "zonedata.switch.ch",
|
||||||
|
"key_name": "tsig-zonedata-li-public-21-01.",
|
||||||
|
"key_secret": "t8GgeCn+fhPaj+cRy/lakQPb6M45xz/NZwmcp4iqbBxKFCCH0/k3xNGe6sf3ObmoaKDBedge/La4cpPfLqtFkw=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(),
|
||||||
|
logging.FileHandler(LOG_FILE) if LOG_FILE.parent.exists() else logging.StreamHandler()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ZoneSyncResult:
|
||||||
|
"""Result of a zone sync operation"""
|
||||||
|
def __init__(self, tld: str):
|
||||||
|
self.tld = tld
|
||||||
|
self.success = False
|
||||||
|
self.domain_count = 0
|
||||||
|
self.drops_count = 0
|
||||||
|
self.error: Optional[str] = None
|
||||||
|
self.duration_seconds = 0
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db_session():
|
||||||
|
"""Create async database session"""
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url.replace("sqlite://", "sqlite+aiosqlite://"))
|
||||||
|
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
return async_session()
|
||||||
|
|
||||||
|
|
||||||
|
def download_czds_zone(tld: str) -> Optional[Path]:
|
||||||
|
"""Download a single CZDS zone file using pyCZDS"""
|
||||||
|
try:
|
||||||
|
from pyczds.client import CZDSClient
|
||||||
|
|
||||||
|
# Read credentials from .env
|
||||||
|
env_file = Path(__file__).parent.parent / ".env"
|
||||||
|
if not env_file.exists():
|
||||||
|
env_file = Path("/home/user/pounce/backend/.env")
|
||||||
|
|
||||||
|
env_content = env_file.read_text()
|
||||||
|
username = password = None
|
||||||
|
|
||||||
|
for line in env_content.splitlines():
|
||||||
|
if line.startswith("CZDS_USERNAME="):
|
||||||
|
username = line.split("=", 1)[1].strip()
|
||||||
|
elif line.startswith("CZDS_PASSWORD="):
|
||||||
|
password = line.split("=", 1)[1].strip()
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
logger.error(f"CZDS credentials not found in .env")
|
||||||
|
return None
|
||||||
|
|
||||||
|
client = CZDSClient(username, password)
|
||||||
|
urls = client.get_zonefiles_list()
|
||||||
|
|
||||||
|
# Find URL for this TLD
|
||||||
|
target_url = None
|
||||||
|
for url in urls:
|
||||||
|
if f"{tld}.zone" in url or f"/{tld}." in url:
|
||||||
|
target_url = url
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_url:
|
||||||
|
logger.warning(f"No access to .{tld} zone file")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info(f"Downloading .{tld} from CZDS...")
|
||||||
|
result = client.get_zonefile(target_url, download_dir=str(CZDS_DIR))
|
||||||
|
|
||||||
|
# Find the downloaded file
|
||||||
|
gz_file = CZDS_DIR / f"{tld}.txt.gz"
|
||||||
|
if gz_file.exists():
|
||||||
|
return gz_file
|
||||||
|
|
||||||
|
# Try alternative naming
|
||||||
|
for f in CZDS_DIR.glob(f"*{tld}*.gz"):
|
||||||
|
return f
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"CZDS download failed for .{tld}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def download_switch_zone(tld: str) -> Optional[Path]:
|
||||||
|
"""Download zone file from Switch.ch via AXFR"""
|
||||||
|
config = SWITCH_CONFIG.get(tld)
|
||||||
|
if not config:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
output_file = SWITCH_DIR / f"{tld}_zone.txt"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"dig", "@" + config["server"],
|
||||||
|
f"{tld}.", "AXFR",
|
||||||
|
"-y", f"hmac-sha512:{config['key_name']}:{config['key_secret']}"
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(f"Downloading .{tld} via AXFR from Switch.ch...")
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(f"AXFR failed for .{tld}: {result.stderr}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
output_file.write_text(result.stdout)
|
||||||
|
return output_file
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error(f"AXFR timeout for .{tld}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AXFR failed for .{tld}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_czds_zone(gz_file: Path, tld: str) -> set:
|
||||||
|
"""Parse a gzipped CZDS zone file and extract unique root domains"""
|
||||||
|
domains = set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with gzip.open(gz_file, 'rt', encoding='utf-8', errors='ignore') as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith(';') or not line.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 4:
|
||||||
|
name = parts[0].rstrip('.')
|
||||||
|
|
||||||
|
if name.lower().endswith(f'.{tld}'):
|
||||||
|
domain_name = name[:-(len(tld) + 1)]
|
||||||
|
|
||||||
|
# Only root domains (no subdomains)
|
||||||
|
if domain_name and '.' not in domain_name:
|
||||||
|
domains.add(domain_name.lower())
|
||||||
|
|
||||||
|
return domains
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to parse .{tld} zone file: {e}")
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_switch_zone(zone_file: Path, tld: str) -> set:
|
||||||
|
"""Parse Switch.ch AXFR output and extract unique root domains"""
|
||||||
|
domains = set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = zone_file.read_text()
|
||||||
|
|
||||||
|
for line in content.splitlines():
|
||||||
|
if line.startswith(';') or not line.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 4:
|
||||||
|
name = parts[0].rstrip('.')
|
||||||
|
|
||||||
|
# Skip the TLD itself
|
||||||
|
if name.lower() == tld:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if name.lower().endswith(f'.{tld}'):
|
||||||
|
domain_name = name[:-(len(tld) + 1)]
|
||||||
|
|
||||||
|
# Only root domains
|
||||||
|
if domain_name and '.' not in domain_name:
|
||||||
|
domains.add(domain_name.lower())
|
||||||
|
|
||||||
|
return domains
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to parse .{tld} zone file: {e}")
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def save_domains(tld: str, domains: Set[str], directory: Path) -> Path:
|
||||||
|
"""Save domain list to compressed file for storage efficiency"""
|
||||||
|
if COMPRESS_DOMAIN_LISTS:
|
||||||
|
out_file = directory / f"{tld}_domains.txt.gz"
|
||||||
|
# Remove old uncompressed file if exists
|
||||||
|
old_file = directory / f"{tld}_domains.txt"
|
||||||
|
if old_file.exists():
|
||||||
|
old_file.unlink()
|
||||||
|
# Write compressed
|
||||||
|
with gzip.open(out_file, 'wt', encoding='utf-8', compresslevel=9) as f:
|
||||||
|
f.write('\n'.join(sorted(domains)))
|
||||||
|
return out_file
|
||||||
|
else:
|
||||||
|
out_file = directory / f"{tld}_domains.txt"
|
||||||
|
out_file.write_text('\n'.join(sorted(domains)))
|
||||||
|
return out_file
|
||||||
|
|
||||||
|
|
||||||
|
def load_previous_domains(tld: str, directory: Path) -> Set[str]:
|
||||||
|
"""Load previous day's domain list (compressed or uncompressed)"""
|
||||||
|
# Try compressed first
|
||||||
|
gz_file = directory / f"{tld}_domains.txt.gz"
|
||||||
|
if gz_file.exists():
|
||||||
|
try:
|
||||||
|
with gzip.open(gz_file, 'rt', encoding='utf-8') as f:
|
||||||
|
return set(f.read().splitlines())
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to read compressed domains for .{tld}: {e}")
|
||||||
|
return set()
|
||||||
|
|
||||||
|
# Fallback to uncompressed
|
||||||
|
txt_file = directory / f"{tld}_domains.txt"
|
||||||
|
if txt_file.exists():
|
||||||
|
try:
|
||||||
|
return set(txt_file.read_text().splitlines())
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def detect_drops(tld: str, today_domains: set, yesterday_domains: set) -> set:
|
||||||
|
"""Detect domains that were dropped (present yesterday, missing today)"""
|
||||||
|
if not yesterday_domains:
|
||||||
|
logger.info(f".{tld}: No previous data for comparison (first run)")
|
||||||
|
return set()
|
||||||
|
|
||||||
|
drops = yesterday_domains - today_domains
|
||||||
|
return drops
|
||||||
|
|
||||||
|
|
||||||
|
async def store_drops_in_db(drops: list[tuple[str, str]], session: AsyncSession):
|
||||||
|
"""Store dropped domains in database using existing DroppedDomain model"""
|
||||||
|
if not drops:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Delete old drops (older than 48 hours)
|
||||||
|
cutoff = now - timedelta(hours=48)
|
||||||
|
await session.execute(
|
||||||
|
text("DELETE FROM dropped_domains WHERE dropped_date < :cutoff"),
|
||||||
|
{"cutoff": cutoff}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert new drops
|
||||||
|
count = 0
|
||||||
|
for domain, tld in drops:
|
||||||
|
try:
|
||||||
|
# Calculate domain properties
|
||||||
|
length = len(domain)
|
||||||
|
is_numeric = domain.isdigit()
|
||||||
|
has_hyphen = '-' in domain
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
text("""
|
||||||
|
INSERT OR REPLACE INTO dropped_domains
|
||||||
|
(domain, tld, dropped_date, length, is_numeric, has_hyphen, created_at)
|
||||||
|
VALUES (:domain, :tld, :dropped_date, :length, :is_numeric, :has_hyphen, :created_at)
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"domain": domain,
|
||||||
|
"tld": tld,
|
||||||
|
"dropped_date": now,
|
||||||
|
"length": length,
|
||||||
|
"is_numeric": is_numeric,
|
||||||
|
"has_hyphen": has_hyphen,
|
||||||
|
"created_at": now
|
||||||
|
}
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to insert drop {domain}.{tld}: {e}")
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_czds_tld(tld: str) -> ZoneSyncResult:
|
||||||
|
"""Sync a single CZDS TLD"""
|
||||||
|
result = ZoneSyncResult(tld)
|
||||||
|
start = datetime.now()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load previous domains for comparison
|
||||||
|
yesterday_domains = load_previous_domains(tld, CZDS_DIR)
|
||||||
|
|
||||||
|
# Download new zone file
|
||||||
|
gz_file = download_czds_zone(tld)
|
||||||
|
if not gz_file:
|
||||||
|
result.error = "Download failed"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Parse zone file
|
||||||
|
logger.info(f"Parsing .{tld} zone file...")
|
||||||
|
today_domains = parse_czds_zone(gz_file, tld)
|
||||||
|
|
||||||
|
if not today_domains:
|
||||||
|
result.error = "Parsing failed - no domains extracted"
|
||||||
|
return result
|
||||||
|
|
||||||
|
result.domain_count = len(today_domains)
|
||||||
|
|
||||||
|
# Detect drops
|
||||||
|
drops = detect_drops(tld, today_domains, yesterday_domains)
|
||||||
|
result.drops_count = len(drops)
|
||||||
|
|
||||||
|
# Save current domains for tomorrow's comparison
|
||||||
|
save_domains(tld, today_domains, CZDS_DIR)
|
||||||
|
|
||||||
|
# Cleanup gz file
|
||||||
|
if gz_file.exists():
|
||||||
|
gz_file.unlink()
|
||||||
|
|
||||||
|
# Update last download marker
|
||||||
|
marker = CZDS_DIR / f".{tld}_last_download"
|
||||||
|
marker.write_text(datetime.utcnow().isoformat())
|
||||||
|
|
||||||
|
result.success = True
|
||||||
|
logger.info(f"✅ .{tld}: {result.domain_count:,} domains, {result.drops_count:,} drops")
|
||||||
|
|
||||||
|
# Return drops for DB storage
|
||||||
|
result.drops = [(d, tld) for d in drops]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result.error = str(e)
|
||||||
|
logger.error(f"❌ .{tld} sync failed: {e}")
|
||||||
|
|
||||||
|
result.duration_seconds = (datetime.now() - start).total_seconds()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_switch_tld(tld: str) -> ZoneSyncResult:
|
||||||
|
"""Sync a single Switch.ch TLD"""
|
||||||
|
result = ZoneSyncResult(tld)
|
||||||
|
start = datetime.now()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load previous domains for comparison
|
||||||
|
yesterday_domains = load_previous_domains(tld, SWITCH_DIR)
|
||||||
|
|
||||||
|
# Download new zone file
|
||||||
|
zone_file = download_switch_zone(tld)
|
||||||
|
if not zone_file:
|
||||||
|
result.error = "AXFR failed"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Parse zone file
|
||||||
|
logger.info(f"Parsing .{tld} zone file...")
|
||||||
|
today_domains = parse_switch_zone(zone_file, tld)
|
||||||
|
|
||||||
|
if not today_domains:
|
||||||
|
result.error = "Parsing failed - no domains extracted"
|
||||||
|
return result
|
||||||
|
|
||||||
|
result.domain_count = len(today_domains)
|
||||||
|
|
||||||
|
# Detect drops
|
||||||
|
drops = detect_drops(tld, today_domains, yesterday_domains)
|
||||||
|
result.drops_count = len(drops)
|
||||||
|
|
||||||
|
# Save current domains for tomorrow's comparison
|
||||||
|
save_domains(tld, today_domains, SWITCH_DIR)
|
||||||
|
|
||||||
|
# Cleanup raw zone file (keep only domain list)
|
||||||
|
if zone_file.exists():
|
||||||
|
zone_file.unlink()
|
||||||
|
|
||||||
|
result.success = True
|
||||||
|
logger.info(f"✅ .{tld}: {result.domain_count:,} domains, {result.drops_count:,} drops")
|
||||||
|
|
||||||
|
# Return drops for DB storage
|
||||||
|
result.drops = [(d, tld) for d in drops]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result.error = str(e)
|
||||||
|
logger.error(f"❌ .{tld} sync failed: {e}")
|
||||||
|
|
||||||
|
result.duration_seconds = (datetime.now() - start).total_seconds()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_stray_files(directory: Path, keep_extensions: list = None):
|
||||||
|
"""Remove any stray/temporary files to save space"""
|
||||||
|
if keep_extensions is None:
|
||||||
|
keep_extensions = ['.txt.gz', '.txt'] # Only keep domain lists
|
||||||
|
|
||||||
|
removed_count = 0
|
||||||
|
removed_size = 0
|
||||||
|
|
||||||
|
for f in directory.iterdir():
|
||||||
|
if f.is_file():
|
||||||
|
# Keep marker files
|
||||||
|
if f.name.startswith('.'):
|
||||||
|
continue
|
||||||
|
# Keep domain list files
|
||||||
|
if any(f.name.endswith(ext) for ext in keep_extensions):
|
||||||
|
continue
|
||||||
|
# Remove everything else (raw zone files, temp files)
|
||||||
|
try:
|
||||||
|
size = f.stat().st_size
|
||||||
|
f.unlink()
|
||||||
|
removed_count += 1
|
||||||
|
removed_size += size
|
||||||
|
logger.info(f"🗑️ Removed stray file: {f.name} ({size / (1024*1024):.1f} MB)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to remove {f.name}: {e}")
|
||||||
|
|
||||||
|
return removed_count, removed_size
|
||||||
|
|
||||||
|
|
||||||
|
def get_directory_size(directory: Path) -> int:
|
||||||
|
"""Get total size of directory in bytes"""
|
||||||
|
total = 0
|
||||||
|
for f in directory.rglob('*'):
|
||||||
|
if f.is_file():
|
||||||
|
total += f.stat().st_size
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def log_storage_stats():
|
||||||
|
"""Log current storage usage"""
|
||||||
|
czds_size = get_directory_size(CZDS_DIR) if CZDS_DIR.exists() else 0
|
||||||
|
switch_size = get_directory_size(SWITCH_DIR) if SWITCH_DIR.exists() else 0
|
||||||
|
total = czds_size + switch_size
|
||||||
|
|
||||||
|
logger.info(f"💾 STORAGE: CZDS={czds_size/(1024*1024):.1f}MB, Switch={switch_size/(1024*1024):.1f}MB, Total={total/(1024*1024):.1f}MB")
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Main sync process"""
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("🚀 POUNCE ZONE SYNC - Starting daily synchronization")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
start_time = datetime.now()
|
||||||
|
|
||||||
|
# Ensure directories exist
|
||||||
|
CZDS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
SWITCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Log initial storage
|
||||||
|
logger.info("\n📊 Initial storage check...")
|
||||||
|
initial_storage = log_storage_stats()
|
||||||
|
|
||||||
|
all_drops = []
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Sync CZDS TLDs (sequentially to respect rate limits)
|
||||||
|
logger.info("\n📦 Syncing ICANN CZDS zone files...")
|
||||||
|
for tld in CZDS_TLDS:
|
||||||
|
result = await sync_czds_tld(tld)
|
||||||
|
results.append(result)
|
||||||
|
if hasattr(result, 'drops'):
|
||||||
|
all_drops.extend(result.drops)
|
||||||
|
|
||||||
|
# Rate limit: wait between downloads
|
||||||
|
if tld != CZDS_TLDS[-1]:
|
||||||
|
logger.info("⏳ Waiting 5 seconds (rate limit)...")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
# Sync Switch.ch TLDs
|
||||||
|
logger.info("\n🇨🇭 Syncing Switch.ch zone files...")
|
||||||
|
for tld in ["ch", "li"]:
|
||||||
|
result = await sync_switch_tld(tld)
|
||||||
|
results.append(result)
|
||||||
|
if hasattr(result, 'drops'):
|
||||||
|
all_drops.extend(result.drops)
|
||||||
|
|
||||||
|
# Store drops in database
|
||||||
|
if all_drops:
|
||||||
|
logger.info(f"\n💾 Storing {len(all_drops)} drops in database...")
|
||||||
|
try:
|
||||||
|
session = await get_db_session()
|
||||||
|
stored = await store_drops_in_db(all_drops, session)
|
||||||
|
await session.close()
|
||||||
|
logger.info(f"✅ Stored {stored} drops in database")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to store drops: {e}")
|
||||||
|
|
||||||
|
# Cleanup stray files
|
||||||
|
logger.info("\n🧹 Cleaning up temporary files...")
|
||||||
|
czds_removed, czds_freed = cleanup_stray_files(CZDS_DIR)
|
||||||
|
switch_removed, switch_freed = cleanup_stray_files(SWITCH_DIR)
|
||||||
|
total_freed = czds_freed + switch_freed
|
||||||
|
if total_freed > 0:
|
||||||
|
logger.info(f"✅ Freed {total_freed / (1024*1024):.1f} MB ({czds_removed + switch_removed} files)")
|
||||||
|
else:
|
||||||
|
logger.info("✅ No stray files found")
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
duration = (datetime.now() - start_time).total_seconds()
|
||||||
|
|
||||||
|
logger.info("\n" + "=" * 60)
|
||||||
|
logger.info("📊 SYNC SUMMARY")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
total_domains = 0
|
||||||
|
total_drops = 0
|
||||||
|
success_count = 0
|
||||||
|
|
||||||
|
for r in results:
|
||||||
|
status = "✅" if r.success else "❌"
|
||||||
|
logger.info(f" {status} .{r.tld}: {r.domain_count:,} domains, {r.drops_count:,} drops ({r.duration_seconds:.1f}s)")
|
||||||
|
if r.success:
|
||||||
|
total_domains += r.domain_count
|
||||||
|
total_drops += r.drops_count
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
logger.info("-" * 60)
|
||||||
|
logger.info(f" TOTAL: {total_domains:,} domains across {success_count}/{len(results)} TLDs")
|
||||||
|
logger.info(f" DROPS: {total_drops:,} new drops detected")
|
||||||
|
logger.info(f" TIME: {duration:.1f} seconds")
|
||||||
|
|
||||||
|
# Final storage stats
|
||||||
|
logger.info("-" * 60)
|
||||||
|
final_storage = log_storage_stats()
|
||||||
|
if initial_storage > 0:
|
||||||
|
change = final_storage - initial_storage
|
||||||
|
logger.info(f" CHANGE: {'+' if change > 0 else ''}{change/(1024*1024):.1f} MB")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
return 0 if success_count == len(results) else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
exit_code = asyncio.run(main())
|
||||||
|
sys.exit(exit_code)
|
||||||
@ -6,13 +6,34 @@ import BlogPostClient from './BlogPostClient'
|
|||||||
import type { BlogPost } from './types'
|
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({
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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 */}
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user