Less top padding, more horizontal padding
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
This commit is contained in:
@ -68,11 +68,11 @@ class AuctionScraperService:
|
|||||||
"""
|
"""
|
||||||
Orchestrates scraping across multiple sources and stores results in DB.
|
Orchestrates scraping across multiple sources and stores results in DB.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.http_client: Optional[httpx.AsyncClient] = None
|
self.http_client: Optional[httpx.AsyncClient] = None
|
||||||
self._last_request: Dict[str, datetime] = {}
|
self._last_request: Dict[str, datetime] = {}
|
||||||
|
|
||||||
async def _get_client(self) -> httpx.AsyncClient:
|
async def _get_client(self) -> httpx.AsyncClient:
|
||||||
"""Get or create HTTP client with appropriate headers (and optional proxy)."""
|
"""Get or create HTTP client with appropriate headers (and optional proxy)."""
|
||||||
if self.http_client is None or self.http_client.is_closed:
|
if self.http_client is None or self.http_client.is_closed:
|
||||||
@ -92,7 +92,7 @@ class AuctionScraperService:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
return self.http_client
|
return self.http_client
|
||||||
|
|
||||||
async def _rate_limit(self, platform: str):
|
async def _rate_limit(self, platform: str):
|
||||||
"""Enforce rate limiting per platform."""
|
"""Enforce rate limiting per platform."""
|
||||||
min_interval = 60 / RATE_LIMITS.get(platform, 10)
|
min_interval = 60 / RATE_LIMITS.get(platform, 10)
|
||||||
@ -102,7 +102,7 @@ class AuctionScraperService:
|
|||||||
if elapsed < min_interval:
|
if elapsed < min_interval:
|
||||||
await asyncio.sleep(min_interval - elapsed)
|
await asyncio.sleep(min_interval - elapsed)
|
||||||
self._last_request[platform] = datetime.utcnow()
|
self._last_request[platform] = datetime.utcnow()
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
# Parsing & validation helpers
|
# Parsing & validation helpers
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
@ -305,7 +305,7 @@ class AuctionScraperService:
|
|||||||
cleaned["currency"] = str(currency).strip().upper()
|
cleaned["currency"] = str(currency).strip().upper()
|
||||||
|
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
async def _store_auction(self, db: AsyncSession, auction_data: Dict[str, Any]) -> str:
|
async def _store_auction(self, db: AsyncSession, auction_data: Dict[str, Any]) -> str:
|
||||||
"""Store or update an auction in the database. Returns 'new', 'updated' or 'skipped'."""
|
"""Store or update an auction in the database. Returns 'new', 'updated' or 'skipped'."""
|
||||||
cleaned = self._sanitize_auction_payload(auction_data)
|
cleaned = self._sanitize_auction_payload(auction_data)
|
||||||
@ -325,7 +325,7 @@ class AuctionScraperService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
existing = existing.scalar_one_or_none()
|
existing = existing.scalar_one_or_none()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
# Prevent "end_time drift" on sources that only provide rounded time-left.
|
# Prevent "end_time drift" on sources that only provide rounded time-left.
|
||||||
# `end_time` must be monotonically decreasing (or stable) across scrapes.
|
# `end_time` must be monotonically decreasing (or stable) across scrapes.
|
||||||
@ -384,15 +384,15 @@ class AuctionScraperService:
|
|||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Scrape ExpiredDomains provider-specific auction pages (real Price/Bids/Endtime)."""
|
"""Scrape ExpiredDomains provider-specific auction pages (real Price/Bids/Endtime)."""
|
||||||
result = {"found": 0, "new": 0, "updated": 0}
|
result = {"found": 0, "new": 0, "updated": 0}
|
||||||
|
|
||||||
log = AuctionScrapeLog(platform=platform)
|
log = AuctionScrapeLog(platform=platform)
|
||||||
db.add(log)
|
db.add(log)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._rate_limit("ExpiredDomains")
|
await self._rate_limit("ExpiredDomains")
|
||||||
client = await self._get_client()
|
client = await self._get_client()
|
||||||
|
|
||||||
resp = await client.get(url, timeout=20.0)
|
resp = await client.get(url, timeout=20.0)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise Exception(f"HTTP {resp.status_code}")
|
raise Exception(f"HTTP {resp.status_code}")
|
||||||
@ -413,14 +413,14 @@ class AuctionScraperService:
|
|||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
for row in rows[:limit]:
|
for row in rows[:limit]:
|
||||||
cols = row.find_all("td")
|
cols = row.find_all("td")
|
||||||
if len(cols) < len(headers):
|
if len(cols) < len(headers):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
domain = cols[header_index["Domain"]].get_text(" ", strip=True).lower()
|
domain = cols[header_index["Domain"]].get_text(" ", strip=True).lower()
|
||||||
if not domain or "." not in domain:
|
if not domain or "." not in domain:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
tld = domain.rsplit(".", 1)[-1].lower()
|
tld = domain.rsplit(".", 1)[-1].lower()
|
||||||
|
|
||||||
parsed_price = self._parse_price_currency(cols[header_index["Price"]].get_text(" ", strip=True))
|
parsed_price = self._parse_price_currency(cols[header_index["Price"]].get_text(" ", strip=True))
|
||||||
@ -428,8 +428,8 @@ class AuctionScraperService:
|
|||||||
continue
|
continue
|
||||||
current_bid, currency = parsed_price
|
current_bid, currency = parsed_price
|
||||||
if current_bid <= 0:
|
if current_bid <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
bids_raw = cols[header_index["Bids"]].get_text(" ", strip=True)
|
bids_raw = cols[header_index["Bids"]].get_text(" ", strip=True)
|
||||||
try:
|
try:
|
||||||
num_bids = int(re.sub(r"[^0-9]", "", bids_raw) or "0")
|
num_bids = int(re.sub(r"[^0-9]", "", bids_raw) or "0")
|
||||||
@ -446,26 +446,26 @@ class AuctionScraperService:
|
|||||||
href = domain_link.get("href") if domain_link else None
|
href = domain_link.get("href") if domain_link else None
|
||||||
if href and href.startswith("/"):
|
if href and href.startswith("/"):
|
||||||
href = f"https://www.expireddomains.net{href}"
|
href = f"https://www.expireddomains.net{href}"
|
||||||
|
|
||||||
auction_data = {
|
auction_data = {
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"tld": tld,
|
"tld": tld,
|
||||||
"platform": platform,
|
"platform": platform,
|
||||||
"platform_auction_id": None,
|
"platform_auction_id": None,
|
||||||
"auction_url": href or build_affiliate_url(platform, domain),
|
"auction_url": href or build_affiliate_url(platform, domain),
|
||||||
"current_bid": current_bid,
|
"current_bid": current_bid,
|
||||||
"currency": currency,
|
"currency": currency,
|
||||||
"num_bids": num_bids,
|
"num_bids": num_bids,
|
||||||
"end_time": end_time,
|
"end_time": end_time,
|
||||||
"scrape_source": f"expireddomains:{url}",
|
"scrape_source": f"expireddomains:{url}",
|
||||||
}
|
}
|
||||||
|
|
||||||
status = await self._store_auction(db, auction_data)
|
status = await self._store_auction(db, auction_data)
|
||||||
if status == "skipped":
|
if status == "skipped":
|
||||||
continue
|
continue
|
||||||
result["found"] += 1
|
result["found"] += 1
|
||||||
result[status] += 1
|
result[status] += 1
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
log.completed_at = datetime.utcnow()
|
log.completed_at = datetime.utcnow()
|
||||||
@ -474,16 +474,16 @@ class AuctionScraperService:
|
|||||||
log.auctions_new = result["new"]
|
log.auctions_new = result["new"]
|
||||||
log.auctions_updated = result["updated"]
|
log.auctions_updated = result["updated"]
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.completed_at = datetime.utcnow()
|
log.completed_at = datetime.utcnow()
|
||||||
log.status = "failed"
|
log.status = "failed"
|
||||||
log.error_message = str(e)[:500]
|
log.error_message = str(e)[:500]
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.error(f"ExpiredDomains({platform}) scrape failed: {e}")
|
logger.error(f"ExpiredDomains({platform}) scrape failed: {e}")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def _scrape_expireddomains_godaddy(self, db: AsyncSession) -> Dict[str, Any]:
|
async def _scrape_expireddomains_godaddy(self, db: AsyncSession) -> Dict[str, Any]:
|
||||||
return await self._scrape_expireddomains_auction_page(
|
return await self._scrape_expireddomains_auction_page(
|
||||||
db=db,
|
db=db,
|
||||||
@ -509,15 +509,15 @@ class AuctionScraperService:
|
|||||||
"""Scrape Park.io public auctions page (includes price + close date)."""
|
"""Scrape Park.io public auctions page (includes price + close date)."""
|
||||||
platform = "Park.io"
|
platform = "Park.io"
|
||||||
result = {"found": 0, "new": 0, "updated": 0}
|
result = {"found": 0, "new": 0, "updated": 0}
|
||||||
|
|
||||||
log = AuctionScrapeLog(platform=platform)
|
log = AuctionScrapeLog(platform=platform)
|
||||||
db.add(log)
|
db.add(log)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._rate_limit(platform)
|
await self._rate_limit(platform)
|
||||||
client = await self._get_client()
|
client = await self._get_client()
|
||||||
|
|
||||||
resp = await client.get("https://park.io/auctions", timeout=20.0)
|
resp = await client.get("https://park.io/auctions", timeout=20.0)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise Exception(f"HTTP {resp.status_code}")
|
raise Exception(f"HTTP {resp.status_code}")
|
||||||
@ -531,8 +531,8 @@ class AuctionScraperService:
|
|||||||
for row in rows[:200]:
|
for row in rows[:200]:
|
||||||
cols = row.find_all("td")
|
cols = row.find_all("td")
|
||||||
if len(cols) < 5:
|
if len(cols) < 5:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
domain = cols[1].get_text(" ", strip=True).lower()
|
domain = cols[1].get_text(" ", strip=True).lower()
|
||||||
if not domain or "." not in domain:
|
if not domain or "." not in domain:
|
||||||
continue
|
continue
|
||||||
@ -544,14 +544,14 @@ class AuctionScraperService:
|
|||||||
continue
|
continue
|
||||||
current_bid, currency = parsed_price
|
current_bid, currency = parsed_price
|
||||||
if current_bid <= 0:
|
if current_bid <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
bids_raw = cols[3].get_text(" ", strip=True)
|
bids_raw = cols[3].get_text(" ", strip=True)
|
||||||
try:
|
try:
|
||||||
num_bids = int(re.sub(r"[^0-9]", "", bids_raw) or "0")
|
num_bids = int(re.sub(r"[^0-9]", "", bids_raw) or "0")
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
close_raw = cols[4].get_text(" ", strip=True)
|
close_raw = cols[4].get_text(" ", strip=True)
|
||||||
try:
|
try:
|
||||||
# Park.io displays a naive timestamp in their platform timezone.
|
# Park.io displays a naive timestamp in their platform timezone.
|
||||||
@ -567,25 +567,25 @@ class AuctionScraperService:
|
|||||||
href = link_el["href"] if link_el else None
|
href = link_el["href"] if link_el else None
|
||||||
if href and href.startswith("/"):
|
if href and href.startswith("/"):
|
||||||
href = f"https://park.io{href}"
|
href = f"https://park.io{href}"
|
||||||
|
|
||||||
auction_data = {
|
auction_data = {
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"tld": tld,
|
"tld": tld,
|
||||||
"platform": platform,
|
"platform": platform,
|
||||||
"auction_url": href or "https://park.io/auctions",
|
"auction_url": href or "https://park.io/auctions",
|
||||||
"current_bid": current_bid,
|
"current_bid": current_bid,
|
||||||
"currency": currency,
|
"currency": currency,
|
||||||
"num_bids": num_bids,
|
"num_bids": num_bids,
|
||||||
"end_time": end_time,
|
"end_time": end_time,
|
||||||
"scrape_source": "park.io:auctions",
|
"scrape_source": "park.io:auctions",
|
||||||
}
|
}
|
||||||
|
|
||||||
status = await self._store_auction(db, auction_data)
|
status = await self._store_auction(db, auction_data)
|
||||||
if status == "skipped":
|
if status == "skipped":
|
||||||
continue
|
continue
|
||||||
result["found"] += 1
|
result["found"] += 1
|
||||||
result[status] += 1
|
result[status] += 1
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
log.completed_at = datetime.utcnow()
|
log.completed_at = datetime.utcnow()
|
||||||
@ -594,29 +594,29 @@ class AuctionScraperService:
|
|||||||
log.auctions_new = result["new"]
|
log.auctions_new = result["new"]
|
||||||
log.auctions_updated = result["updated"]
|
log.auctions_updated = result["updated"]
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.completed_at = datetime.utcnow()
|
log.completed_at = datetime.utcnow()
|
||||||
log.status = "failed"
|
log.status = "failed"
|
||||||
log.error_message = str(e)[:500]
|
log.error_message = str(e)[:500]
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.error(f"Park.io scrape failed: {e}")
|
logger.error(f"Park.io scrape failed: {e}")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def _scrape_sav_public(self, db: AsyncSession) -> Dict[str, Any]:
|
async def _scrape_sav_public(self, db: AsyncSession) -> Dict[str, Any]:
|
||||||
"""Scrape Sav auctions from their HTML table endpoint."""
|
"""Scrape Sav auctions from their HTML table endpoint."""
|
||||||
platform = "Sav"
|
platform = "Sav"
|
||||||
result = {"found": 0, "new": 0, "updated": 0}
|
result = {"found": 0, "new": 0, "updated": 0}
|
||||||
|
|
||||||
log = AuctionScrapeLog(platform=platform)
|
log = AuctionScrapeLog(platform=platform)
|
||||||
db.add(log)
|
db.add(log)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._rate_limit(platform)
|
await self._rate_limit(platform)
|
||||||
client = await self._get_client()
|
client = await self._get_client()
|
||||||
|
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
for page in range(0, 3):
|
for page in range(0, 3):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
@ -636,7 +636,7 @@ class AuctionScraperService:
|
|||||||
cells = row.find_all("td")
|
cells = row.find_all("td")
|
||||||
if len(cells) < 7:
|
if len(cells) < 7:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
domain_link = cells[1].find("a")
|
domain_link = cells[1].find("a")
|
||||||
domain = domain_link.get_text(" ", strip=True).lower() if domain_link else ""
|
domain = domain_link.get_text(" ", strip=True).lower() if domain_link else ""
|
||||||
if not domain or "." not in domain:
|
if not domain or "." not in domain:
|
||||||
@ -655,38 +655,38 @@ class AuctionScraperService:
|
|||||||
try:
|
try:
|
||||||
num_bids = int(re.sub(r"[^0-9]", "", bids_raw) or "0")
|
num_bids = int(re.sub(r"[^0-9]", "", bids_raw) or "0")
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
time_left_raw = cells[6].get_text(" ", strip=True)
|
time_left_raw = cells[6].get_text(" ", strip=True)
|
||||||
delta = self._parse_timeleft(time_left_raw)
|
delta = self._parse_timeleft(time_left_raw)
|
||||||
if not delta:
|
if not delta:
|
||||||
continue
|
continue
|
||||||
end_time = now + delta
|
end_time = now + delta
|
||||||
|
|
||||||
href = domain_link.get("href") if domain_link else None
|
href = domain_link.get("href") if domain_link else None
|
||||||
if href and href.startswith("/"):
|
if href and href.startswith("/"):
|
||||||
href = f"https://www.sav.com{href}"
|
href = f"https://www.sav.com{href}"
|
||||||
|
|
||||||
auction_data = {
|
auction_data = {
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"tld": tld,
|
"tld": tld,
|
||||||
"platform": platform,
|
"platform": platform,
|
||||||
"auction_url": href or "https://www.sav.com/domains/auctions",
|
"auction_url": href or "https://www.sav.com/domains/auctions",
|
||||||
"current_bid": current_bid,
|
"current_bid": current_bid,
|
||||||
"currency": currency,
|
"currency": currency,
|
||||||
"num_bids": num_bids,
|
"num_bids": num_bids,
|
||||||
"end_time": end_time,
|
"end_time": end_time,
|
||||||
"scrape_source": f"sav:load_domains_ajax:{page}",
|
"scrape_source": f"sav:load_domains_ajax:{page}",
|
||||||
}
|
}
|
||||||
|
|
||||||
status = await self._store_auction(db, auction_data)
|
status = await self._store_auction(db, auction_data)
|
||||||
if status == "skipped":
|
if status == "skipped":
|
||||||
continue
|
continue
|
||||||
result["found"] += 1
|
result["found"] += 1
|
||||||
result[status] += 1
|
result[status] += 1
|
||||||
|
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
log.completed_at = datetime.utcnow()
|
log.completed_at = datetime.utcnow()
|
||||||
@ -695,16 +695,16 @@ class AuctionScraperService:
|
|||||||
log.auctions_new = result["new"]
|
log.auctions_new = result["new"]
|
||||||
log.auctions_updated = result["updated"]
|
log.auctions_updated = result["updated"]
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.completed_at = datetime.utcnow()
|
log.completed_at = datetime.utcnow()
|
||||||
log.status = "failed"
|
log.status = "failed"
|
||||||
log.error_message = str(e)[:500]
|
log.error_message = str(e)[:500]
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.error(f"Sav scrape failed: {e}")
|
logger.error(f"Sav scrape failed: {e}")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
# Orchestration
|
# Orchestration
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
@ -729,7 +729,7 @@ class AuctionScraperService:
|
|||||||
for item in hidden_api_result.get("items", []):
|
for item in hidden_api_result.get("items", []):
|
||||||
action = await self._store_auction(db, item)
|
action = await self._store_auction(db, item)
|
||||||
if action == "skipped":
|
if action == "skipped":
|
||||||
continue
|
continue
|
||||||
platform = item.get("platform", "Unknown")
|
platform = item.get("platform", "Unknown")
|
||||||
_touch_platform(platform)
|
_touch_platform(platform)
|
||||||
results["platforms"][platform]["found"] += 1
|
results["platforms"][platform]["found"] += 1
|
||||||
@ -777,7 +777,7 @@ class AuctionScraperService:
|
|||||||
results["total_found"] += r.get("found", 0)
|
results["total_found"] += r.get("found", 0)
|
||||||
results["total_new"] += r.get("new", 0)
|
results["total_new"] += r.get("new", 0)
|
||||||
results["total_updated"] += r.get("updated", 0)
|
results["total_updated"] += r.get("updated", 0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results["errors"].append(f"{platform_name}: {str(e)}")
|
results["errors"].append(f"{platform_name}: {str(e)}")
|
||||||
|
|
||||||
# TIER 3: Playwright (opt-in)
|
# TIER 3: Playwright (opt-in)
|
||||||
@ -804,98 +804,98 @@ class AuctionScraperService:
|
|||||||
results["errors"].append(f"Playwright: {error}")
|
results["errors"].append(f"Playwright: {error}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results["errors"].append(f"Playwright: {str(e)}")
|
results["errors"].append(f"Playwright: {str(e)}")
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await self._cleanup_ended_auctions(db)
|
await self._cleanup_ended_auctions(db)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
# Tier 1 helpers (official APIs)
|
# Tier 1 helpers (official APIs)
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
|
|
||||||
async def _fetch_dropcatch_api(self, db: AsyncSession) -> Dict[str, Any]:
|
async def _fetch_dropcatch_api(self, db: AsyncSession) -> Dict[str, Any]:
|
||||||
platform = "DropCatch"
|
platform = "DropCatch"
|
||||||
result = {"found": 0, "new": 0, "updated": 0, "source": "api"}
|
result = {"found": 0, "new": 0, "updated": 0, "source": "api"}
|
||||||
|
|
||||||
if not dropcatch_client.is_configured:
|
if not dropcatch_client.is_configured:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
log = AuctionScrapeLog(platform=platform)
|
log = AuctionScrapeLog(platform=platform)
|
||||||
db.add(log)
|
db.add(log)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
api_result = await dropcatch_client.search_auctions(page_size=100)
|
api_result = await dropcatch_client.search_auctions(page_size=100)
|
||||||
auctions = api_result.get("auctions") or api_result.get("items") or []
|
auctions = api_result.get("auctions") or api_result.get("items") or []
|
||||||
result["found"] = len(auctions)
|
result["found"] = len(auctions)
|
||||||
|
|
||||||
for dc_auction in auctions:
|
for dc_auction in auctions:
|
||||||
auction_data = dropcatch_client.transform_to_pounce_format(dc_auction)
|
auction_data = dropcatch_client.transform_to_pounce_format(dc_auction)
|
||||||
status = await self._store_auction(db, auction_data)
|
status = await self._store_auction(db, auction_data)
|
||||||
if status == "skipped":
|
if status == "skipped":
|
||||||
continue
|
continue
|
||||||
result[status] += 1
|
result[status] += 1
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
log.status = "success"
|
log.status = "success"
|
||||||
log.auctions_found = result["found"]
|
log.auctions_found = result["found"]
|
||||||
log.auctions_new = result["new"]
|
log.auctions_new = result["new"]
|
||||||
log.auctions_updated = result["updated"]
|
log.auctions_updated = result["updated"]
|
||||||
log.completed_at = datetime.utcnow()
|
log.completed_at = datetime.utcnow()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.status = "failed"
|
log.status = "failed"
|
||||||
log.error_message = str(e)[:500]
|
log.error_message = str(e)[:500]
|
||||||
log.completed_at = datetime.utcnow()
|
log.completed_at = datetime.utcnow()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def _fetch_sedo_api(self, db: AsyncSession) -> Dict[str, Any]:
|
async def _fetch_sedo_api(self, db: AsyncSession) -> Dict[str, Any]:
|
||||||
platform = "Sedo"
|
platform = "Sedo"
|
||||||
result = {"found": 0, "new": 0, "updated": 0, "source": "api"}
|
result = {"found": 0, "new": 0, "updated": 0, "source": "api"}
|
||||||
|
|
||||||
if not sedo_client.is_configured:
|
if not sedo_client.is_configured:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
log = AuctionScrapeLog(platform=platform)
|
log = AuctionScrapeLog(platform=platform)
|
||||||
db.add(log)
|
db.add(log)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
api_result = await sedo_client.search_auctions(page_size=100)
|
api_result = await sedo_client.search_auctions(page_size=100)
|
||||||
listings = api_result.get("domains") or api_result.get("items") or api_result.get("result") or []
|
listings = api_result.get("domains") or api_result.get("items") or api_result.get("result") or []
|
||||||
if isinstance(listings, dict):
|
if isinstance(listings, dict):
|
||||||
listings = list(listings.values()) if listings else []
|
listings = list(listings.values()) if listings else []
|
||||||
|
|
||||||
result["found"] = len(listings)
|
result["found"] = len(listings)
|
||||||
|
|
||||||
for sedo_listing in listings:
|
for sedo_listing in listings:
|
||||||
auction_data = sedo_client.transform_to_pounce_format(sedo_listing)
|
auction_data = sedo_client.transform_to_pounce_format(sedo_listing)
|
||||||
status = await self._store_auction(db, auction_data)
|
status = await self._store_auction(db, auction_data)
|
||||||
if status == "skipped":
|
if status == "skipped":
|
||||||
continue
|
continue
|
||||||
result[status] += 1
|
result[status] += 1
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
log.status = "success"
|
log.status = "success"
|
||||||
log.auctions_found = result["found"]
|
log.auctions_found = result["found"]
|
||||||
log.auctions_new = result["new"]
|
log.auctions_new = result["new"]
|
||||||
log.auctions_updated = result["updated"]
|
log.auctions_updated = result["updated"]
|
||||||
log.completed_at = datetime.utcnow()
|
log.completed_at = datetime.utcnow()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.status = "failed"
|
log.status = "failed"
|
||||||
log.error_message = str(e)[:500]
|
log.error_message = str(e)[:500]
|
||||||
log.completed_at = datetime.utcnow()
|
log.completed_at = datetime.utcnow()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
# DB cleanup / queries
|
# DB cleanup / queries
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
@ -903,7 +903,7 @@ class AuctionScraperService:
|
|||||||
async def _cleanup_ended_auctions(self, db: AsyncSession):
|
async def _cleanup_ended_auctions(self, db: AsyncSession):
|
||||||
"""Mark auctions that have ended as inactive and delete very old inactive auctions."""
|
"""Mark auctions that have ended as inactive and delete very old inactive auctions."""
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
from sqlalchemy import update
|
from sqlalchemy import update
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@ -911,14 +911,14 @@ class AuctionScraperService:
|
|||||||
.where(and_(DomainAuction.end_time < now, DomainAuction.is_active == True))
|
.where(and_(DomainAuction.end_time < now, DomainAuction.is_active == True))
|
||||||
.values(is_active=False)
|
.values(is_active=False)
|
||||||
)
|
)
|
||||||
|
|
||||||
cutoff = now - timedelta(days=30)
|
cutoff = now - timedelta(days=30)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
delete(DomainAuction).where(and_(DomainAuction.is_active == False, DomainAuction.end_time < cutoff))
|
delete(DomainAuction).where(and_(DomainAuction.is_active == False, DomainAuction.end_time < cutoff))
|
||||||
)
|
)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
async def get_active_auctions(
|
async def get_active_auctions(
|
||||||
self,
|
self,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
@ -934,7 +934,7 @@ class AuctionScraperService:
|
|||||||
) -> List[DomainAuction]:
|
) -> List[DomainAuction]:
|
||||||
"""Get active auctions from database with filters."""
|
"""Get active auctions from database with filters."""
|
||||||
query = select(DomainAuction).where(DomainAuction.is_active == True)
|
query = select(DomainAuction).where(DomainAuction.is_active == True)
|
||||||
|
|
||||||
if platform:
|
if platform:
|
||||||
query = query.where(DomainAuction.platform == platform)
|
query = query.where(DomainAuction.platform == platform)
|
||||||
if tld:
|
if tld:
|
||||||
@ -948,7 +948,7 @@ class AuctionScraperService:
|
|||||||
if ending_within_hours:
|
if ending_within_hours:
|
||||||
cutoff = datetime.utcnow() + timedelta(hours=ending_within_hours)
|
cutoff = datetime.utcnow() + timedelta(hours=ending_within_hours)
|
||||||
query = query.where(DomainAuction.end_time <= cutoff)
|
query = query.where(DomainAuction.end_time <= cutoff)
|
||||||
|
|
||||||
if sort_by == "end_time":
|
if sort_by == "end_time":
|
||||||
query = query.order_by(DomainAuction.end_time.asc())
|
query = query.order_by(DomainAuction.end_time.asc())
|
||||||
elif sort_by == "bid_asc":
|
elif sort_by == "bid_asc":
|
||||||
@ -957,17 +957,17 @@ class AuctionScraperService:
|
|||||||
query = query.order_by(DomainAuction.current_bid.desc())
|
query = query.order_by(DomainAuction.current_bid.desc())
|
||||||
elif sort_by == "bids":
|
elif sort_by == "bids":
|
||||||
query = query.order_by(DomainAuction.num_bids.desc())
|
query = query.order_by(DomainAuction.num_bids.desc())
|
||||||
|
|
||||||
result = await db.execute(query.offset(offset).limit(limit))
|
result = await db.execute(query.offset(offset).limit(limit))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
async def get_auction_count(self, db: AsyncSession) -> int:
|
async def get_auction_count(self, db: AsyncSession) -> int:
|
||||||
"""Get total count of active auctions."""
|
"""Get total count of active auctions."""
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
result = await db.execute(select(func.count(DomainAuction.id)).where(DomainAuction.is_active == True))
|
result = await db.execute(select(func.count(DomainAuction.id)).where(DomainAuction.is_active == True))
|
||||||
return result.scalar() or 0
|
return result.scalar() or 0
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
"""Close HTTP client."""
|
"""Close HTTP client."""
|
||||||
if self.http_client and not self.http_client.is_closed:
|
if self.http_client and not self.http_client.is_closed:
|
||||||
|
|||||||
@ -265,8 +265,8 @@ class DomainChecker:
|
|||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Custom RDAP error for {domain}: {e}")
|
logger.warning(f"Custom RDAP error for {domain}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _check_rdap(self, domain: str) -> Optional[DomainCheckResult]:
|
async def _check_rdap(self, domain: str) -> Optional[DomainCheckResult]:
|
||||||
"""
|
"""
|
||||||
Check domain using RDAP (Registration Data Access Protocol).
|
Check domain using RDAP (Registration Data Access Protocol).
|
||||||
@ -459,12 +459,12 @@ class DomainChecker:
|
|||||||
'object does not exist',
|
'object does not exist',
|
||||||
]
|
]
|
||||||
if any(phrase in error_str for phrase in not_found_phrases):
|
if any(phrase in error_str for phrase in not_found_phrases):
|
||||||
return DomainCheckResult(
|
return DomainCheckResult(
|
||||||
domain=domain,
|
domain=domain,
|
||||||
status=DomainStatus.AVAILABLE,
|
status=DomainStatus.AVAILABLE,
|
||||||
is_available=True,
|
is_available=True,
|
||||||
check_method="whois",
|
check_method="whois",
|
||||||
)
|
)
|
||||||
# Otherwise it's a real error
|
# Otherwise it's a real error
|
||||||
return DomainCheckResult(
|
return DomainCheckResult(
|
||||||
domain=domain,
|
domain=domain,
|
||||||
@ -589,16 +589,16 @@ class DomainChecker:
|
|||||||
|
|
||||||
# Priority 3: Fall back to WHOIS (skip for TLDs that block it like .ch)
|
# Priority 3: Fall back to WHOIS (skip for TLDs that block it like .ch)
|
||||||
if tld not in self.CUSTOM_RDAP_ENDPOINTS:
|
if tld not in self.CUSTOM_RDAP_ENDPOINTS:
|
||||||
whois_result = await self._check_whois(domain)
|
whois_result = await self._check_whois(domain)
|
||||||
|
|
||||||
# Validate with DNS
|
# Validate with DNS
|
||||||
if whois_result.is_available:
|
if whois_result.is_available:
|
||||||
dns_available = await self._check_dns(domain)
|
dns_available = await self._check_dns(domain)
|
||||||
if not dns_available:
|
if not dns_available:
|
||||||
whois_result.status = DomainStatus.TAKEN
|
whois_result.status = DomainStatus.TAKEN
|
||||||
whois_result.is_available = False
|
whois_result.is_available = False
|
||||||
|
|
||||||
return whois_result
|
return whois_result
|
||||||
|
|
||||||
# Final fallback: DNS-only check (for TLDs where everything else failed)
|
# Final fallback: DNS-only check (for TLDs where everything else failed)
|
||||||
dns_available = await self._check_dns(domain)
|
dns_available = await self._check_dns(domain)
|
||||||
|
|||||||
@ -26,7 +26,7 @@ logger = logging.getLogger(__name__)
|
|||||||
def build_affiliate_url(platform: str, domain: str, original_url: Optional[str] = None) -> str:
|
def build_affiliate_url(platform: str, domain: str, original_url: Optional[str] = None) -> str:
|
||||||
"""
|
"""
|
||||||
Build an affiliate URL for a given platform and domain.
|
Build an affiliate URL for a given platform and domain.
|
||||||
|
|
||||||
If the affiliate program is not configured, returns the plain provider URL.
|
If the affiliate program is not configured, returns the plain provider URL.
|
||||||
If `original_url` is provided, it is preferred (e.g. ExpiredDomains click-through links).
|
If `original_url` is provided, it is preferred (e.g. ExpiredDomains click-through links).
|
||||||
"""
|
"""
|
||||||
@ -73,14 +73,14 @@ def build_affiliate_url(platform: str, domain: str, original_url: Optional[str]
|
|||||||
class DynadotApiScraper:
|
class DynadotApiScraper:
|
||||||
"""
|
"""
|
||||||
Scraper for Dynadot Marketplace using their hidden JSON API.
|
Scraper for Dynadot Marketplace using their hidden JSON API.
|
||||||
|
|
||||||
Endpoint:
|
Endpoint:
|
||||||
- https://www.dynadot.com/dynadot-vue-api/dynadot-service/marketplace-api
|
- https://www.dynadot.com/dynadot-vue-api/dynadot-service/marketplace-api
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BASE_URL = "https://www.dynadot.com"
|
BASE_URL = "https://www.dynadot.com"
|
||||||
MARKETPLACE_API = "/dynadot-vue-api/dynadot-service/marketplace-api"
|
MARKETPLACE_API = "/dynadot-vue-api/dynadot-service/marketplace-api"
|
||||||
|
|
||||||
def _parse_end_time(self, item: Dict[str, Any]) -> Optional[datetime]:
|
def _parse_end_time(self, item: Dict[str, Any]) -> Optional[datetime]:
|
||||||
# Dynadot often provides an epoch timestamp in ms
|
# Dynadot often provides an epoch timestamp in ms
|
||||||
end_time_stamp = item.get("end_time_stamp")
|
end_time_stamp = item.get("end_time_stamp")
|
||||||
@ -121,7 +121,7 @@ class DynadotApiScraper:
|
|||||||
}
|
}
|
||||||
if keyword:
|
if keyword:
|
||||||
params["keyword"] = keyword
|
params["keyword"] = keyword
|
||||||
|
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{self.BASE_URL}{self.MARKETPLACE_API}",
|
f"{self.BASE_URL}{self.MARKETPLACE_API}",
|
||||||
params=params,
|
params=params,
|
||||||
@ -131,13 +131,13 @@ class DynadotApiScraper:
|
|||||||
"Referer": "https://www.dynadot.com/market",
|
"Referer": "https://www.dynadot.com/market",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
return {"items": [], "total": 0, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
|
return {"items": [], "total": 0, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
listings = data.get("data", {}).get("records", []) or data.get("data", {}).get("list", [])
|
listings = data.get("data", {}).get("records", []) or data.get("data", {}).get("list", [])
|
||||||
|
|
||||||
transformed: List[Dict[str, Any]] = []
|
transformed: List[Dict[str, Any]] = []
|
||||||
for item in listings:
|
for item in listings:
|
||||||
domain = item.get("domain") or item.get("name") or item.get("utf8_name") or ""
|
domain = item.get("domain") or item.get("name") or item.get("utf8_name") or ""
|
||||||
@ -170,21 +170,21 @@ class DynadotApiScraper:
|
|||||||
|
|
||||||
transformed.append(
|
transformed.append(
|
||||||
{
|
{
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"tld": tld,
|
"tld": tld,
|
||||||
"platform": "Dynadot",
|
"platform": "Dynadot",
|
||||||
"current_bid": current_bid,
|
"current_bid": current_bid,
|
||||||
"currency": str(item.get("bid_price_currency") or "USD").upper(),
|
"currency": str(item.get("bid_price_currency") or "USD").upper(),
|
||||||
"num_bids": num_bids,
|
"num_bids": num_bids,
|
||||||
"end_time": end_time,
|
"end_time": end_time,
|
||||||
"auction_url": build_affiliate_url("Dynadot", domain),
|
"auction_url": build_affiliate_url("Dynadot", domain),
|
||||||
"buy_now_price": float(item.get("accepted_bid_price")) if item.get("accepted_bid_price") else None,
|
"buy_now_price": float(item.get("accepted_bid_price")) if item.get("accepted_bid_price") else None,
|
||||||
"age_years": int(item.get("age", 0) or 0) or None,
|
"age_years": int(item.get("age", 0) or 0) or None,
|
||||||
"backlinks": int(item.get("links", 0) or 0) or None,
|
"backlinks": int(item.get("links", 0) or 0) or None,
|
||||||
"scrape_source": "dynadot:hidden_api",
|
"scrape_source": "dynadot:hidden_api",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"items": transformed,
|
"items": transformed,
|
||||||
"total": data.get("data", {}).get("total_count", len(transformed)),
|
"total": data.get("data", {}).get("total_count", len(transformed)),
|
||||||
@ -197,10 +197,10 @@ class DynadotApiScraper:
|
|||||||
|
|
||||||
class HiddenApiScraperService:
|
class HiddenApiScraperService:
|
||||||
"""Orchestrates enabled hidden API scrapers."""
|
"""Orchestrates enabled hidden API scrapers."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.dynadot = DynadotApiScraper()
|
self.dynadot = DynadotApiScraper()
|
||||||
|
|
||||||
async def scrape_all(self, limit_per_platform: int = 100) -> Dict[str, Any]:
|
async def scrape_all(self, limit_per_platform: int = 100) -> Dict[str, Any]:
|
||||||
results: Dict[str, Any] = {"total_found": 0, "platforms": {}, "errors": [], "items": []}
|
results: Dict[str, Any] = {"total_found": 0, "platforms": {}, "errors": [], "items": []}
|
||||||
|
|
||||||
@ -212,12 +212,12 @@ class HiddenApiScraperService:
|
|||||||
}
|
}
|
||||||
results["items"].extend(dynadot_data.get("items", []))
|
results["items"].extend(dynadot_data.get("items", []))
|
||||||
results["total_found"] += len(dynadot_data.get("items", []))
|
results["total_found"] += len(dynadot_data.get("items", []))
|
||||||
|
|
||||||
if dynadot_data.get("error"):
|
if dynadot_data.get("error"):
|
||||||
results["errors"].append(f"Dynadot: {dynadot_data['error']}")
|
results["errors"].append(f"Dynadot: {dynadot_data['error']}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results["errors"].append(f"Dynadot: {str(e)}")
|
results["errors"].append(f"Dynadot: {str(e)}")
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -499,7 +499,7 @@ export default function CommandTldDetailPage() {
|
|||||||
)}>
|
)}>
|
||||||
<ShieldCheck className="w-3.5 h-3.5" />
|
<ShieldCheck className="w-3.5 h-3.5" />
|
||||||
{level} Risk
|
{level} Risk
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -513,7 +513,7 @@ export default function CommandTldDetailPage() {
|
|||||||
<TerminalLayout hideHeaderSearch={true}>
|
<TerminalLayout hideHeaderSearch={true}>
|
||||||
<div className="flex items-center justify-center min-h-[50vh]">
|
<div className="flex items-center justify-center min-h-[50vh]">
|
||||||
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
|
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
</TerminalLayout>
|
</TerminalLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -527,8 +527,8 @@ export default function CommandTldDetailPage() {
|
|||||||
<p className="mb-6">The extension .{tld} is not currently tracked.</p>
|
<p className="mb-6">The extension .{tld} is not currently tracked.</p>
|
||||||
<Link href="/terminal/intel" className="text-emerald-400 hover:text-emerald-300 flex items-center gap-2">
|
<Link href="/terminal/intel" className="text-emerald-400 hover:text-emerald-300 flex items-center gap-2">
|
||||||
<ArrowLeft className="w-4 h-4" /> Back to Intelligence
|
<ArrowLeft className="w-4 h-4" /> Back to Intelligence
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</TerminalLayout>
|
</TerminalLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -548,14 +548,14 @@ export default function CommandTldDetailPage() {
|
|||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6 border-b border-white/5 pb-8">
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6 border-b border-white/5 pb-8">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<nav className="flex items-center gap-2 text-xs font-medium text-zinc-500 uppercase tracking-widest">
|
<nav className="flex items-center gap-2 text-xs font-medium text-zinc-500 uppercase tracking-widest">
|
||||||
<Link href="/terminal/intel" className="hover:text-emerald-400 transition-colors">
|
<Link href="/terminal/intel" className="hover:text-emerald-400 transition-colors">
|
||||||
TLD Intelligence
|
TLD Intelligence
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronRight className="w-3 h-3" />
|
<ChevronRight className="w-3 h-3" />
|
||||||
<span className="text-white">.{details.tld}</span>
|
<span className="text-white">.{details.tld}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="h-12 w-1.5 bg-emerald-500 rounded-full shadow-[0_0_15px_rgba(16,185,129,0.5)]" />
|
<div className="h-12 w-1.5 bg-emerald-500 rounded-full shadow-[0_0_15px_rgba(16,185,129,0.5)]" />
|
||||||
@ -593,13 +593,13 @@ export default function CommandTldDetailPage() {
|
|||||||
label="Registration"
|
label="Registration"
|
||||||
value={`$${details.pricing.min.toFixed(2)}`}
|
value={`$${details.pricing.min.toFixed(2)}`}
|
||||||
subValue={`at ${details.cheapest_registrar}`}
|
subValue={`at ${details.cheapest_registrar}`}
|
||||||
icon={DollarSign}
|
icon={DollarSign}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Renewal"
|
label="Renewal"
|
||||||
value={canSeeRenewal && details.min_renewal_price ? `$${details.min_renewal_price.toFixed(2)}` : '—'}
|
value={canSeeRenewal && details.min_renewal_price ? `$${details.min_renewal_price.toFixed(2)}` : '—'}
|
||||||
subValue={canSeeRenewal ? (renewalInfo?.isTrap ? `${renewalInfo.ratio.toFixed(1)}x Markup` : '/ year') : undefined}
|
subValue={canSeeRenewal ? (renewalInfo?.isTrap ? `${renewalInfo.ratio.toFixed(1)}x Markup` : '/ year') : undefined}
|
||||||
icon={RefreshCw}
|
icon={RefreshCw}
|
||||||
locked={!canSeeRenewal}
|
locked={!canSeeRenewal}
|
||||||
lockTooltip="Upgrade to Trader to see renewal prices"
|
lockTooltip="Upgrade to Trader to see renewal prices"
|
||||||
valueClassName={renewalInfo?.isTrap ? "text-amber-400" : undefined}
|
valueClassName={renewalInfo?.isTrap ? "text-amber-400" : undefined}
|
||||||
@ -619,7 +619,7 @@ export default function CommandTldDetailPage() {
|
|||||||
label="3y Trend"
|
label="3y Trend"
|
||||||
value={canSeeFullHistory ? `${details.price_change_3y > 0 ? '+' : ''}${details.price_change_3y.toFixed(0)}%` : '—'}
|
value={canSeeFullHistory ? `${details.price_change_3y > 0 ? '+' : ''}${details.price_change_3y.toFixed(0)}%` : '—'}
|
||||||
subValue={canSeeFullHistory ? "Long-term" : undefined}
|
subValue={canSeeFullHistory ? "Long-term" : undefined}
|
||||||
icon={BarChart3}
|
icon={BarChart3}
|
||||||
locked={!canSeeFullHistory}
|
locked={!canSeeFullHistory}
|
||||||
lockTooltip="Upgrade to Tycoon for 3-year trends"
|
lockTooltip="Upgrade to Tycoon for 3-year trends"
|
||||||
valueClassName={
|
valueClassName={
|
||||||
@ -639,65 +639,65 @@ export default function CommandTldDetailPage() {
|
|||||||
<div className="bg-zinc-900/40 border border-white/5 p-6 backdrop-blur-sm shadow-xl relative overflow-hidden group">
|
<div className="bg-zinc-900/40 border border-white/5 p-6 backdrop-blur-sm shadow-xl relative overflow-hidden group">
|
||||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity pointer-events-none">
|
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity pointer-events-none">
|
||||||
<Activity className="w-32 h-32" />
|
<Activity className="w-32 h-32" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lock overlay for Scout users */}
|
{/* Lock overlay for Scout users */}
|
||||||
{!canAccessDetailPage && <LockedChartOverlay onUpgrade={handleUpgrade} />}
|
{!canAccessDetailPage && <LockedChartOverlay onUpgrade={handleUpgrade} />}
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-8 relative z-10">
|
<div className="flex items-center justify-between mb-8 relative z-10">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
||||||
<BarChart3 className="w-5 h-5 text-emerald-500" />
|
<BarChart3 className="w-5 h-5 text-emerald-500" />
|
||||||
Price History
|
Price History
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-zinc-500">Historical registration price trends</p>
|
<p className="text-xs text-zinc-500">Historical registration price trends</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex bg-zinc-900 rounded-lg p-1 border border-zinc-800">
|
<div className="flex bg-zinc-900 rounded-lg p-1 border border-zinc-800">
|
||||||
{(['1M', '3M', '1Y', 'ALL'] as ChartPeriod[]).map((period) => {
|
{(['1M', '3M', '1Y', 'ALL'] as ChartPeriod[]).map((period) => {
|
||||||
const isAvailable = availablePeriods.includes(period)
|
const isAvailable = availablePeriods.includes(period)
|
||||||
const isActive = chartPeriod === period && isAvailable
|
const isActive = chartPeriod === period && isAvailable
|
||||||
return (
|
return (
|
||||||
<Tooltip key={period} content={!isAvailable ? 'Upgrade to Tycoon for more history' : ''}>
|
<Tooltip key={period} content={!isAvailable ? 'Upgrade to Tycoon for more history' : ''}>
|
||||||
<button
|
<button
|
||||||
onClick={() => isAvailable && setChartPeriod(period)}
|
onClick={() => isAvailable && setChartPeriod(period)}
|
||||||
disabled={!isAvailable}
|
disabled={!isAvailable}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"px-3 py-1 text-[10px] font-bold rounded transition-all",
|
"px-3 py-1 text-[10px] font-bold rounded transition-all",
|
||||||
isActive
|
isActive
|
||||||
? "bg-zinc-800 text-white shadow-sm"
|
? "bg-zinc-800 text-white shadow-sm"
|
||||||
: isAvailable
|
: isAvailable
|
||||||
? "text-zinc-500 hover:text-zinc-300"
|
? "text-zinc-500 hover:text-zinc-300"
|
||||||
: "text-zinc-700 cursor-not-allowed opacity-50"
|
: "text-zinc-700 cursor-not-allowed opacity-50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{period}
|
{period}
|
||||||
{!isAvailable && <Lock className="w-2 h-2 inline ml-1" />}
|
{!isAvailable && <Lock className="w-2 h-2 inline ml-1" />}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={clsx("h-64 relative z-10", !canAccessDetailPage && "blur-sm")}>
|
<div className={clsx("h-64 relative z-10", !canAccessDetailPage && "blur-sm")}>
|
||||||
<PriceChart data={filteredHistory} chartStats={chartStats} />
|
<PriceChart data={filteredHistory} chartStats={chartStats} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4 mt-6 pt-6 border-t border-white/5 relative z-10">
|
<div className="grid grid-cols-3 gap-4 mt-6 pt-6 border-t border-white/5 relative z-10">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-[10px] text-zinc-500 uppercase tracking-widest mb-1">High</div>
|
<div className="text-[10px] text-zinc-500 uppercase tracking-widest mb-1">High</div>
|
||||||
<div className="text-lg font-mono font-bold text-white">${chartStats.high.toFixed(2)}</div>
|
<div className="text-lg font-mono font-bold text-white">${chartStats.high.toFixed(2)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center border-l border-r border-white/5">
|
<div className="text-center border-l border-r border-white/5">
|
||||||
<div className="text-[10px] text-zinc-500 uppercase tracking-widest mb-1">Average</div>
|
<div className="text-[10px] text-zinc-500 uppercase tracking-widest mb-1">Average</div>
|
||||||
<div className="text-lg font-mono font-bold text-white">${chartStats.avg.toFixed(2)}</div>
|
<div className="text-lg font-mono font-bold text-white">${chartStats.avg.toFixed(2)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-[10px] text-zinc-500 uppercase tracking-widest mb-1">Low</div>
|
<div className="text-[10px] text-zinc-500 uppercase tracking-widest mb-1">Low</div>
|
||||||
<div className="text-lg font-mono font-bold text-emerald-400">${chartStats.low.toFixed(2)}</div>
|
<div className="text-lg font-mono font-bold text-emerald-400">${chartStats.low.toFixed(2)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Check Bar */}
|
{/* Quick Check Bar */}
|
||||||
<div className="bg-zinc-900/40 border border-white/5 p-6 backdrop-blur-sm relative overflow-hidden group hover:border-white/10 transition-colors">
|
<div className="bg-zinc-900/40 border border-white/5 p-6 backdrop-blur-sm relative overflow-hidden group hover:border-white/10 transition-colors">
|
||||||
@ -709,79 +709,79 @@ export default function CommandTldDetailPage() {
|
|||||||
Check Availability
|
Check Availability
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-zinc-400">Instantly check if your desired .{details.tld} domain is available.</p>
|
<p className="text-sm text-zinc-400">Instantly check if your desired .{details.tld} domain is available.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 w-full max-w-xl flex gap-3">
|
<div className="flex-1 w-full max-w-xl flex gap-3">
|
||||||
<div className="relative flex-1 group/input">
|
<div className="relative flex-1 group/input">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={domainSearch}
|
value={domainSearch}
|
||||||
onChange={(e) => setDomainSearch(e.target.value)}
|
onChange={(e) => setDomainSearch(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleDomainCheck()}
|
onKeyDown={(e) => e.key === 'Enter' && handleDomainCheck()}
|
||||||
placeholder={`example.${details.tld}`}
|
placeholder={`example.${details.tld}`}
|
||||||
className="w-full h-12 bg-black/50 border border-white/10 rounded-lg pl-4 pr-4 text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all font-mono"
|
className="w-full h-12 bg-black/50 border border-white/10 rounded-lg pl-4 pr-4 text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleDomainCheck}
|
onClick={handleDomainCheck}
|
||||||
disabled={checkingDomain || !domainSearch.trim()}
|
disabled={checkingDomain || !domainSearch.trim()}
|
||||||
className="h-12 px-8 bg-emerald-500 text-white font-bold rounded-lg hover:bg-emerald-400 transition-all disabled:opacity-50 shadow-lg shadow-emerald-500/20"
|
className="h-12 px-8 bg-emerald-500 text-white font-bold rounded-lg hover:bg-emerald-400 transition-all disabled:opacity-50 shadow-lg shadow-emerald-500/20"
|
||||||
>
|
>
|
||||||
{checkingDomain ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Check'}
|
{checkingDomain ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Check'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Check Result */}
|
{/* Check Result */}
|
||||||
{domainResult && (
|
{domainResult && (
|
||||||
<div className="mt-6 pt-6 border-t border-white/5 animate-in fade-in slide-in-from-top-2">
|
<div className="mt-6 pt-6 border-t border-white/5 animate-in fade-in slide-in-from-top-2">
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
"p-4 rounded-lg border flex items-center justify-between",
|
"p-4 rounded-lg border flex items-center justify-between",
|
||||||
domainResult.is_available
|
domainResult.is_available
|
||||||
? "bg-emerald-500/10 border-emerald-500/20"
|
? "bg-emerald-500/10 border-emerald-500/20"
|
||||||
: "bg-rose-500/10 border-rose-500/20"
|
: "bg-rose-500/10 border-rose-500/20"
|
||||||
)}>
|
)}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{domainResult.is_available ? (
|
{domainResult.is_available ? (
|
||||||
<div className="p-2 rounded-full bg-emerald-500/20 text-emerald-400"><Check className="w-5 h-5" /></div>
|
<div className="p-2 rounded-full bg-emerald-500/20 text-emerald-400"><Check className="w-5 h-5" /></div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2 rounded-full bg-rose-500/20 text-rose-400"><X className="w-5 h-5" /></div>
|
<div className="p-2 rounded-full bg-rose-500/20 text-rose-400"><X className="w-5 h-5" /></div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="font-mono font-bold text-white text-lg">{domainResult.domain}</div>
|
<div className="font-mono font-bold text-white text-lg">{domainResult.domain}</div>
|
||||||
<div className={clsx("text-xs font-medium uppercase tracking-wider", domainResult.is_available ? "text-emerald-400" : "text-rose-400")}>
|
<div className={clsx("text-xs font-medium uppercase tracking-wider", domainResult.is_available ? "text-emerald-400" : "text-rose-400")}>
|
||||||
{domainResult.is_available ? 'Available for registration' : 'Already Registered'}
|
{domainResult.is_available ? 'Available for registration' : 'Already Registered'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{domainResult.is_available && (
|
|
||||||
<a
|
|
||||||
href={getRegistrarUrl(details.cheapest_registrar, domainResult.domain)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="px-4 py-2 bg-emerald-500 text-white text-sm font-bold rounded hover:bg-emerald-400 transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
Buy at {details.cheapest_registrar} <ExternalLink className="w-4 h-4" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{domainResult.is_available && (
|
||||||
|
<a
|
||||||
|
href={getRegistrarUrl(details.cheapest_registrar, domainResult.domain)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="px-4 py-2 bg-emerald-500 text-white text-sm font-bold rounded hover:bg-emerald-400 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
Buy at {details.cheapest_registrar} <ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* TLD Info Cards */}
|
{/* TLD Info Cards */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="bg-zinc-900/40 border border-white/5 p-4 hover:border-white/10 transition-colors">
|
<div className="bg-zinc-900/40 border border-white/5 p-4 hover:border-white/10 transition-colors">
|
||||||
<div className="flex items-center gap-2 text-zinc-500 mb-2">
|
<div className="flex items-center gap-2 text-zinc-500 mb-2">
|
||||||
<Globe className="w-4 h-4" />
|
<Globe className="w-4 h-4" />
|
||||||
<span className="text-xs uppercase tracking-widest">Type</span>
|
<span className="text-xs uppercase tracking-widest">Type</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-medium text-white capitalize">{details.type}</div>
|
<div className="text-lg font-medium text-white capitalize">{details.type}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-zinc-900/40 border border-white/5 p-4 hover:border-white/10 transition-colors">
|
<div className="bg-zinc-900/40 border border-white/5 p-4 hover:border-white/10 transition-colors">
|
||||||
<div className="flex items-center gap-2 text-zinc-500 mb-2">
|
<div className="flex items-center gap-2 text-zinc-500 mb-2">
|
||||||
<Building className="w-4 h-4" />
|
<Building className="w-4 h-4" />
|
||||||
<span className="text-xs uppercase tracking-widest">Registry</span>
|
<span className="text-xs uppercase tracking-widest">Registry</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-medium text-white truncate" title={details.registry}>{details.registry}</div>
|
<div className="text-lg font-medium text-white truncate" title={details.registry}>{details.registry}</div>
|
||||||
@ -795,7 +795,7 @@ export default function CommandTldDetailPage() {
|
|||||||
<div className="p-4 border-b border-white/5 bg-white/[0.02]">
|
<div className="p-4 border-b border-white/5 bg-white/[0.02]">
|
||||||
<h3 className="text-lg font-bold text-white">Registrar Prices</h3>
|
<h3 className="text-lg font-bold text-white">Registrar Prices</h3>
|
||||||
<p className="text-xs text-zinc-500">Live comparison sorted by price</p>
|
<p className="text-xs text-zinc-500">Live comparison sorted by price</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left">
|
<table className="w-full text-left">
|
||||||
@ -830,13 +830,13 @@ export default function CommandTldDetailPage() {
|
|||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<div className={clsx("font-mono text-sm", isBest ? "text-emerald-400 font-bold" : "text-white")}>
|
<div className={clsx("font-mono text-sm", isBest ? "text-emerald-400 font-bold" : "text-white")}>
|
||||||
${registrar.registration_price.toFixed(2)}
|
${registrar.registration_price.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
{canSeeRenewal ? (
|
{canSeeRenewal ? (
|
||||||
<div className={clsx("font-mono text-sm", hasRenewalTrap ? "text-amber-400" : "text-zinc-500")}>
|
<div className={clsx("font-mono text-sm", hasRenewalTrap ? "text-amber-400" : "text-zinc-500")}>
|
||||||
${registrar.renewal_price.toFixed(2)}
|
${registrar.renewal_price.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-zinc-700 font-mono text-sm">—</div>
|
<div className="text-zinc-700 font-mono text-sm">—</div>
|
||||||
)}
|
)}
|
||||||
@ -856,7 +856,7 @@ export default function CommandTldDetailPage() {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upgrade CTA for Scout users */}
|
{/* Upgrade CTA for Scout users */}
|
||||||
{userTier === 'scout' && (
|
{userTier === 'scout' && (
|
||||||
@ -865,9 +865,9 @@ export default function CommandTldDetailPage() {
|
|||||||
<Sparkles className="w-4 h-4" />
|
<Sparkles className="w-4 h-4" />
|
||||||
Upgrade to see renewal prices
|
Upgrade to see renewal prices
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -18,8 +18,7 @@ import {
|
|||||||
Crosshair,
|
Crosshair,
|
||||||
Zap,
|
Zap,
|
||||||
Globe,
|
Globe,
|
||||||
Target,
|
Target
|
||||||
TrendingUp
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
@ -47,6 +46,31 @@ interface SearchResult {
|
|||||||
auctionData?: HotAuction
|
auctionData?: HotAuction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LIVE TICKER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function LiveTicker({ items }: { items: { label: string; value: string; highlight?: boolean }[] }) {
|
||||||
|
return (
|
||||||
|
<div className="relative border-y border-white/[0.08] bg-black/40 overflow-hidden">
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-16 bg-gradient-to-r from-[#020202] to-transparent z-10" />
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 w-16 bg-gradient-to-l from-[#020202] to-transparent z-10" />
|
||||||
|
|
||||||
|
<div className="flex animate-[ticker_30s_linear_infinite] py-2.5" style={{ width: 'max-content' }}>
|
||||||
|
{[...items, ...items, ...items].map((item, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 px-6 border-r border-white/[0.08]">
|
||||||
|
<span className="text-[9px] font-mono uppercase tracking-widest text-white/30">{item.label}</span>
|
||||||
|
<span className={clsx(
|
||||||
|
"text-xs font-mono font-medium",
|
||||||
|
item.highlight ? "text-accent" : "text-white/70"
|
||||||
|
)}>{item.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MAIN PAGE
|
// MAIN PAGE
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -69,7 +93,7 @@ export default function RadarPage() {
|
|||||||
const loadDashboardData = useCallback(async () => {
|
const loadDashboardData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const summary = await api.getDashboardSummary()
|
const summary = await api.getDashboardSummary()
|
||||||
setHotAuctions((summary.market.ending_soon_preview || []).slice(0, 6))
|
setHotAuctions((summary.market.ending_soon_preview || []).slice(0, 5))
|
||||||
setMarketStats({
|
setMarketStats({
|
||||||
totalAuctions: summary.market.total_auctions || 0,
|
totalAuctions: summary.market.total_auctions || 0,
|
||||||
endingSoon: summary.market.ending_soon || 0,
|
endingSoon: summary.market.ending_soon || 0,
|
||||||
@ -138,210 +162,267 @@ export default function RadarPage() {
|
|||||||
// Computed
|
// Computed
|
||||||
const availableDomains = domains?.filter(d => d.is_available) || []
|
const availableDomains = domains?.filter(d => d.is_available) || []
|
||||||
const totalDomains = domains?.length || 0
|
const totalDomains = domains?.length || 0
|
||||||
|
|
||||||
|
const tickerItems = [
|
||||||
|
{ label: 'Status', value: 'ONLINE', highlight: true },
|
||||||
|
{ label: 'Tracking', value: totalDomains.toString() },
|
||||||
|
{ label: 'Available', value: availableDomains.length.toString(), highlight: availableDomains.length > 0 },
|
||||||
|
{ label: 'Auctions', value: marketStats.totalAuctions.toString() },
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandCenterLayout minimal>
|
<CommandCenterLayout minimal>
|
||||||
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
{/* HEADER ROW */}
|
{/* HERO - Compact for Laptops */}
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
<div className="flex items-center justify-between pb-6 border-b border-white/[0.06]">
|
<section className="pt-6 lg:pt-8 pb-10">
|
||||||
<div className="flex items-center gap-3">
|
<div className="grid lg:grid-cols-2 gap-10 lg:gap-16 items-center">
|
||||||
<div className="w-2 h-2 bg-accent rounded-full animate-pulse" />
|
|
||||||
<span className="text-xs font-mono uppercase tracking-wider text-white/50">Intelligence Hub</span>
|
{/* Left: Typography */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="inline-flex items-center gap-3">
|
||||||
|
<div className="w-1.5 h-1.5 bg-accent rounded-full animate-pulse shadow-[0_0_10px_rgba(16,185,129,0.8)]" />
|
||||||
|
<span className="text-[10px] font-mono uppercase tracking-[0.2em] text-accent">
|
||||||
|
Intelligence Hub
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="font-display text-[2.5rem] sm:text-[3rem] lg:text-[3.5rem] leading-[0.95] tracking-[-0.03em]">
|
||||||
|
<span className="block text-white">Global Recon.</span>
|
||||||
|
<span className="block text-white/30">Zero Blind Spots.</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-sm lg:text-base text-white/50 max-w-md font-light leading-relaxed">
|
||||||
|
Real-time monitoring across {marketStats.totalAuctions.toLocaleString()}+ auctions.
|
||||||
|
<span className="text-white/70"> Your targets. Your intel.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Stats Row */}
|
||||||
|
<div className="flex gap-8 lg:gap-10 pt-6 border-t border-white/[0.08]">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl lg:text-3xl font-display text-white">{totalDomains}</div>
|
||||||
|
<div className="text-[9px] uppercase tracking-widest text-white/30 font-mono mt-1">Tracking</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl lg:text-3xl font-display text-accent">{availableDomains.length}</div>
|
||||||
|
<div className="text-[9px] uppercase tracking-widest text-white/30 font-mono mt-1">Available</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl lg:text-3xl font-display text-white">{marketStats.endingSoon}</div>
|
||||||
|
<div className="text-[9px] uppercase tracking-widest text-white/30 font-mono mt-1">Ending Soon</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Search Terminal */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute -inset-2 bg-gradient-to-tr from-accent/10 via-transparent to-accent/5 blur-2xl opacity-40" />
|
||||||
|
|
||||||
|
<div className="relative bg-[#0A0A0A] border border-white/15 p-1.5">
|
||||||
|
{/* Tech Corners */}
|
||||||
|
<div className="absolute -top-px -left-px w-4 h-4 border-t border-l border-accent/60" />
|
||||||
|
<div className="absolute -top-px -right-px w-4 h-4 border-t border-r border-accent/60" />
|
||||||
|
<div className="absolute -bottom-px -left-px w-4 h-4 border-b border-l border-accent/60" />
|
||||||
|
<div className="absolute -bottom-px -right-px w-4 h-4 border-b border-r border-accent/60" />
|
||||||
|
|
||||||
|
<div className="bg-[#050505] p-6 lg:p-8 relative">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<span className="text-[9px] font-mono uppercase tracking-[0.15em] text-accent flex items-center gap-2">
|
||||||
|
<Crosshair className="w-3 h-3" />
|
||||||
|
Target Acquisition
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<div className="w-1 h-1 bg-accent/50" />
|
||||||
|
<div className="w-1 h-1 bg-accent/30" />
|
||||||
|
<div className="w-1 h-1 bg-accent/10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className={clsx(
|
||||||
|
"relative border transition-all duration-300",
|
||||||
|
searchFocused ? "border-accent/50 shadow-[0_0_20px_-5px_rgba(16,185,129,0.2)]" : "border-white/10"
|
||||||
|
)}>
|
||||||
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-accent font-mono text-sm">{'>'}</div>
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onFocus={() => setSearchFocused(true)}
|
||||||
|
onBlur={() => setSearchFocused(false)}
|
||||||
|
placeholder="ENTER_TARGET..."
|
||||||
|
className="w-full bg-black/50 px-8 py-4 text-lg lg:text-xl text-white placeholder:text-white/15 font-mono uppercase tracking-tight outline-none"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSearchQuery(''); setSearchResult(null) }}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-white/20 hover:text-white"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{searchResult && (
|
||||||
|
<div className="mt-5 border-t border-white/[0.08] pt-5">
|
||||||
|
{searchResult.loading ? (
|
||||||
|
<div className="flex items-center gap-3 text-accent">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
<span className="text-xs font-mono uppercase tracking-widest">Scanning...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{searchResult.is_available ? (
|
||||||
|
<div className="w-2 h-2 bg-accent shadow-[0_0_8px_rgba(16,185,129,0.8)]" />
|
||||||
|
) : (
|
||||||
|
<div className="w-2 h-2 bg-white/20" />
|
||||||
|
)}
|
||||||
|
<span className="text-base font-mono text-white">{searchResult.domain}</span>
|
||||||
|
</div>
|
||||||
|
<span className={clsx(
|
||||||
|
"text-[9px] font-mono uppercase tracking-widest px-2 py-0.5 border",
|
||||||
|
searchResult.is_available
|
||||||
|
? "text-accent border-accent/30 bg-accent/5"
|
||||||
|
: "text-white/30 border-white/10"
|
||||||
|
)}>
|
||||||
|
{searchResult.is_available ? 'AVAILABLE' : 'TAKEN'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchResult.is_available && (
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleAddToWatchlist}
|
||||||
|
disabled={addingToWatchlist}
|
||||||
|
className="flex-1 py-2.5 border border-white/15 text-white/70 font-mono text-[10px] uppercase tracking-widest hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
{addingToWatchlist ? 'TRACKING...' : '+ TRACK'}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={`https://www.namecheap.com/domains/registration/results/?domain=${searchResult.domain}`}
|
||||||
|
target="_blank"
|
||||||
|
className="flex-1 py-2.5 bg-accent text-black font-mono text-[10px] font-bold uppercase tracking-widest hover:bg-white transition-colors flex items-center justify-center gap-1.5"
|
||||||
|
>
|
||||||
|
GET <ArrowRight className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-6 pt-4 border-t border-white/[0.05] flex justify-between items-center text-[9px] text-white/15 font-mono">
|
||||||
|
<span>SECURE</span>
|
||||||
|
<span>V2.1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-6 text-xs font-mono">
|
</section>
|
||||||
<span className="text-white/30">Tracking: <span className="text-white">{totalDomains}</span></span>
|
|
||||||
<span className="text-white/30">Available: <span className="text-accent">{availableDomains.length}</span></span>
|
{/* Ticker */}
|
||||||
<span className="text-white/30">Auctions: <span className="text-white">{marketStats.totalAuctions.toLocaleString()}</span></span>
|
<LiveTicker items={tickerItems} />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
{/* MAIN CONTENT */}
|
{/* CONTENT GRID */}
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
<div className="grid lg:grid-cols-[1fr_380px] gap-8 pt-8">
|
<section className="py-8 lg:py-10">
|
||||||
|
<div className="grid lg:grid-cols-3 gap-px bg-white/[0.08] border border-white/[0.08]">
|
||||||
{/* Left: Search + Actions */}
|
|
||||||
<div className="space-y-8">
|
|
||||||
|
|
||||||
{/* Hero Title */}
|
{/* Hot Auctions - 2 cols */}
|
||||||
<div>
|
<div className="lg:col-span-2 bg-[#020202] p-6 lg:p-8">
|
||||||
<h1 className="text-3xl lg:text-4xl font-bold text-white tracking-tight">
|
<div className="flex items-center justify-between mb-6">
|
||||||
Domain Radar
|
<div className="flex items-center gap-2">
|
||||||
</h1>
|
<Gavel className="w-4 h-4 text-accent" />
|
||||||
<p className="text-base text-white/40 mt-2">
|
<span className="text-xs font-bold text-white uppercase tracking-wider">Live Auctions</span>
|
||||||
Search domains, track availability, discover opportunities.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Box */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className={clsx(
|
|
||||||
"bg-[#0A0A0A] border transition-all duration-200",
|
|
||||||
searchFocused ? "border-accent/50" : "border-white/10"
|
|
||||||
)}>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="pl-5 text-accent font-mono text-lg">{'>'}</div>
|
|
||||||
<input
|
|
||||||
ref={searchInputRef}
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
onFocus={() => setSearchFocused(true)}
|
|
||||||
onBlur={() => setSearchFocused(false)}
|
|
||||||
placeholder="Search domain..."
|
|
||||||
className="w-full bg-transparent px-4 py-5 text-lg text-white placeholder:text-white/20 outline-none"
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
onClick={() => { setSearchQuery(''); setSearchResult(null) }}
|
|
||||||
className="pr-5 text-white/30 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<XCircle className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<Link href="/terminal/market" className="text-[9px] font-mono uppercase text-white/30 hover:text-white transition-colors">
|
||||||
{/* Search Results */}
|
View All →
|
||||||
{searchResult && (
|
|
||||||
<div className="border-t border-white/[0.06] p-5">
|
|
||||||
{searchResult.loading ? (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin text-accent" />
|
|
||||||
<span className="text-sm text-white/50">Checking availability...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={clsx(
|
|
||||||
"w-2.5 h-2.5",
|
|
||||||
searchResult.is_available ? "bg-accent" : "bg-white/20"
|
|
||||||
)} />
|
|
||||||
<span className="text-lg font-medium text-white">{searchResult.domain}</span>
|
|
||||||
</div>
|
|
||||||
<span className={clsx(
|
|
||||||
"text-xs font-mono uppercase tracking-wider px-3 py-1 border",
|
|
||||||
searchResult.is_available
|
|
||||||
? "text-accent border-accent/30 bg-accent/5"
|
|
||||||
: "text-white/40 border-white/10"
|
|
||||||
)}>
|
|
||||||
{searchResult.is_available ? 'Available' : 'Taken'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{searchResult.is_available && (
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<button
|
|
||||||
onClick={handleAddToWatchlist}
|
|
||||||
disabled={addingToWatchlist}
|
|
||||||
className="flex-1 py-3 border border-white/15 text-white/70 text-sm font-medium hover:bg-white/5 transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{addingToWatchlist ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
|
|
||||||
Add to Watchlist
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${searchResult.domain}`}
|
|
||||||
target="_blank"
|
|
||||||
className="flex-1 py-3 bg-accent text-black text-sm font-bold hover:bg-white transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
Register <ArrowRight className="w-4 h-4" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{searchResult.registrar && (
|
|
||||||
<div className="text-xs text-white/30 pt-2">
|
|
||||||
Registrar: {searchResult.registrar}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Links */}
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
{[
|
|
||||||
{ label: 'Watchlist', href: '/terminal/watchlist', icon: Eye, count: totalDomains },
|
|
||||||
{ label: 'Market', href: '/terminal/market', icon: Gavel, count: marketStats.endingSoon },
|
|
||||||
{ label: 'Intel', href: '/terminal/intel', icon: Globe },
|
|
||||||
].map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className="group flex flex-col p-4 bg-white/[0.02] border border-white/[0.06] hover:border-accent/30 hover:bg-accent/5 transition-all"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<item.icon className="w-5 h-5 text-white/30 group-hover:text-accent transition-colors" />
|
|
||||||
{item.count !== undefined && (
|
|
||||||
<span className="text-xs font-mono text-white/40">{item.count}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium text-white/70 group-hover:text-white transition-colors">{item.label}</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: Live Auctions */}
|
|
||||||
<div className="bg-[#0A0A0A] border border-white/[0.06]">
|
|
||||||
<div className="flex items-center justify-between p-5 border-b border-white/[0.06]">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Gavel className="w-4 h-4 text-accent" />
|
|
||||||
<span className="text-sm font-semibold text-white">Live Auctions</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Link href="/terminal/market" className="text-xs text-white/30 hover:text-accent transition-colors">
|
|
||||||
View All →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="divide-y divide-white/[0.04]">
|
|
||||||
{loadingData ? (
|
{loadingData ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Loader2 className="w-5 h-5 text-accent animate-spin" />
|
<Loader2 className="w-5 h-5 text-accent animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : hotAuctions.length > 0 ? (
|
) : hotAuctions.length > 0 ? (
|
||||||
hotAuctions.map((auction, i) => (
|
<div className="space-y-px">
|
||||||
<a
|
{hotAuctions.map((auction, i) => (
|
||||||
key={i}
|
<a
|
||||||
href={auction.affiliate_url || '#'}
|
key={i}
|
||||||
target="_blank"
|
href={auction.affiliate_url || '#'}
|
||||||
className="flex items-center justify-between p-4 hover:bg-white/[0.02] transition-colors group"
|
target="_blank"
|
||||||
>
|
className="flex items-center justify-between p-3 bg-white/[0.02] hover:bg-white/[0.04] transition-colors group"
|
||||||
<div className="min-w-0">
|
>
|
||||||
<div className="text-sm font-medium text-white truncate group-hover:text-accent transition-colors">
|
<div className="flex items-center gap-3">
|
||||||
{auction.domain}
|
<span className="text-[9px] font-mono text-white/20 w-5">{auction.platform.substring(0, 2).toUpperCase()}</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-sm text-white group-hover:text-accent transition-colors">{auction.domain}</div>
|
||||||
|
<div className="text-[9px] text-white/25 font-mono uppercase">{auction.time_remaining}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-white/30 mt-0.5 flex items-center gap-2">
|
<div className="font-mono text-sm text-accent">${auction.current_bid.toLocaleString()}</div>
|
||||||
<span className="uppercase">{auction.platform.substring(0, 3)}</span>
|
</a>
|
||||||
<span>·</span>
|
))}
|
||||||
<span>{auction.time_remaining}</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-mono text-accent font-medium">
|
|
||||||
${auction.current_bid.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<div className="py-12 text-center text-sm text-white/20">No active auctions</div>
|
<div className="text-center py-8 text-white/15 font-mono text-xs uppercase">No active auctions</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Footer */}
|
{/* Quick Links */}
|
||||||
<div className="p-4 border-t border-white/[0.06] bg-black/30">
|
<div className="bg-[#020202] p-6 lg:p-8">
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center gap-2 mb-6">
|
||||||
<span className="text-white/30">Ending Soon</span>
|
<Zap className="w-4 h-4 text-white/50" />
|
||||||
<span className="text-accent font-mono">{marketStats.endingSoon}</span>
|
<span className="text-xs font-bold text-white uppercase tracking-wider">Quick Access</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ label: 'Watchlist', href: '/terminal/watchlist', icon: Eye },
|
||||||
|
{ label: 'Market', href: '/terminal/market', icon: Gavel },
|
||||||
|
{ label: 'Intel', href: '/terminal/intel', icon: Globe },
|
||||||
|
].map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="flex items-center gap-3 p-3 border border-white/[0.05] hover:border-accent/30 hover:bg-accent/5 transition-all group"
|
||||||
|
>
|
||||||
|
<item.icon className="w-4 h-4 text-white/30 group-hover:text-accent transition-colors" />
|
||||||
|
<span className="text-sm text-white/70 group-hover:text-white transition-colors flex-1">{item.label}</span>
|
||||||
|
<ArrowRight className="w-3 h-3 text-white/15 group-hover:text-accent group-hover:translate-x-0.5 transition-all" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="mt-6 pt-4 border-t border-white/[0.05]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-1 h-1 bg-accent rounded-full animate-pulse" />
|
||||||
|
<span className="text-[9px] font-mono text-white/30 uppercase">System Online</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
|
<style jsx global>{`
|
||||||
|
@keyframes ticker {
|
||||||
|
0% { transform: translateX(0); }
|
||||||
|
100% { transform: translateX(-33.33%); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
</CommandCenterLayout>
|
</CommandCenterLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -61,11 +61,11 @@ function getTimeAgo(date: string | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const healthConfig: Record<HealthStatus, { label: string; color: string; bg: string }> = {
|
const healthConfig: Record<HealthStatus, { label: string; color: string; bg: string }> = {
|
||||||
healthy: { label: 'Healthy', color: 'text-accent', bg: 'bg-accent/10 border-accent/20' },
|
healthy: { label: 'ONLINE', color: 'text-accent', bg: 'bg-accent/10 border-accent/20' },
|
||||||
weakening: { label: 'Weak', color: 'text-amber-400', bg: 'bg-amber-500/10 border-amber-500/20' },
|
weakening: { label: 'WEAK', color: 'text-amber-400', bg: 'bg-amber-500/10 border-amber-500/20' },
|
||||||
parked: { label: 'Parked', color: 'text-blue-400', bg: 'bg-blue-500/10 border-blue-500/20' },
|
parked: { label: 'PARKED', color: 'text-blue-400', bg: 'bg-blue-500/10 border-blue-500/20' },
|
||||||
critical: { label: 'Critical', color: 'text-rose-400', bg: 'bg-rose-500/10 border-rose-500/20' },
|
critical: { label: 'CRIT', color: 'text-rose-400', bg: 'bg-rose-500/10 border-rose-500/20' },
|
||||||
unknown: { label: 'Unknown', color: 'text-white/40', bg: 'bg-white/5 border-white/10' },
|
unknown: { label: '???', color: 'text-white/40', bg: 'bg-white/5 border-white/10' },
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -121,10 +121,10 @@ export default function WatchlistPage() {
|
|||||||
setAdding(true)
|
setAdding(true)
|
||||||
try {
|
try {
|
||||||
await addDomain(newDomain.trim())
|
await addDomain(newDomain.trim())
|
||||||
showToast(`Added: ${newDomain.trim()}`, 'success')
|
showToast(`Target locked: ${newDomain.trim()}`, 'success')
|
||||||
setNewDomain('')
|
setNewDomain('')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast(err.message || 'Failed to add domain', 'error')
|
showToast(err.message || 'Failed', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setAdding(false)
|
setAdding(false)
|
||||||
}
|
}
|
||||||
@ -134,18 +134,18 @@ export default function WatchlistPage() {
|
|||||||
setRefreshingId(id)
|
setRefreshingId(id)
|
||||||
try {
|
try {
|
||||||
await refreshDomain(id)
|
await refreshDomain(id)
|
||||||
showToast('Domain refreshed', 'success')
|
showToast('Intel updated', 'success')
|
||||||
} catch { showToast('Refresh failed', 'error') }
|
} catch { showToast('Update failed', 'error') }
|
||||||
finally { setRefreshingId(null) }
|
finally { setRefreshingId(null) }
|
||||||
}, [refreshDomain, showToast])
|
}, [refreshDomain, showToast])
|
||||||
|
|
||||||
const handleDelete = useCallback(async (id: number, name: string) => {
|
const handleDelete = useCallback(async (id: number, name: string) => {
|
||||||
if (!confirm(`Remove ${name} from watchlist?`)) return
|
if (!confirm(`Drop target: ${name}?`)) return
|
||||||
setDeletingId(id)
|
setDeletingId(id)
|
||||||
try {
|
try {
|
||||||
await deleteDomain(id)
|
await deleteDomain(id)
|
||||||
showToast('Domain removed', 'success')
|
showToast('Target dropped', 'success')
|
||||||
} catch { showToast('Failed to remove', 'error') }
|
} catch { showToast('Failed', 'error') }
|
||||||
finally { setDeletingId(null) }
|
finally { setDeletingId(null) }
|
||||||
}, [deleteDomain, showToast])
|
}, [deleteDomain, showToast])
|
||||||
|
|
||||||
@ -154,8 +154,8 @@ export default function WatchlistPage() {
|
|||||||
try {
|
try {
|
||||||
await api.updateDomainNotify(id, !current)
|
await api.updateDomainNotify(id, !current)
|
||||||
updateDomain(id, { notify_on_available: !current })
|
updateDomain(id, { notify_on_available: !current })
|
||||||
showToast(!current ? 'Notifications enabled' : 'Notifications disabled', 'success')
|
showToast(!current ? 'Alerts armed' : 'Alerts disarmed', 'success')
|
||||||
} catch { showToast('Failed to update', 'error') }
|
} catch { showToast('Failed', 'error') }
|
||||||
finally { setTogglingNotifyId(null) }
|
finally { setTogglingNotifyId(null) }
|
||||||
}, [updateDomain, showToast])
|
}, [updateDomain, showToast])
|
||||||
|
|
||||||
@ -190,59 +190,68 @@ export default function WatchlistPage() {
|
|||||||
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
{/* HEADER */}
|
{/* HEADER - Compact */}
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 pb-6 border-b border-white/[0.06]">
|
<section className="pt-6 lg:pt-8 pb-6">
|
||||||
<div>
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6">
|
||||||
<h1 className="text-2xl lg:text-3xl font-bold text-white tracking-tight">
|
|
||||||
Watchlist
|
{/* Left */}
|
||||||
</h1>
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-white/40 mt-1">
|
<div className="inline-flex items-center gap-2">
|
||||||
Monitor {stats.total} domain{stats.total !== 1 ? 's' : ''} for availability changes
|
<Target className="w-4 h-4 text-accent" />
|
||||||
</p>
|
<span className="text-[9px] font-mono uppercase tracking-[0.2em] text-accent">Surveillance</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-6 text-sm">
|
<h1 className="font-display text-[2.5rem] lg:text-[3rem] leading-[0.95] tracking-[-0.03em]">
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-white">Watchlist</span>
|
||||||
<div className="w-2 h-2 bg-accent rounded-full" />
|
<span className="text-white/30 ml-3">{stats.total}</span>
|
||||||
<span className="text-white/50">Available:</span>
|
</h1>
|
||||||
<span className="font-medium text-accent">{stats.available}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 bg-amber-400 rounded-full" />
|
{/* Right: Stats */}
|
||||||
<span className="text-white/50">Expiring:</span>
|
<div className="flex gap-6 lg:gap-8">
|
||||||
<span className="font-medium text-amber-400">{stats.expiring}</span>
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-display text-accent">{stats.available}</div>
|
||||||
|
<div className="text-[8px] uppercase tracking-widest text-white/30 font-mono">Available</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-display text-amber-400">{stats.expiring}</div>
|
||||||
|
<div className="text-[8px] uppercase tracking-widest text-white/30 font-mono">Expiring</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
{/* ADD + FILTER */}
|
{/* ADD DOMAIN */}
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center gap-4 py-6">
|
<section className="pb-6">
|
||||||
{/* Add Form */}
|
<form onSubmit={handleAdd} className="relative max-w-xl">
|
||||||
<form onSubmit={handleAdd} className="flex-1 max-w-lg">
|
<div className="flex items-center bg-[#050505] border border-white/10 focus-within:border-accent/40 transition-colors">
|
||||||
<div className="flex items-center bg-[#0A0A0A] border border-white/10 focus-within:border-accent/40 transition-colors">
|
<div className="pl-4 text-accent font-mono">{'>'}</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newDomain}
|
value={newDomain}
|
||||||
onChange={(e) => setNewDomain(e.target.value)}
|
onChange={(e) => setNewDomain(e.target.value)}
|
||||||
placeholder="Add domain to watch..."
|
placeholder="ADD_TARGET..."
|
||||||
className="flex-1 bg-transparent px-4 py-3 text-sm text-white placeholder:text-white/25 outline-none"
|
className="flex-1 bg-transparent px-3 py-3 text-sm text-white placeholder:text-white/20 font-mono uppercase outline-none"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={adding || !newDomain.trim()}
|
disabled={adding || !newDomain.trim()}
|
||||||
className="px-5 py-3 bg-accent text-black text-sm font-semibold hover:bg-white transition-colors disabled:opacity-30 flex items-center gap-2"
|
className="h-full px-5 py-3 bg-accent text-black font-mono text-[10px] font-bold uppercase tracking-wider hover:bg-white transition-colors disabled:opacity-30"
|
||||||
>
|
>
|
||||||
{adding ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
{adding ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
|
||||||
Add
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</section>
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex items-center gap-1 bg-white/[0.02] p-1">
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
|
{/* FILTERS */}
|
||||||
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
|
<section className="pb-6 border-b border-white/[0.08]">
|
||||||
|
<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 },
|
||||||
@ -252,258 +261,250 @@ 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-4 py-2 text-sm transition-colors",
|
"px-4 py-2 text-[10px] font-mono uppercase tracking-wider transition-colors",
|
||||||
filter === item.value
|
filter === item.value
|
||||||
? "bg-white/10 text-white font-medium"
|
? "bg-white/10 text-white"
|
||||||
: "text-white/40 hover:text-white/60"
|
: "text-white/30 hover:text-white/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.label} <span className="text-white/30">({item.count})</span>
|
{item.label} ({item.count})
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
{/* TABLE */}
|
{/* TABLE */}
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
<div className="bg-[#0A0A0A] border border-white/[0.06]">
|
<section className="py-6">
|
||||||
{!filteredDomains.length ? (
|
{!filteredDomains.length ? (
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
<div className="w-14 h-14 mx-auto bg-white/[0.02] border border-white/[0.06] flex items-center justify-center mb-4">
|
<div className="w-12 h-12 mx-auto border border-white/10 flex items-center justify-center mb-4">
|
||||||
<Eye className="w-6 h-6 text-white/20" />
|
<Crosshair className="w-5 h-5 text-white/20" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white/40 text-sm">No domains in your watchlist</p>
|
<p className="text-white/30 font-mono text-sm uppercase">No targets</p>
|
||||||
<p className="text-white/20 text-xs mt-1">Add a domain above to start monitoring</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="space-y-px">
|
||||||
{/* Table Header */}
|
{/* Table Header */}
|
||||||
<div className="hidden lg:grid grid-cols-[1fr_100px_100px_120px_80px_100px] gap-4 px-5 py-3 text-xs font-medium uppercase tracking-wider text-white/30 border-b border-white/[0.06] bg-black/30">
|
<div className="hidden lg:grid grid-cols-[1fr_100px_100px_100px_80px_100px] gap-4 px-4 py-2 text-[9px] font-mono uppercase tracking-widest text-white/30 border-b border-white/[0.05]">
|
||||||
<div>Domain</div>
|
<div>Domain</div>
|
||||||
<div>Status</div>
|
<div>Status</div>
|
||||||
<div>Health</div>
|
<div>Health</div>
|
||||||
<div>Expires</div>
|
<div>Expires</div>
|
||||||
<div>Notify</div>
|
<div>Alert</div>
|
||||||
<div className="text-right">Actions</div>
|
<div className="text-right">Actions</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rows */}
|
{/* Rows */}
|
||||||
<div className="divide-y divide-white/[0.04]">
|
{filteredDomains.map((domain) => {
|
||||||
{filteredDomains.map((domain) => {
|
const health = healthReports[domain.id]
|
||||||
const health = healthReports[domain.id]
|
const healthStatus = health?.status || 'unknown'
|
||||||
const healthStatus = health?.status || 'unknown'
|
const config = healthConfig[healthStatus]
|
||||||
const config = healthConfig[healthStatus]
|
const days = getDaysUntilExpiry(domain.expiration_date)
|
||||||
const days = getDaysUntilExpiry(domain.expiration_date)
|
|
||||||
|
return (
|
||||||
return (
|
<div
|
||||||
<div key={domain.id} className="group hover:bg-white/[0.02] transition-colors">
|
key={domain.id}
|
||||||
{/* Mobile */}
|
className="group bg-white/[0.01] hover:bg-white/[0.03] border border-white/[0.05] hover:border-white/[0.08] transition-all"
|
||||||
<div className="lg:hidden p-4 space-y-3">
|
>
|
||||||
<div className="flex items-center justify-between">
|
{/* Mobile */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="lg:hidden p-4">
|
||||||
<div className={clsx(
|
<div className="flex items-center justify-between mb-3">
|
||||||
"w-2 h-2 rounded-full",
|
<div className="flex items-center gap-3">
|
||||||
domain.is_available ? "bg-accent" : "bg-white/20"
|
<div className={clsx(
|
||||||
)} />
|
"w-2 h-2",
|
||||||
<span className="font-medium text-white">{domain.name}</span>
|
domain.is_available ? "bg-accent shadow-[0_0_8px_rgba(16,185,129,0.8)]" : "bg-white/15"
|
||||||
</div>
|
)} />
|
||||||
<span className={clsx(
|
<span className="font-mono text-sm text-white">{domain.name}</span>
|
||||||
"text-xs px-2 py-0.5 border",
|
|
||||||
domain.is_available ? "text-accent border-accent/30 bg-accent/5" : "text-white/40 border-white/10"
|
|
||||||
)}>
|
|
||||||
{domain.is_available ? 'Available' : 'Taken'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs text-white/40">
|
|
||||||
<span>{formatExpiryDate(domain.expiration_date)}</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button onClick={() => handleRefresh(domain.id)} className="p-2 hover:bg-white/5 rounded">
|
|
||||||
<RefreshCw className={clsx("w-4 h-4", refreshingId === domain.id && "animate-spin")} />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => handleDelete(domain.id, domain.name)} className="p-2 hover:bg-rose-500/10 hover:text-rose-400 rounded">
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className={clsx(
|
||||||
|
"text-[9px] font-mono uppercase px-2 py-0.5 border",
|
||||||
|
domain.is_available ? "text-accent border-accent/30" : "text-white/30 border-white/10"
|
||||||
|
)}>
|
||||||
|
{domain.is_available ? 'OPEN' : 'TAKEN'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop */}
|
<div className="flex items-center justify-between text-[10px] font-mono text-white/30">
|
||||||
<div className="hidden lg:grid grid-cols-[1fr_100px_100px_120px_80px_100px] gap-4 items-center px-5 py-4">
|
<span>{formatExpiryDate(domain.expiration_date)}</span>
|
||||||
{/* Domain */}
|
<div className="flex gap-2">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<button onClick={() => handleRefresh(domain.id)} className="p-1.5 hover:bg-white/5">
|
||||||
<div className={clsx(
|
<RefreshCw className={clsx("w-3.5 h-3.5", refreshingId === domain.id && "animate-spin")} />
|
||||||
"w-2 h-2 rounded-full shrink-0",
|
|
||||||
domain.is_available ? "bg-accent" : "bg-white/20"
|
|
||||||
)} />
|
|
||||||
<span className="font-medium text-white truncate">{domain.name}</span>
|
|
||||||
<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" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div>
|
|
||||||
<span className={clsx(
|
|
||||||
"text-xs px-2 py-0.5 border",
|
|
||||||
domain.is_available ? "text-accent border-accent/30 bg-accent/5" : "text-white/40 border-white/10"
|
|
||||||
)}>
|
|
||||||
{domain.is_available ? 'Available' : 'Taken'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Health */}
|
|
||||||
<button
|
|
||||||
onClick={() => { setSelectedDomain(domain.id); handleHealthCheck(domain.id) }}
|
|
||||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
|
||||||
>
|
|
||||||
{loadingHealth[domain.id] ? (
|
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-white/30" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Activity className={clsx("w-3.5 h-3.5", config.color)} />
|
|
||||||
<span className={clsx("text-xs", config.color)}>{config.label}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Expires */}
|
|
||||||
<div className="text-sm text-white/50">
|
|
||||||
{days !== null && days <= 30 && days > 0 ? (
|
|
||||||
<span className="text-amber-400 font-medium">{days} days</span>
|
|
||||||
) : (
|
|
||||||
formatExpiryDate(domain.expiration_date)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notify */}
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggleNotify(domain.id, domain.notify_on_available)}
|
|
||||||
disabled={togglingNotifyId === domain.id}
|
|
||||||
className={clsx(
|
|
||||||
"w-8 h-8 flex items-center justify-center rounded hover:bg-white/5 transition-all",
|
|
||||||
domain.notify_on_available ? "text-accent" : "text-white/25"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{togglingNotifyId === domain.id ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : domain.notify_on_available ? (
|
|
||||||
<Bell className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<BellOff className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => handleRefresh(domain.id)}
|
|
||||||
disabled={refreshingId === domain.id}
|
|
||||||
className="w-8 h-8 flex items-center justify-center text-white/30 hover:text-white hover:bg-white/5 rounded transition-all"
|
|
||||||
>
|
|
||||||
<RefreshCw className={clsx("w-4 h-4", refreshingId === domain.id && "animate-spin")} />
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={() => handleDelete(domain.id, domain.name)} className="p-1.5 hover:bg-rose-500/10 hover:text-rose-400">
|
||||||
onClick={() => handleDelete(domain.id, domain.name)}
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
disabled={deletingId === domain.id}
|
|
||||||
className="w-8 h-8 flex items-center justify-center text-white/30 hover:text-rose-400 hover:bg-rose-500/10 rounded transition-all"
|
|
||||||
>
|
|
||||||
{deletingId === domain.id ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
})}
|
{/* Desktop */}
|
||||||
</div>
|
<div className="hidden lg:grid grid-cols-[1fr_100px_100px_100px_80px_100px] gap-4 items-center px-4 py-3">
|
||||||
</>
|
{/* Domain */}
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className={clsx(
|
||||||
|
"w-1.5 h-1.5 shrink-0",
|
||||||
|
domain.is_available ? "bg-accent shadow-[0_0_6px_rgba(16,185,129,0.8)]" : "bg-white/15"
|
||||||
|
)} />
|
||||||
|
<span className="font-mono text-sm text-white truncate">{domain.name}</span>
|
||||||
|
<a href={`https://${domain.name}`} target="_blank" className="opacity-0 group-hover:opacity-50 hover:!opacity-100 transition-opacity">
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div>
|
||||||
|
<span className={clsx(
|
||||||
|
"text-[9px] font-mono uppercase px-2 py-0.5 border",
|
||||||
|
domain.is_available ? "text-accent border-accent/30 bg-accent/5" : "text-white/30 border-white/10"
|
||||||
|
)}>
|
||||||
|
{domain.is_available ? 'AVAIL' : 'TAKEN'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Health */}
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedDomain(domain.id); handleHealthCheck(domain.id) }}
|
||||||
|
className="flex items-center gap-1.5 group/health"
|
||||||
|
>
|
||||||
|
{loadingHealth[domain.id] ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin text-white/30" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Activity className={clsx("w-3 h-3", config.color)} />
|
||||||
|
<span className={clsx("text-[9px] font-mono uppercase", config.color)}>{config.label}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expires */}
|
||||||
|
<div className="text-[10px] font-mono text-white/40">
|
||||||
|
{days !== null && days <= 30 && days > 0 ? (
|
||||||
|
<span className="text-amber-400">{days}d</span>
|
||||||
|
) : (
|
||||||
|
formatExpiryDate(domain.expiration_date)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alert */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleNotify(domain.id, domain.notify_on_available)}
|
||||||
|
disabled={togglingNotifyId === domain.id}
|
||||||
|
className={clsx(
|
||||||
|
"w-6 h-6 flex items-center justify-center transition-colors",
|
||||||
|
domain.notify_on_available ? "text-accent" : "text-white/20 hover:text-white/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{togglingNotifyId === domain.id ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : domain.notify_on_available ? (
|
||||||
|
<Bell className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<BellOff className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleRefresh(domain.id)}
|
||||||
|
disabled={refreshingId === domain.id}
|
||||||
|
className="w-7 h-7 flex items-center justify-center text-white/20 hover:text-white hover:bg-white/5 transition-all"
|
||||||
|
>
|
||||||
|
<RefreshCw className={clsx("w-3.5 h-3.5", refreshingId === domain.id && "animate-spin")} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(domain.id, domain.name)}
|
||||||
|
disabled={deletingId === domain.id}
|
||||||
|
className="w-7 h-7 flex items-center justify-center text-white/20 hover:text-rose-400 hover:bg-rose-500/10 transition-all"
|
||||||
|
>
|
||||||
|
{deletingId === domain.id ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
{/* HEALTH MODAL */}
|
{/* HEALTH MODAL */}
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
{selectedDomainData && (
|
{selectedDomainData && (
|
||||||
<div
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm" onClick={() => setSelectedDomain(null)}>
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
<div className="w-full max-w-md bg-[#0A0A0A] border border-white/15 p-1.5" onClick={(e) => e.stopPropagation()}>
|
||||||
onClick={() => setSelectedDomain(null)}
|
{/* Corner Decorations */}
|
||||||
>
|
<div className="absolute -top-px -left-px w-4 h-4 border-t border-l border-accent/60" />
|
||||||
<div
|
<div className="absolute -top-px -right-px w-4 h-4 border-t border-r border-accent/60" />
|
||||||
className="w-full max-w-md bg-[#0A0A0A] border border-white/15 shadow-2xl"
|
<div className="absolute -bottom-px -left-px w-4 h-4 border-b border-l border-accent/60" />
|
||||||
onClick={(e) => e.stopPropagation()}
|
<div className="absolute -bottom-px -right-px w-4 h-4 border-b border-r border-accent/60" />
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/[0.06]">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Activity className="w-5 h-5 text-accent" />
|
|
||||||
<span className="font-semibold text-white">Health Report</span>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setSelectedDomain(null)} className="text-white/30 hover:text-white p-1">
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
<div className="bg-[#050505] p-6 relative">
|
||||||
<div className="p-6">
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Activity className="w-4 h-4 text-accent" />
|
||||||
|
<span className="text-[10px] font-mono uppercase tracking-widest text-accent">Health Intel</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setSelectedDomain(null)} className="text-white/30 hover:text-white">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Domain */}
|
{/* Domain */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h3 className="text-lg font-medium text-white">{selectedDomainData.name}</h3>
|
<h3 className="font-mono text-lg text-white">{selectedDomainData.name}</h3>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<span className={clsx(
|
<div className={clsx(
|
||||||
"text-xs px-2.5 py-1 border",
|
"text-[9px] font-mono uppercase px-2 py-0.5 border",
|
||||||
healthConfig[selectedHealth?.status || 'unknown'].bg,
|
healthConfig[selectedHealth?.status || 'unknown'].bg,
|
||||||
healthConfig[selectedHealth?.status || 'unknown'].color
|
healthConfig[selectedHealth?.status || 'unknown'].color
|
||||||
)}>
|
)}>
|
||||||
{healthConfig[selectedHealth?.status || 'unknown'].label}
|
{healthConfig[selectedHealth?.status || 'unknown'].label}
|
||||||
</span>
|
</div>
|
||||||
{selectedHealth?.score !== undefined && (
|
|
||||||
<span className="text-xs text-white/40">Score: {selectedHealth.score}/100</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Checks */}
|
{/* Checks */}
|
||||||
{selectedHealth && (
|
{selectedHealth && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
{[
|
{[
|
||||||
{ label: 'DNS Resolution', value: selectedHealth.dns?.has_a },
|
{ label: 'DNS', value: selectedHealth.dns?.has_a },
|
||||||
{ label: 'HTTP Reachable', value: selectedHealth.http?.is_reachable },
|
{ label: 'HTTP', value: selectedHealth.http?.is_reachable },
|
||||||
{ label: 'SSL Certificate', value: selectedHealth.ssl?.has_certificate },
|
{ label: 'SSL', value: selectedHealth.ssl?.has_certificate },
|
||||||
{ label: 'Not Parked', value: !selectedHealth.dns?.is_parked && !selectedHealth.http?.is_parked },
|
{ label: 'Parked', value: !selectedHealth.dns?.is_parked && !selectedHealth.http?.is_parked },
|
||||||
].map((check) => (
|
].map((check) => (
|
||||||
<div key={check.label} className="flex items-center justify-between py-2">
|
<div key={check.label} className="flex items-center justify-between py-2 border-b border-white/[0.05]">
|
||||||
<span className="text-sm text-white/60">{check.label}</span>
|
<span className="text-xs font-mono text-white/50 uppercase">{check.label}</span>
|
||||||
{check.value ? (
|
{check.value ? (
|
||||||
<CheckCircle2 className="w-5 h-5 text-accent" />
|
<CheckCircle2 className="w-4 h-4 text-accent" />
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="w-5 h-5 text-rose-400" />
|
<XCircle className="w-4 h-4 text-rose-400" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Refresh Button */}
|
{/* Refresh */}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleHealthCheck(selectedDomainData.id)}
|
onClick={() => handleHealthCheck(selectedDomainData.id)}
|
||||||
disabled={loadingHealth[selectedDomainData.id]}
|
disabled={loadingHealth[selectedDomainData.id]}
|
||||||
className="w-full mt-6 py-3 bg-white/5 border border-white/10 text-white/70 text-sm font-medium hover:bg-white/10 hover:text-white transition-all flex items-center justify-center gap-2"
|
className="w-full mt-6 py-3 border border-white/10 text-white/50 font-mono text-[10px] uppercase tracking-wider hover:bg-white/5 hover:text-white transition-all flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{loadingHealth[selectedDomainData.id] ? (
|
{loadingHealth[selectedDomainData.id] ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
Refresh Health Check
|
Refresh
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -235,7 +235,7 @@ export function CommandCenterLayout({
|
|||||||
<main className="relative">
|
<main className="relative">
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
"mx-auto",
|
"mx-auto",
|
||||||
minimal ? "max-w-[1400px] px-6 sm:px-10 lg:px-16 py-4" : "max-w-7xl px-4 sm:px-6 lg:px-8 py-6 sm:py-8"
|
minimal ? "max-w-[1600px] px-8 sm:px-12 lg:px-20" : "max-w-7xl px-4 sm:px-6 lg:px-8 py-6 sm:py-8"
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -27,7 +27,7 @@ const getApiBase = (): string => {
|
|||||||
// Local network (10.x.x.x, 192.168.x.x, 172.16-31.x.x)
|
// Local network (10.x.x.x, 192.168.x.x, 172.16-31.x.x)
|
||||||
if (/^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(hostname)) {
|
if (/^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(hostname)) {
|
||||||
return `http://${hostname}:8000/api/v1`
|
return `http://${hostname}:8000/api/v1`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production: use same protocol and domain with /api/v1 path
|
// Production: use same protocol and domain with /api/v1 path
|
||||||
// This requires a reverse proxy (nginx/caddy) to route /api/v1 to the backend
|
// This requires a reverse proxy (nginx/caddy) to route /api/v1 to the backend
|
||||||
@ -50,7 +50,7 @@ interface ApiError {
|
|||||||
class ApiClient {
|
class ApiClient {
|
||||||
get baseUrl(): string {
|
get baseUrl(): string {
|
||||||
return getApiBaseUrl().replace('/api/v1', '')
|
return getApiBaseUrl().replace('/api/v1', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
async request<T>(
|
async request<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user