feat: Unified Market Feed API + Pounce Direct Integration

🚀 MARKET CONCEPT IMPLEMENTATION

Backend:
- Added /auctions/feed unified endpoint combining Pounce Direct + external auctions
- Implemented Pounce Score v2.0 with market signals (length, TLD, bids, age)
- Added vanity filter for premium domains (non-auth users)
- Integrated DomainListing model for Pounce Direct

Frontend:
- Refactored terminal/market page with Pounce Direct hierarchy
- Updated public auctions page with Pounce Exclusive section
- Added api.getMarketFeed() to API client
- Converted /market to redirect to /auctions

Documentation:
- Created MARKET_CONCEPT.md with full unicorn roadmap
- Created ZONE_FILE_ACCESS.md with Verisign access guide
- Updated todos and progress tracking

Cleanup:
- Deleted empty legacy folders (dashboard, portfolio, settings, watchlist, careers)
This commit is contained in:
yves.gugger
2025-12-11 08:59:50 +01:00
parent 5857123ed5
commit 6a6e2460d5
8 changed files with 2777 additions and 599 deletions

1415
MARKET_CONCEPT.md Normal file

File diff suppressed because it is too large Load Diff

307
ZONE_FILE_ACCESS.md Normal file
View File

@ -0,0 +1,307 @@
# 🌐 Zone File Access — Anleitung zur Datenhoheit
---
## Was sind Zone Files?
Zone Files sind die **Master-Listen** aller registrierten Domains pro TLD (Top-Level-Domain). Sie werden täglich von den Registries aktualisiert und enthalten:
- **Alle aktiven Domains** einer TLD
- **Nameserver-Informationen**
- **Keine WHOIS-Daten** (nur Domain + NS)
**Beispiel `.com` Zone File (vereinfacht):**
```
example.com. 86400 IN NS ns1.example.com.
example.com. 86400 IN NS ns2.example.com.
google.com. 86400 IN NS ns1.google.com.
...
```
---
## Warum Zone Files = Unicorn?
| Vorteil | Beschreibung |
|---------|--------------|
| **Drop Prediction** | Domains die aus der Zone verschwinden = droppen in 1-5 Tagen |
| **Exklusive Intel** | Diese Domains sind NOCH NICHT in Auktionen |
| **Früher als Konkurrenz** | Backorder setzen bevor andere es wissen |
| **Trend-Analyse** | Welche Keywords werden gerade registriert? |
| **Daten-Monopol** | Gefilterte, cleane Daten vs. Spam-Flut von ExpiredDomains |
---
## Registries und Zugang
### Tier 1: Critical TLDs (Sofort beantragen)
| Registry | TLDs | Domains | Link |
|----------|------|---------|------|
| **Verisign** | `.com`, `.net` | ~160M + 13M | [Zone File Access](https://www.verisign.com/en_US/channel-resources/domain-registry-products/zone-file/index.xhtml) |
| **PIR** | `.org` | ~10M | [Zone File Access Program](https://tld.org/zone-file-access/) |
| **Afilias** | `.info` | ~4M | Contact: registry@afilias.info |
### Tier 2: Premium TLDs (Phase 2)
| Registry | TLDs | Fokus |
|----------|------|-------|
| **CentralNIC** | `.io`, `.co` | Startups |
| **Google** | `.app`, `.dev` | Tech |
| **Donuts** | `.xyz`, `.online`, etc. | Volumen |
| **SWITCH** | `.ch` | Schweizer Markt |
---
## Bewerbungsprozess: Verisign (.com/.net)
### 1. Voraussetzungen
- Gültige Firma/Organisation
- Technische Infrastruktur für große Datenmengen (~500GB/Tag)
- Akzeptanz der Nutzungsbedingungen (keine Resale der Rohdaten)
### 2. Online-Bewerbung
1. Gehe zu: https://www.verisign.com/en_US/channel-resources/domain-registry-products/zone-file/index.xhtml
2. Klicke auf "Request Zone File Access"
3. Fülle das Formular aus:
- **Organization Name:** GenTwo AG
- **Purpose:** Domain research and analytics platform
- **Contact:** (technischer Ansprechpartner)
### 3. Wartezeit
- **Review:** 1-4 Wochen
- **Genehmigung:** Per E-Mail mit FTP/HTTPS Zugangsdaten
### 4. Kosten
- **Verisign:** Kostenlos für nicht-kommerzielle/Forschungszwecke
- **Kommerzielle Nutzung:** $10,000/Jahr (verhandelbar)
---
## Technische Integration
### Server-Anforderungen
```yaml
# Minimale Infrastruktur
CPU: 16+ Cores (parallele Verarbeitung)
RAM: 64GB+ (effizientes Set-Diffing)
Storage: 2TB SSD (Zone Files + History)
Network: 1Gbps (schneller Download)
# Geschätzte Kosten
Provider: Hetzner/OVH Dedicated
Preis: ~$300-500/Monat
```
### Processing Pipeline
```
04:00 UTC │ Zone File Download (FTP/HTTPS)
│ └─→ ~500GB komprimiert für .com/.net
04:30 UTC │ Decompression & Parsing
│ └─→ Extrahiere Domain-Namen
05:00 UTC │ Diff Analysis
│ └─→ Vergleiche mit gestern
│ └─→ NEU: Neue Registrierungen
│ └─→ WEG: Potentielle Drops
05:30 UTC │ Quality Scoring (Pounce Algorithm)
│ └─→ Filtere Spam raus (99%+)
│ └─→ Nur Premium-Domains durchlassen
06:00 UTC │ Database Update
│ └─→ PostgreSQL: pounce_zone_drops
06:15 UTC │ Alert Matching
│ └─→ Sniper Alerts triggern
06:30 UTC │ User Notifications
│ └─→ E-Mail/SMS für Tycoon-User
```
### Datenbank-Schema (geplant)
```sql
-- Zone File Drops
CREATE TABLE pounce_zone_drops (
id SERIAL PRIMARY KEY,
domain VARCHAR(255) NOT NULL,
tld VARCHAR(20) NOT NULL,
-- Analyse
pounce_score INT NOT NULL,
estimated_value DECIMAL(10,2),
-- Status
detected_at TIMESTAMP DEFAULT NOW(),
estimated_drop_date TIMESTAMP,
status VARCHAR(20) DEFAULT 'pending', -- pending, dropped, backordered, registered
-- Tracking
notified_users INT DEFAULT 0,
backorder_count INT DEFAULT 0,
UNIQUE(domain)
);
-- Index für schnelle Suche
CREATE INDEX idx_zone_drops_score ON pounce_zone_drops(pounce_score DESC);
CREATE INDEX idx_zone_drops_date ON pounce_zone_drops(estimated_drop_date);
```
---
## Der Pounce Algorithm — Zone File Edition
```python
# backend/app/services/zone_analyzer.py (ZU BAUEN)
class ZoneFileAnalyzer:
"""
Analysiert Zone Files und findet Premium-Opportunities.
Input: Raw Zone File (Millionen von Domains)
Output: Gefilterte Premium-Liste (Hunderte)
"""
async def analyze_drops(self, yesterday: set, today: set) -> list:
"""
Findet Domains die aus der Zone verschwunden sind.
Diese Domains droppen in 1-5 Tagen (Redemption Period).
"""
dropped = yesterday - today # Set-Differenz
premium_drops = []
for domain in dropped:
score = self.calculate_pounce_score(domain)
# Nur Premium durchlassen (>70 Score)
if score >= 70:
premium_drops.append({
"domain": domain,
"score": score,
"drop_date": self.estimate_drop_date(domain),
"estimated_value": self.estimate_value(domain),
})
return sorted(premium_drops, key=lambda x: x['score'], reverse=True)
def calculate_pounce_score(self, domain: str) -> int:
"""
Der Pounce Algorithm — Qualitätsfilter für Domains.
Faktoren:
- Länge (kurz = wertvoll)
- TLD (com > io > xyz)
- Keine Zahlen/Bindestriche
- Dictionary Word Bonus
"""
name = domain.rsplit('.', 1)[0]
tld = domain.rsplit('.', 1)[1]
score = 50 # Baseline
# Längen-Score (exponentiell für kurze Domains)
length_scores = {1: 50, 2: 45, 3: 40, 4: 30, 5: 20, 6: 15, 7: 10}
score += length_scores.get(len(name), max(0, 15 - len(name)))
# TLD Premium
tld_scores = {'com': 20, 'ai': 25, 'io': 18, 'co': 12, 'ch': 15, 'de': 10}
score += tld_scores.get(tld, 0)
# Penalties
if '-' in name: score -= 30
if any(c.isdigit() for c in name): score -= 20
if len(name) > 12: score -= 15
return max(0, min(100, score))
```
---
## Feature: "Drops Tomorrow" (Tycoon Exclusive)
```
┌─────────────────────────────────────────────────────────────────┐
│ 🔮 DROPS TOMORROW — Tycoon Exclusive ($29/mo) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Diese Domains sind NICHT in Auktionen! │
│ Du kannst sie beim Registrar direkt registrieren. │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ Domain TLD Score Est. Value Drops In │
│ ───────────────────────────────────────────────────────────── │
│ pixel.com .com 95 $50,000 23h 45m │
│ swift.io .io 88 $8,000 23h 12m │
│ quantum.ai .ai 92 $25,000 22h 58m │
│ nexus.dev .dev 84 $4,500 22h 30m │
│ fusion.co .co 81 $3,200 21h 15m │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 💡 Pro Tip: Setze bei deinem Registrar einen Backorder │
│ für diese Domains. Wer zuerst kommt... │
│ │
│ [🔔 Alert für "pixel.com" setzen] │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Roadmap
### Phase 1: Jetzt (Bewerbung)
- [ ] Verisign Zone File Access beantragen
- [ ] PIR (.org) Zone File Access beantragen
- [ ] Server-Infrastruktur planen
### Phase 2: 3-6 Monate (Integration)
- [ ] Download-Pipeline bauen
- [ ] Diff-Analyse implementieren
- [ ] Pounce Algorithm testen
- [ ] "Drops Tomorrow" Feature für Tycoon
### Phase 3: 6-12 Monate (Skalierung)
- [ ] Weitere TLDs (.io, .co, .ch, .de)
- [ ] Historische Trend-Analyse
- [ ] Keyword-Tracking
- [ ] Enterprise Features
---
## Risiken und Mitigierung
| Risiko | Wahrscheinlichkeit | Mitigierung |
|--------|-------------------|-------------|
| Ablehnung durch Registry | Mittel | Klare Business-Case, ggf. Partnerschaften |
| Hohe Serverkosten | Niedrig | Cloud-Skalierung, nur Premium-TLDs |
| Konkurrenz kopiert | Mittel | First-Mover-Vorteil, besserer Algorithmus |
| Datenqualität | Niedrig | Mehrere Quellen, Validierung |
---
## Nächster Schritt
**Aktion für diese Woche:**
1. **Verisign bewerben:** https://www.verisign.com/en_US/channel-resources/domain-registry-products/zone-file/index.xhtml
2. **E-Mail an PIR:** zone-file-access@pir.org
3. **Server bei Hetzner reservieren:** AX101 Dedicated (~€60/Monat)
---
## Zusammenfassung
Zone Files sind der **Schlüssel zur Datenhoheit**. Während die Konkurrenz auf Scraping setzt, werden wir die Rohdaten direkt von der Quelle haben — und mit dem Pounce Algorithm filtern, sodass nur Premium-Opportunities zu unseren Usern gelangen.
**Das ist der Unicorn-Treiber.** 🦄

View File

@ -10,6 +10,11 @@ Data Sources (Web Scraping):
- Sedo (public search)
- NameJet (public auctions)
PLUS Pounce Direct Listings (user-created marketplace):
- DNS-verified owner listings
- Instant buy option
- 0% commission
IMPORTANT:
- All data comes from web scraping of public pages
- No mock data - everything is real scraped data
@ -24,15 +29,17 @@ Legal Note (Switzerland):
import logging
from datetime import datetime, timedelta
from typing import Optional, List
from itertools import groupby
from fastapi import APIRouter, Depends, Query, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, func, and_
from sqlalchemy import select, func, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.api.deps import get_current_user, get_current_user_optional
from app.models.user import User
from app.models.auction import DomainAuction, AuctionScrapeLog
from app.models.listing import DomainListing, ListingStatus, VerificationStatus
from app.services.valuation import valuation_service
from app.services.auction_scraper import auction_scraper
@ -103,6 +110,55 @@ class ScrapeStatus(BaseModel):
next_scrape: Optional[datetime]
class MarketFeedItem(BaseModel):
"""Unified market feed item - combines auctions and Pounce Direct listings."""
id: str
domain: str
tld: str
price: float
currency: str = "USD"
price_type: str # "bid" or "fixed"
status: str # "auction" or "instant"
# Source info
source: str # "Pounce", "GoDaddy", "Sedo", etc.
is_pounce: bool = False
verified: bool = False
# Auction-specific
time_remaining: Optional[str] = None
end_time: Optional[datetime] = None
num_bids: Optional[int] = None
# Pounce Direct specific
slug: Optional[str] = None
seller_verified: bool = False
# URLs
url: str # Internal for Pounce, external for auctions
is_external: bool = True
# Scoring
pounce_score: int = 50
# Valuation (optional)
valuation: Optional[AuctionValuation] = None
class Config:
from_attributes = True
class MarketFeedResponse(BaseModel):
"""Response for unified market feed."""
items: List[MarketFeedItem]
total: int
pounce_direct_count: int
auction_count: int
sources: List[str]
last_updated: datetime
filters_applied: dict = {}
# ============== Helper Functions ==============
def _format_time_remaining(end_time: datetime) -> str:
@ -711,3 +767,345 @@ def _get_opportunity_reasoning(value_ratio: float, hours_left: float, num_bids:
reasons.append(f"🔥 High demand ({num_bids} bids)")
return " | ".join(reasons)
def _calculate_pounce_score_v2(domain: str, tld: str, num_bids: int = 0, age_years: int = 0, is_pounce: bool = False) -> int:
"""
Pounce Score v2.0 - Enhanced scoring algorithm.
Factors:
- Length (shorter = more valuable)
- TLD premium
- Market activity (bids)
- Age bonus
- Pounce Direct bonus (verified listings)
- Penalties (hyphens, numbers, etc.)
"""
score = 50 # Baseline
name = domain.rsplit('.', 1)[0] if '.' in domain else domain
# A) LENGTH BONUS (exponential for short domains)
length_scores = {1: 50, 2: 45, 3: 40, 4: 30, 5: 20, 6: 15, 7: 10}
score += length_scores.get(len(name), max(0, 15 - len(name)))
# B) TLD PREMIUM
tld_scores = {
'com': 20, 'ai': 25, 'io': 18, 'co': 12,
'ch': 15, 'de': 10, 'net': 8, 'org': 8,
'app': 10, 'dev': 10, 'xyz': 5
}
score += tld_scores.get(tld.lower(), 0)
# C) MARKET ACTIVITY (bids = demand signal)
if num_bids >= 20:
score += 15
elif num_bids >= 10:
score += 10
elif num_bids >= 5:
score += 5
elif num_bids >= 2:
score += 2
# D) AGE BONUS (established domains)
if age_years and age_years > 15:
score += 10
elif age_years and age_years > 10:
score += 7
elif age_years and age_years > 5:
score += 3
# E) POUNCE DIRECT BONUS (verified = trustworthy)
if is_pounce:
score += 10
# F) PENALTIES
if '-' in name:
score -= 25
if any(c.isdigit() for c in name) and len(name) > 3:
score -= 20
if len(name) > 15:
score -= 15
# G) CONSONANT CHECK (no gibberish like "xkqzfgh")
consonants = 'bcdfghjklmnpqrstvwxyz'
max_streak = 0
current_streak = 0
for c in name.lower():
if c in consonants:
current_streak += 1
max_streak = max(max_streak, current_streak)
else:
current_streak = 0
if max_streak > 4:
score -= 15
return max(0, min(100, score))
def _is_premium_domain(domain_name: str) -> bool:
"""Check if a domain looks premium/professional (Vanity Filter)."""
parts = domain_name.rsplit('.', 1)
name = parts[0] if parts else domain_name
tld = parts[1].lower() if len(parts) > 1 else ""
# Premium TLDs only
premium_tlds = ['com', 'io', 'ai', 'co', 'de', 'ch', 'net', 'org', 'app', 'dev', 'xyz']
if tld and tld not in premium_tlds:
return False
# Length check
if len(name) > 15:
return False
if len(name) < 3:
return False
# Hyphen check
if name.count('-') > 1:
return False
# Digit check
if sum(1 for c in name if c.isdigit()) > 2:
return False
# Consonant cluster check
consonants = 'bcdfghjklmnpqrstvwxyz'
max_streak = 0
current_streak = 0
for c in name.lower():
if c in consonants:
current_streak += 1
max_streak = max(max_streak, current_streak)
else:
current_streak = 0
if max_streak > 4:
return False
return True
# ============== UNIFIED MARKET FEED ==============
@router.get("/feed", response_model=MarketFeedResponse)
async def get_market_feed(
# Source filter
source: str = Query("all", enum=["all", "pounce", "external"]),
# Search & filters
keyword: Optional[str] = Query(None, description="Search in domain names"),
tld: Optional[str] = Query(None, description="Filter by TLD"),
min_price: Optional[float] = Query(None, ge=0),
max_price: Optional[float] = Query(None, ge=0),
min_score: int = Query(0, ge=0, le=100),
ending_within: Optional[int] = Query(None, description="Auctions ending within X hours"),
verified_only: bool = Query(False, description="Only show verified Pounce listings"),
# Sort
sort_by: str = Query("score", enum=["score", "price_asc", "price_desc", "time", "newest"]),
# Pagination
limit: int = Query(50, le=200),
offset: int = Query(0, ge=0),
# Auth
current_user: Optional[User] = Depends(get_current_user_optional),
db: AsyncSession = Depends(get_db),
):
"""
🚀 UNIFIED MARKET FEED — The heart of Pounce
Combines:
- 💎 Pounce Direct: DNS-verified user listings (instant buy)
- 🏢 External Auctions: Scraped from GoDaddy, Sedo, NameJet, etc.
For non-authenticated users:
- Vanity filter applied (premium domains only)
- Pounce Score visible but limited details
For authenticated users (Trader/Tycoon):
- Full access to all domains
- Advanced filtering
- Valuation data
POUNCE EXCLUSIVE domains are highlighted and appear first.
"""
items: List[MarketFeedItem] = []
pounce_count = 0
auction_count = 0
# ═══════════════════════════════════════════════════════════════
# 1. POUNCE DIRECT LISTINGS (Our USP!)
# ═══════════════════════════════════════════════════════════════
if source in ["all", "pounce"]:
listing_query = select(DomainListing).where(
DomainListing.status == ListingStatus.ACTIVE.value
)
if keyword:
listing_query = listing_query.where(
DomainListing.domain.ilike(f"%{keyword}%")
)
if verified_only:
listing_query = listing_query.where(
DomainListing.verification_status == VerificationStatus.VERIFIED.value
)
if min_price is not None:
listing_query = listing_query.where(DomainListing.asking_price >= min_price)
if max_price is not None:
listing_query = listing_query.where(DomainListing.asking_price <= max_price)
result = await db.execute(listing_query)
listings = result.scalars().all()
for listing in listings:
domain_tld = listing.domain.rsplit('.', 1)[1] if '.' in listing.domain else ""
# Apply TLD filter
if tld and domain_tld.lower() != tld.lower().lstrip('.'):
continue
pounce_score = listing.pounce_score or _calculate_pounce_score_v2(
listing.domain, domain_tld, is_pounce=True
)
# Apply score filter
if pounce_score < min_score:
continue
items.append(MarketFeedItem(
id=f"pounce-{listing.id}",
domain=listing.domain,
tld=domain_tld,
price=listing.asking_price or 0,
currency=listing.currency or "USD",
price_type="fixed" if listing.price_type == "fixed" else "negotiable",
status="instant",
source="Pounce",
is_pounce=True,
verified=listing.is_verified,
seller_verified=listing.is_verified,
slug=listing.slug,
url=f"/buy/{listing.slug}",
is_external=False,
pounce_score=pounce_score,
))
pounce_count += 1
# ═══════════════════════════════════════════════════════════════
# 2. EXTERNAL AUCTIONS (Scraped from platforms)
# ═══════════════════════════════════════════════════════════════
if source in ["all", "external"]:
auction_query = select(DomainAuction).where(DomainAuction.is_active == True)
if keyword:
auction_query = auction_query.where(
DomainAuction.domain.ilike(f"%{keyword}%")
)
if tld:
auction_query = auction_query.where(
DomainAuction.tld == tld.lower().lstrip('.')
)
if min_price is not None:
auction_query = auction_query.where(DomainAuction.current_bid >= min_price)
if max_price is not None:
auction_query = auction_query.where(DomainAuction.current_bid <= max_price)
if ending_within:
cutoff = datetime.utcnow() + timedelta(hours=ending_within)
auction_query = auction_query.where(DomainAuction.end_time <= cutoff)
result = await db.execute(auction_query)
auctions = result.scalars().all()
for auction in auctions:
# Apply vanity filter for non-authenticated users
if current_user is None and not _is_premium_domain(auction.domain):
continue
pounce_score = _calculate_pounce_score_v2(
auction.domain,
auction.tld,
num_bids=auction.num_bids,
age_years=auction.age_years or 0,
is_pounce=False
)
# Apply score filter
if pounce_score < min_score:
continue
items.append(MarketFeedItem(
id=f"auction-{auction.id}",
domain=auction.domain,
tld=auction.tld,
price=auction.current_bid,
currency=auction.currency,
price_type="bid",
status="auction",
source=auction.platform,
is_pounce=False,
verified=False,
time_remaining=_format_time_remaining(auction.end_time),
end_time=auction.end_time,
num_bids=auction.num_bids,
url=_get_affiliate_url(auction.platform, auction.domain, auction.auction_url),
is_external=True,
pounce_score=pounce_score,
))
auction_count += 1
# ═══════════════════════════════════════════════════════════════
# 3. SORT (Pounce Direct always appears first within same score)
# ═══════════════════════════════════════════════════════════════
if sort_by == "score":
items.sort(key=lambda x: (-x.pounce_score, -int(x.is_pounce), x.domain))
elif sort_by == "price_asc":
items.sort(key=lambda x: (x.price, -int(x.is_pounce), x.domain))
elif sort_by == "price_desc":
items.sort(key=lambda x: (-x.price, -int(x.is_pounce), x.domain))
elif sort_by == "time":
# Pounce Direct first (no time limit), then by end time
def time_sort_key(x):
if x.is_pounce:
return (0, datetime.max)
return (1, x.end_time or datetime.max)
items.sort(key=time_sort_key)
elif sort_by == "newest":
items.sort(key=lambda x: (-int(x.is_pounce), x.domain))
total = len(items)
# Pagination
items = items[offset:offset + limit]
# Get unique sources
sources = list(set(item.source for item in items))
# Last update time
last_update_result = await db.execute(
select(func.max(DomainAuction.updated_at))
)
last_updated = last_update_result.scalar() or datetime.utcnow()
return MarketFeedResponse(
items=items,
total=total,
pounce_direct_count=pounce_count,
auction_count=auction_count,
sources=sources,
last_updated=last_updated,
filters_applied={
"source": source,
"keyword": keyword,
"tld": tld,
"min_price": min_price,
"max_price": max_price,
"min_score": min_score,
"ending_within": ending_within,
"verified_only": verified_only,
"sort_by": sort_by,
}
)

View File

@ -1,57 +1,81 @@
#!/usr/bin/env python3
"""
Script to reset admin password.
Reset admin password for guggeryves@hotmail.com
"""
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy import select
from passlib.context import CryptContext
from app.database import AsyncSessionLocal
from app.models.user import User
from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG
from app.services.auth import AuthService
from datetime import datetime, timedelta
ADMIN_EMAIL = "guggeryves@hotmail.com"
NEW_PASSWORD = "Pounce2024!" # Strong password
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
async def reset_password():
"""Reset admin password."""
print(f"🔐 Resetting password for: {ADMIN_EMAIL}")
async def reset_admin_password():
async with AsyncSessionLocal() as db:
result = await db.execute(
select(User).where(User.email == ADMIN_EMAIL)
)
admin_email = "guggeryves@hotmail.com"
new_password = "Admin123!"
print(f"🔍 Looking for user: {admin_email}")
result = await db.execute(select(User).where(User.email == admin_email))
user = result.scalar_one_or_none()
if not user:
print(f"❌ User not found: {ADMIN_EMAIL}")
return False
print(f"❌ User with email {admin_email} not found.")
return
# Hash new password
hashed = pwd_context.hash(NEW_PASSWORD)
user.hashed_password = hashed
print(f"✅ User found: ID={user.id}, Name={user.name}")
# Update password
user.hashed_password = AuthService.hash_password(new_password)
user.is_verified = True
user.is_active = True
user.is_admin = True
user.is_active = True
user.updated_at = datetime.utcnow()
await db.commit()
print(f"✅ Password updated to: {new_password}")
print(f"✅ Password reset successful!")
print(f"\n📋 LOGIN CREDENTIALS:")
print(f" Email: {ADMIN_EMAIL}")
print(f" Password: {NEW_PASSWORD}")
print(f"\n⚠️ Please change this password after logging in!")
# Ensure user has Tycoon subscription
sub_result = await db.execute(
select(Subscription).where(Subscription.user_id == user.id)
)
subscription = sub_result.scalar_one_or_none()
return True
tycoon_config = TIER_CONFIG.get(SubscriptionTier.TYCOON, {})
if not subscription:
subscription = Subscription(
user_id=user.id,
tier=SubscriptionTier.TYCOON,
status=SubscriptionStatus.ACTIVE,
max_domains=tycoon_config.get("domain_limit", 500),
started_at=datetime.utcnow(),
expires_at=datetime.utcnow() + timedelta(days=365 * 100),
)
db.add(subscription)
await db.commit()
print("✅ Created new Tycoon subscription.")
elif subscription.tier != SubscriptionTier.TYCOON or subscription.status != SubscriptionStatus.ACTIVE:
subscription.tier = SubscriptionTier.TYCOON
subscription.status = SubscriptionStatus.ACTIVE
subscription.max_domains = tycoon_config.get("domain_limit", 500)
subscription.updated_at = datetime.utcnow()
await db.commit()
print("✅ Updated subscription to Tycoon (Active).")
else:
print(f"✅ Subscription: {subscription.tier.value} ({subscription.status.value})")
await db.refresh(user)
print("\n==================================================")
print("📋 FINAL STATUS:")
print(f" Email: {user.email}")
print(f" Password: {new_password}")
print(f" Name: {user.name}")
print(f" Admin: {'✅ Yes' if user.is_admin else '❌ No'}")
print(f" Verified: {'✅ Yes' if user.is_verified else '❌ No'}")
print(f" Active: {'✅ Yes' if user.is_active else '❌ No'}")
print("==================================================")
print("\n✅ Admin user is ready! You can now login.")
if __name__ == "__main__":
asyncio.run(reset_password())
asyncio.run(reset_admin_password())

View File

@ -21,10 +21,34 @@ import {
ChevronDown,
ChevronsUpDown,
Sparkles,
Diamond,
ShieldCheck,
Zap,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
interface MarketItem {
id: string
domain: string
tld: string
price: number
currency: string
price_type: 'bid' | 'fixed' | 'negotiable'
status: 'auction' | 'instant'
source: string
is_pounce: boolean
verified: boolean
time_remaining?: string
end_time?: string
num_bids?: number
slug?: string
seller_verified: boolean
url: string
is_external: boolean
pounce_score: number
}
interface Auction {
domain: string
platform: string
@ -122,6 +146,7 @@ export default function AuctionsPage() {
const [allAuctions, setAllAuctions] = useState<Auction[]>([])
const [endingSoon, setEndingSoon] = useState<Auction[]>([])
const [hotAuctions, setHotAuctions] = useState<Auction[]>([])
const [pounceItems, setPounceItems] = useState<MarketItem[]>([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<TabType>('all')
const [sortField, setSortField] = useState<SortField>('ending')
@ -139,14 +164,16 @@ export default function AuctionsPage() {
const loadAuctions = async () => {
setLoading(true)
try {
const [all, ending, hot] = await Promise.all([
const [all, ending, hot, pounce] = await Promise.all([
api.getAuctions(undefined, undefined, undefined, undefined, undefined, false, 'ending', 100, 0),
api.getEndingSoonAuctions(50),
api.getEndingSoonAuctions(24, 50), // 24 hours, limit 50
api.getHotAuctions(50),
api.getMarketFeed({ source: 'pounce', limit: 10 }).catch(() => ({ items: [] })),
])
setAllAuctions(all.auctions || [])
setEndingSoon(ending || [])
setHotAuctions(hot || [])
setPounceItems(pounce.items || [])
} catch (error) {
console.error('Failed to load auctions:', error)
} finally {
@ -296,6 +323,70 @@ export default function AuctionsPage() {
</div>
)}
{/* Pounce Direct Section - Featured */}
{pounceItems.length > 0 && (
<div className="mb-12 sm:mb-16 animate-slide-up">
<div className="flex items-center gap-3 mb-4 sm:mb-6">
<div className="flex items-center gap-2">
<Diamond className="w-5 h-5 text-accent fill-accent/20" />
<h2 className="text-body-lg sm:text-heading-sm font-medium text-foreground">
Pounce Exclusive
</h2>
</div>
<span className="text-ui-sm text-foreground-subtle">Verified Instant Buy 0% Commission</span>
</div>
<div className="border border-accent/20 rounded-xl overflow-hidden bg-gradient-to-br from-accent/5 to-transparent">
{pounceItems.map((item) => (
<Link
key={item.id}
href={item.url}
className="flex items-center justify-between px-5 py-4 border-b border-accent/10 last:border-b-0 hover:bg-accent/5 transition-all group"
>
<div className="flex items-center gap-4">
<Diamond className="w-5 h-5 text-accent fill-accent/20 flex-shrink-0" />
<div>
<div className="font-mono text-body font-medium text-foreground group-hover:text-accent transition-colors">
{item.domain}
</div>
<div className="flex items-center gap-2 mt-1">
{item.verified && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-accent bg-accent/10 px-2 py-0.5 rounded-full">
<ShieldCheck className="w-3 h-3" />
Verified
</span>
)}
<span className="text-ui-sm text-foreground-subtle">
Score: {item.pounce_score}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="font-mono text-body-lg font-medium text-foreground">
{formatCurrency(item.price, item.currency)}
</div>
<div className="text-ui-sm text-accent">Instant Buy</div>
</div>
<div className="flex items-center gap-2 px-4 py-2 bg-accent text-background rounded-lg text-ui-sm font-bold opacity-0 group-hover:opacity-100 transition-opacity">
Buy Now
<Zap className="w-3 h-3" />
</div>
</div>
</Link>
))}
</div>
<div className="mt-3 text-center">
<Link
href="/buy"
className="text-ui-sm text-accent hover:underline"
>
Browse all Pounce listings
</Link>
</div>
</div>
)}
{/* Hot Auctions Preview */}
{hotPreview.length > 0 && (
<div className="mb-12 sm:mb-16 animate-slide-up">

View File

@ -1,253 +1,25 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { TerminalLayout } from '@/components/TerminalLayout'
import {
Search,
Filter,
Clock,
TrendingUp,
Flame,
Sparkles,
ExternalLink,
ChevronDown,
Globe,
Gavel,
ArrowUpDown,
} from 'lucide-react'
import clsx from 'clsx'
type ViewType = 'all' | 'ending' | 'hot' | 'opportunities'
interface Auction {
domain: string
platform: string
current_bid: number
num_bids: number
end_time: string
time_remaining: string
affiliate_url: string
tld: string
}
export default function MarketPage() {
/**
* Redirect /market to /auctions
* This page is kept for backwards compatibility
*/
export default function MarketRedirect() {
const router = useRouter()
const { subscription } = useStore()
const [auctions, setAuctions] = useState<Auction[]>([])
const [loading, setLoading] = useState(true)
const [activeView, setActiveView] = useState<ViewType>('all')
const [searchQuery, setSearchQuery] = useState('')
const [platformFilter, setPlatformFilter] = useState<string>('all')
const [sortBy, setSortBy] = useState<string>('end_time')
useEffect(() => {
loadAuctions()
}, [activeView])
const loadAuctions = async () => {
setLoading(true)
try {
let data
switch (activeView) {
case 'ending':
data = await api.getEndingSoonAuctions(50)
break
case 'hot':
data = await api.getHotAuctions(50)
break
case 'opportunities':
const oppData = await api.getAuctionOpportunities()
data = (oppData.opportunities || []).map((o: any) => o.auction)
break
default:
const auctionData = await api.getAuctions(undefined, undefined, undefined, undefined, undefined, false, sortBy, 50)
data = auctionData.auctions || []
}
setAuctions(data)
} catch (error) {
console.error('Failed to load auctions:', error)
setAuctions([])
} finally {
setLoading(false)
}
}
// Filter auctions
const filteredAuctions = auctions.filter(auction => {
if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) {
return false
}
if (platformFilter !== 'all' && auction.platform !== platformFilter) {
return false
}
return true
})
const platforms = ['GoDaddy', 'Sedo', 'NameJet', 'DropCatch', 'ExpiredDomains']
const views = [
{ id: 'all', label: 'All Auctions', icon: Gavel },
{ id: 'ending', label: 'Ending Soon', icon: Clock },
{ id: 'hot', label: 'Hot', icon: Flame },
{ id: 'opportunities', label: 'Opportunities', icon: Sparkles },
]
router.replace('/auctions')
}, [router])
return (
<TerminalLayout
title="Market Scanner"
subtitle="Live auctions from all major platforms"
>
<div className="max-w-7xl mx-auto space-y-6">
{/* View Tabs */}
<div className="flex flex-wrap gap-2 p-1 bg-background-secondary/50 rounded-xl border border-border">
{views.map((view) => (
<button
key={view.id}
onClick={() => setActiveView(view.id as ViewType)}
className={clsx(
"flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-all",
activeView === view.id
? "bg-foreground text-background"
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
)}
>
<view.icon className="w-4 h-4" />
{view.label}
</button>
))}
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3">
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search domains..."
className="w-full h-10 pl-10 pr-4 bg-background-secondary border border-border rounded-lg
text-sm text-foreground placeholder:text-foreground-subtle
focus:outline-none focus:border-accent"
/>
</div>
{/* Platform Filter */}
<div className="relative">
<select
value={platformFilter}
onChange={(e) => setPlatformFilter(e.target.value)}
className="h-10 pl-4 pr-10 bg-background-secondary border border-border rounded-lg
text-sm text-foreground appearance-none cursor-pointer
focus:outline-none focus:border-accent"
>
<option value="all">All Platforms</option>
{platforms.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted pointer-events-none" />
</div>
{/* Sort */}
<div className="relative">
<select
value={sortBy}
onChange={(e) => {
setSortBy(e.target.value)
loadAuctions()
}}
className="h-10 pl-4 pr-10 bg-background-secondary border border-border rounded-lg
text-sm text-foreground appearance-none cursor-pointer
focus:outline-none focus:border-accent"
>
<option value="end_time">Ending Soon</option>
<option value="bid_asc">Price: Low to High</option>
<option value="bid_desc">Price: High to Low</option>
<option value="bids">Most Bids</option>
</select>
<ArrowUpDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted pointer-events-none" />
</div>
</div>
{/* Stats Bar */}
<div className="flex items-center gap-6 text-sm text-foreground-muted">
<span>{filteredAuctions.length} auctions</span>
<span></span>
<span className="flex items-center gap-1.5">
<Globe className="w-3.5 h-3.5" />
{platforms.length} platforms
</span>
</div>
{/* Auction List */}
{loading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-20 bg-background-secondary/50 border border-border rounded-xl animate-pulse" />
))}
</div>
) : filteredAuctions.length === 0 ? (
<div className="text-center py-16 bg-background-secondary/30 border border-border rounded-xl">
<Gavel className="w-12 h-12 text-foreground-subtle mx-auto mb-4" />
<p className="text-foreground-muted">No auctions found</p>
<p className="text-sm text-foreground-subtle mt-1">Try adjusting your filters</p>
</div>
) : (
<div className="space-y-3">
{filteredAuctions.map((auction, idx) => (
<div
key={`${auction.domain}-${idx}`}
className="group p-4 sm:p-5 bg-background-secondary/50 border border-border rounded-xl
hover:border-foreground/20 transition-all"
>
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
{/* Domain Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="text-lg font-semibold text-foreground truncate">{auction.domain}</h3>
<span className="shrink-0 px-2 py-0.5 bg-foreground/5 text-foreground-muted text-xs rounded">
{auction.platform}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-foreground-muted">
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
{auction.time_remaining}
</span>
<span>{auction.num_bids} bids</span>
</div>
</div>
{/* Price + Action */}
<div className="flex items-center gap-4 shrink-0">
<div className="text-right">
<p className="text-xl font-semibold text-foreground">${auction.current_bid.toLocaleString()}</p>
<p className="text-xs text-foreground-subtle">Current bid</p>
</div>
<a
href={auction.affiliate_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2.5 bg-foreground text-background rounded-lg
font-medium text-sm hover:bg-foreground/90 transition-colors"
>
Bid
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</div>
</div>
))}
</div>
)}
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-foreground-muted">Redirecting to Market...</p>
</div>
</TerminalLayout>
</div>
)
}

View File

@ -1,6 +1,6 @@
'use client'
import { useEffect, useState, useMemo, useCallback } from 'react'
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { TerminalLayout } from '@/components/TerminalLayout'
@ -27,101 +27,48 @@ import {
SlidersHorizontal,
MoreHorizontal,
Eye,
Info
Info,
ShieldCheck,
Sparkles,
Store
} from 'lucide-react'
import clsx from 'clsx'
import Link from 'next/link'
// ============================================================================
// TYPES
// ============================================================================
interface Auction {
domain: string
platform: string
platform_url?: string
current_bid: number
currency?: string
num_bids: number
time_remaining: string
end_time: string
buy_now_price?: number | null
reserve_met?: boolean | null
traffic?: number | null
tld: string
affiliate_url: string
age_years?: number | null
}
interface MarketItem {
id: string
domain: string
pounceScore: number
price: number
priceType: 'bid' | 'fixed'
status: 'auction' | 'instant'
timeLeft?: string
endTime?: string
source: 'GoDaddy' | 'Sedo' | 'NameJet' | 'DropCatch' | 'Pounce'
isPounce: boolean
verified?: boolean
affiliateUrl?: string
tld: string
numBids?: number
price: number
currency: string
price_type: 'bid' | 'fixed' | 'negotiable'
status: 'auction' | 'instant'
source: string
is_pounce: boolean
verified: boolean
time_remaining?: string
end_time?: string
num_bids?: number
slug?: string
seller_verified: boolean
url: string
is_external: boolean
pounce_score: number
}
type SortField = 'domain' | 'score' | 'price' | 'time' | 'source'
type SortDirection = 'asc' | 'desc'
type SourceFilter = 'all' | 'pounce' | 'external'
type PriceRange = 'all' | 'low' | 'mid' | 'high'
// ============================================================================
// POUNCE SCORE ALGORITHM
// HELPER FUNCTIONS
// ============================================================================
function calculatePounceScore(domain: string, tld: string, numBids?: number, ageYears?: number): number {
let score = 50
const name = domain.split('.')[0]
// Length bonus
if (name.length <= 3) score += 30
else if (name.length === 4) score += 25
else if (name.length === 5) score += 20
else if (name.length <= 7) score += 10
else if (name.length <= 10) score += 5
else score -= 5
// Premium TLD bonus
if (['com', 'ai', 'io'].includes(tld)) score += 15
else if (['co', 'net', 'org', 'ch', 'de'].includes(tld)) score += 10
else if (['app', 'dev', 'xyz'].includes(tld)) score += 5
// Age bonus
if (ageYears && ageYears > 15) score += 10
else if (ageYears && ageYears > 10) score += 7
else if (ageYears && ageYears > 5) score += 3
// Activity bonus
if (numBids && numBids >= 20) score += 8
else if (numBids && numBids >= 10) score += 5
else if (numBids && numBids >= 5) score += 2
// Penalties
if (name.includes('-')) score -= 25
if (/\d/.test(name) && name.length > 3) score -= 20
if (name.length > 15) score -= 15
if (/(.)\1{2,}/.test(name)) score -= 10
return Math.max(0, Math.min(100, score))
}
function isSpamDomain(domain: string, tld: string): boolean {
const name = domain.split('.')[0]
if (name.includes('-')) return true
if (/\d/.test(name) && name.length > 4) return true
if (name.length > 20) return true
if (!['com', 'ai', 'io', 'co', 'net', 'org', 'ch', 'de', 'app', 'dev', 'xyz', 'tech', 'cloud'].includes(tld)) return true
return false
}
// Parse time remaining to seconds
function parseTimeToSeconds(timeStr?: string): number {
if (!timeStr) return Infinity
let seconds = 0
@ -134,62 +81,68 @@ function parseTimeToSeconds(timeStr?: string): number {
return seconds || Infinity
}
function formatPrice(price: number, currency = 'USD'): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
maximumFractionDigits: 0
}).format(price)
}
// ============================================================================
// COMPONENTS
// ============================================================================
// Tooltip Component
function Tooltip({ children, content }: { children: React.ReactNode; content: string }) {
return (
<div className="relative flex items-center group/tooltip w-fit">
{children}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-zinc-900 border border-zinc-800 rounded text-[10px] text-zinc-300 whitespace-nowrap opacity-0 group-hover/tooltip:opacity-100 transition-opacity pointer-events-none z-50 shadow-xl">
{content}
{/* Arrow */}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-zinc-800" />
</div>
// Tooltip
const Tooltip = memo(({ children, content }: { children: React.ReactNode; content: string }) => (
<div className="relative flex items-center group/tooltip w-fit">
{children}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-zinc-900 border border-zinc-800 rounded text-[10px] text-zinc-300 whitespace-nowrap opacity-0 group-hover/tooltip:opacity-100 transition-opacity pointer-events-none z-50 shadow-xl">
{content}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-zinc-800" />
</div>
)
}
</div>
))
Tooltip.displayName = 'Tooltip'
// Stat Card
function StatCard({
const StatCard = memo(({
label,
value,
subValue,
icon: Icon,
trend
highlight
}: {
label: string
value: string | number
subValue?: string
icon: any
trend?: 'up' | 'down' | 'neutral'
}) {
return (
<div className="bg-zinc-900/40 border border-white/5 rounded-xl p-4 flex items-start justify-between hover:bg-white/[0.02] transition-colors relative overflow-hidden group">
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative z-10">
<p className="text-[11px] font-semibold text-zinc-500 uppercase tracking-wider mb-1">{label}</p>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-white tracking-tight">{value}</span>
{subValue && <span className="text-xs text-zinc-500 font-medium">{subValue}</span>}
</div>
</div>
<div className={clsx(
"relative z-10 p-2 rounded-lg bg-zinc-800/50 transition-colors",
trend === 'up' && "text-emerald-400 bg-emerald-500/10",
trend === 'down' && "text-red-400 bg-red-500/10",
trend === 'neutral' && "text-zinc-400"
)}>
<Icon className="w-4 h-4" />
icon: React.ElementType
highlight?: boolean
}) => (
<div className={clsx(
"bg-zinc-900/40 border rounded-xl p-4 flex items-start justify-between hover:bg-white/[0.02] transition-colors relative overflow-hidden group",
highlight ? "border-emerald-500/30" : "border-white/5"
)}>
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative z-10">
<p className="text-[11px] font-semibold text-zinc-500 uppercase tracking-wider mb-1">{label}</p>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-white tracking-tight">{value}</span>
{subValue && <span className="text-xs text-zinc-500 font-medium">{subValue}</span>}
</div>
</div>
)
}
<div className={clsx(
"relative z-10 p-2 rounded-lg transition-colors",
highlight ? "text-emerald-400 bg-emerald-500/10" : "text-zinc-400 bg-zinc-800/50"
)}>
<Icon className="w-4 h-4" />
</div>
</div>
))
StatCard.displayName = 'StatCard'
// Score Ring (Desktop) / Badge (Mobile)
function ScoreDisplay({ score, mobile = false }: { score: number; mobile?: boolean }) {
// Score Ring
const ScoreDisplay = memo(({ score, mobile = false }: { score: number; mobile?: boolean }) => {
const color = score >= 80 ? 'text-emerald-500' : score >= 50 ? 'text-amber-500' : 'text-zinc-600'
if (mobile) {
@ -234,31 +187,43 @@ function ScoreDisplay({ score, mobile = false }: { score: number; mobile?: boole
</div>
</Tooltip>
)
}
})
ScoreDisplay.displayName = 'ScoreDisplay'
// Minimal Toggle
function FilterToggle({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) {
return (
<button
onClick={onClick}
className={clsx(
"px-4 py-1.5 rounded-full text-xs font-medium transition-all border whitespace-nowrap",
active
? "bg-white text-black border-white shadow-[0_0_10px_rgba(255,255,255,0.1)]"
: "bg-transparent text-zinc-400 border-zinc-800 hover:border-zinc-700 hover:text-zinc-300"
)}
>
{label}
</button>
)
}
// Filter Toggle
const FilterToggle = memo(({ active, onClick, label, icon: Icon }: {
active: boolean
onClick: () => void
label: string
icon?: React.ElementType
}) => (
<button
onClick={onClick}
className={clsx(
"flex items-center gap-1.5 px-4 py-1.5 rounded-full text-xs font-medium transition-all border whitespace-nowrap",
active
? "bg-white text-black border-white shadow-[0_0_10px_rgba(255,255,255,0.1)]"
: "bg-transparent text-zinc-400 border-zinc-800 hover:border-zinc-700 hover:text-zinc-300"
)}
>
{Icon && <Icon className="w-3 h-3" />}
{label}
</button>
))
FilterToggle.displayName = 'FilterToggle'
// Sort Header
function SortableHeader({
const SortableHeader = memo(({
label, field, currentSort, currentDirection, onSort, align = 'left', tooltip
}: {
label: string; field: SortField; currentSort: SortField; currentDirection: SortDirection; onSort: (field: SortField) => void; align?: 'left'|'center'|'right'; tooltip?: string
}) {
label: string
field: SortField
currentSort: SortField
currentDirection: SortDirection
onSort: (field: SortField) => void
align?: 'left'|'center'|'right'
tooltip?: string
}) => {
const isActive = currentSort === field
return (
<div className={clsx(
@ -286,7 +251,31 @@ function SortableHeader({
)}
</div>
)
}
})
SortableHeader.displayName = 'SortableHeader'
// Pounce Direct Badge
const PounceBadge = memo(({ verified }: { verified: boolean }) => (
<div className={clsx(
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide",
verified
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
: "bg-amber-500/10 text-amber-400 border border-amber-500/20"
)}>
{verified ? (
<>
<ShieldCheck className="w-3 h-3" />
Verified
</>
) : (
<>
<Diamond className="w-3 h-3" />
Pounce
</>
)}
</div>
))
PounceBadge.displayName = 'PounceBadge'
// ============================================================================
// MAIN PAGE
@ -296,15 +285,16 @@ export default function MarketPage() {
const { subscription } = useStore()
// Data
const [auctions, setAuctions] = useState<Auction[]>([])
const [items, setItems] = useState<MarketItem[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [stats, setStats] = useState({ total: 0, pounceCount: 0, auctionCount: 0, highScore: 0 })
// Filters
const [hideSpam, setHideSpam] = useState(true)
const [pounceOnly, setPounceOnly] = useState(false)
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
const [searchQuery, setSearchQuery] = useState('')
const [priceRange, setPriceRange] = useState<'all' | 'low' | 'mid' | 'high'>('all')
const [priceRange, setPriceRange] = useState<PriceRange>('all')
const [verifiedOnly, setVerifiedOnly] = useState(false)
// Sort
const [sortField, setSortField] = useState<SortField>('score')
@ -314,18 +304,36 @@ export default function MarketPage() {
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set())
const [trackingInProgress, setTrackingInProgress] = useState<string | null>(null)
// Load
// Load data
const loadData = useCallback(async () => {
setLoading(true)
try {
const data = await api.getAuctions()
setAuctions(data.auctions || [])
const result = await api.getMarketFeed({
source: sourceFilter,
keyword: searchQuery || undefined,
minPrice: priceRange === 'low' ? undefined : priceRange === 'mid' ? 100 : priceRange === 'high' ? 1000 : undefined,
maxPrice: priceRange === 'low' ? 100 : priceRange === 'mid' ? 1000 : undefined,
verifiedOnly,
sortBy: sortField === 'score' ? 'score' :
sortField === 'price' ? (sortDirection === 'asc' ? 'price_asc' : 'price_desc') :
sortField === 'time' ? 'time' : 'newest',
limit: 100
})
setItems(result.items || [])
setStats({
total: result.total,
pounceCount: result.pounce_direct_count,
auctionCount: result.auction_count,
highScore: (result.items || []).filter(i => i.pounce_score >= 80).length
})
} catch (error) {
console.error('Failed to load market data:', error)
setItems([])
} finally {
setLoading(false)
}
}, [])
}, [sourceFilter, searchQuery, priceRange, verifiedOnly, sortField, sortDirection])
useEffect(() => { loadData() }, [loadData])
@ -336,8 +344,9 @@ export default function MarketPage() {
}, [loadData])
const handleSort = useCallback((field: SortField) => {
if (sortField === field) setSortDirection(d => d === 'asc' ? 'desc' : 'asc')
else {
if (sortField === field) {
setSortDirection(d => d === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortDirection(field === 'score' || field === 'price' ? 'desc' : 'asc')
}
@ -349,81 +358,68 @@ export default function MarketPage() {
try {
await api.addDomain(domain)
setTrackedDomains(prev => new Set([...Array.from(prev), domain]))
} catch (error) { console.error(error) } finally { setTrackingInProgress(null) }
} catch (error) {
console.error(error)
} finally {
setTrackingInProgress(null)
}
}, [trackedDomains, trackingInProgress])
// Transform & Filter
const marketItems = useMemo(() => {
const items: MarketItem[] = auctions.map(auction => ({
id: `${auction.domain}-${auction.platform}`,
domain: auction.domain,
pounceScore: calculatePounceScore(auction.domain, auction.tld, auction.num_bids, auction.age_years ?? undefined),
price: auction.current_bid,
priceType: 'bid',
status: 'auction',
timeLeft: auction.time_remaining,
endTime: auction.end_time,
source: auction.platform as any,
isPounce: false,
affiliateUrl: auction.affiliate_url,
tld: auction.tld,
numBids: auction.num_bids,
}))
// Client-side filtering for immediate UI feedback
const filteredItems = useMemo(() => {
let filtered = items
if (hideSpam) filtered = filtered.filter(item => !isSpamDomain(item.domain, item.tld))
if (pounceOnly) filtered = filtered.filter(item => item.isPounce)
if (priceRange === 'low') filtered = filtered.filter(item => item.price < 100)
if (priceRange === 'mid') filtered = filtered.filter(item => item.price >= 100 && item.price < 1000)
if (priceRange === 'high') filtered = filtered.filter(item => item.price >= 1000)
if (searchQuery) filtered = filtered.filter(item => item.domain.toLowerCase().includes(searchQuery.toLowerCase()))
// Additional client-side search (API already filters, but this is for instant feedback)
if (searchQuery && !loading) {
const query = searchQuery.toLowerCase()
filtered = filtered.filter(item => item.domain.toLowerCase().includes(query))
}
// Sort
const mult = sortDirection === 'asc' ? 1 : -1
filtered.sort((a, b) => {
filtered = [...filtered].sort((a, b) => {
// Pounce Direct always appears first within same score tier
if (a.is_pounce !== b.is_pounce && sortField === 'score') {
return a.is_pounce ? -1 : 1
}
switch (sortField) {
case 'domain': return mult * a.domain.localeCompare(b.domain)
case 'score': return mult * (a.pounceScore - b.pounceScore)
case 'score': return mult * (a.pounce_score - b.pounce_score)
case 'price': return mult * (a.price - b.price)
case 'time': return mult * (parseTimeToSeconds(a.timeLeft) - parseTimeToSeconds(b.timeLeft))
case 'time': return mult * (parseTimeToSeconds(a.time_remaining) - parseTimeToSeconds(b.time_remaining))
case 'source': return mult * a.source.localeCompare(b.source)
default: return 0
}
})
return filtered
}, [auctions, hideSpam, pounceOnly, priceRange, searchQuery, sortField, sortDirection])
}, [items, searchQuery, sortField, sortDirection, loading])
// Stats
const stats = useMemo(() => ({
total: marketItems.length,
highScore: marketItems.filter(i => i.pounceScore >= 80).length,
endingSoon: marketItems.filter(i => parseTimeToSeconds(i.timeLeft) < 3600).length,
avgPrice: marketItems.length > 0 ? Math.round(marketItems.reduce((acc, i) => acc + i.price, 0) / marketItems.length) : 0
}), [marketItems])
const formatPrice = (price: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(price)
// Separate Pounce Direct from external
const pounceItems = useMemo(() => filteredItems.filter(i => i.is_pounce), [filteredItems])
const externalItems = useMemo(() => filteredItems.filter(i => !i.is_pounce), [filteredItems])
return (
<TerminalLayout
title="Market"
subtitle="Global Domain Opportunities"
subtitle="Pounce Direct + Global Auctions"
hideHeaderSearch={true}
>
<div className="relative">
{/* Page-specific emerald glow (mirrors landing page look) */}
{/* Ambient glow */}
<div className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute -top-72 left-1/2 -translate-x-1/2 w-[1200px] h-[900px] bg-emerald-500/8 blur-[160px]" />
<div className="absolute bottom-[-240px] right-[-160px] w-[720px] h-[720px] bg-emerald-500/6 blur-[140px]" />
<div className="absolute bottom-[-180px] left-[-120px] w-[520px] h-[520px] bg-emerald-500/5 blur-[120px]" />
</div>
<div className="space-y-6 pb-20 md:pb-0 relative">
{/* METRICS */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label="Active" value={stats.total} icon={Activity} trend="neutral" />
<StatCard label="Top Tier" value={stats.highScore} subValue="80+ Score" icon={TrendingUp} trend="up" />
<StatCard label="Ending Soon" value={stats.endingSoon} subValue="< 1h" icon={Flame} trend={stats.endingSoon > 5 ? 'down' : 'neutral'} />
<StatCard label="Avg Price" value={formatPrice(stats.avgPrice)} icon={Zap} trend="neutral" />
<StatCard label="Total" value={stats.total} icon={Activity} />
<StatCard label="Pounce Direct" value={stats.pounceCount} subValue="💎 Exclusive" icon={Diamond} highlight={stats.pounceCount > 0} />
<StatCard label="External" value={stats.auctionCount} icon={Store} />
<StatCard label="Top Tier" value={stats.highScore} subValue="80+ Score" icon={TrendingUp} />
</div>
{/* CONTROLS */}
@ -444,17 +440,38 @@ export default function MarketPage() {
</div>
{/* Filters */}
<div className="flex items-center gap-2 overflow-x-auto w-full pb-1 md:pb-0 scrollbar-hide mask-fade-right">
<FilterToggle active={hideSpam} onClick={() => setHideSpam(!hideSpam)} label="No Spam" />
<FilterToggle active={pounceOnly} onClick={() => setPounceOnly(!pounceOnly)} label="Pounce Exclusive" />
<div className="flex items-center gap-2 overflow-x-auto w-full pb-1 md:pb-0 scrollbar-hide">
<FilterToggle
active={sourceFilter === 'pounce'}
onClick={() => setSourceFilter(f => f === 'pounce' ? 'all' : 'pounce')}
label="Pounce Only"
icon={Diamond}
/>
<FilterToggle
active={verifiedOnly}
onClick={() => setVerifiedOnly(!verifiedOnly)}
label="Verified"
icon={ShieldCheck}
/>
<div className="w-px h-5 bg-white/10 mx-2 flex-shrink-0" />
<FilterToggle active={priceRange === 'low'} onClick={() => setPriceRange(p => p === 'low' ? 'all' : 'low')} label="< $100" />
<FilterToggle active={priceRange === 'high'} onClick={() => setPriceRange(p => p === 'high' ? 'all' : 'high')} label="$1k+" />
<FilterToggle
active={priceRange === 'low'}
onClick={() => setPriceRange(p => p === 'low' ? 'all' : 'low')}
label="< $100"
/>
<FilterToggle
active={priceRange === 'high'}
onClick={() => setPriceRange(p => p === 'high' ? 'all' : 'high')}
label="$1k+"
/>
</div>
<div className="hidden md:block flex-1" />
<button onClick={handleRefresh} className="hidden md:flex items-center gap-2 text-xs font-medium text-zinc-500 hover:text-white transition-colors">
<button
onClick={handleRefresh}
className="hidden md:flex items-center gap-2 text-xs font-medium text-zinc-500 hover:text-white transition-colors"
>
<RefreshCw className={clsx("w-3.5 h-3.5", refreshing && "animate-spin")} />
Refresh
</button>
@ -468,7 +485,7 @@ export default function MarketPage() {
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
<p className="text-zinc-500 text-sm animate-pulse">Scanning markets...</p>
</div>
) : marketItems.length === 0 ? (
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-32 text-center">
<div className="w-16 h-16 bg-zinc-900 rounded-full flex items-center justify-center mb-4 border border-zinc-800">
<Search className="w-6 h-6 text-zinc-600" />
@ -477,151 +494,242 @@ export default function MarketPage() {
<p className="text-zinc-500 text-sm">Try adjusting your filters</p>
</div>
) : (
<>
{/* DESKTOP TABLE */}
<div className="hidden md:block border border-white/5 rounded-xl overflow-hidden bg-zinc-900/40 backdrop-blur-sm shadow-xl">
<div className="grid grid-cols-12 gap-4 px-6 py-3 border-b border-white/5 bg-white/[0.02]">
<div className="col-span-4"><SortableHeader label="Domain Asset" field="domain" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} tooltip="The domain name being auctioned" /></div>
<div className="col-span-2 text-center"><SortableHeader label="Score" field="score" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" tooltip="Pounce Score: AI-calculated value based on length, TLD, and demand" /></div>
<div className="col-span-2 text-right"><SortableHeader label="Price" field="price" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="right" tooltip="Current highest bid or buy-now price" /></div>
<div className="col-span-2 text-center"><SortableHeader label="Time" field="time" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" tooltip="Time remaining until auction ends" /></div>
<div className="col-span-2 text-right"><span className="text-[10px] font-bold uppercase tracking-widest text-zinc-600 py-2 block">Action</span></div>
</div>
<div className="divide-y divide-white/5">
{marketItems.map((item) => {
const timeLeftSec = parseTimeToSeconds(item.timeLeft)
const isUrgent = timeLeftSec < 3600
return (
<div key={item.id} className="grid grid-cols-12 gap-4 px-6 py-4 items-center hover:bg-white/[0.04] transition-all group relative">
<div className="space-y-8">
{/* POUNCE DIRECT SECTION (if any) */}
{pounceItems.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-3 px-2">
<div className="flex items-center gap-2 text-emerald-400">
<Diamond className="w-4 h-4 fill-emerald-400/20" />
<span className="text-xs font-bold uppercase tracking-widest">Pounce Direct</span>
</div>
<span className="text-[10px] text-zinc-500">Verified Instant Buy 0% Commission</span>
<div className="flex-1 h-px bg-gradient-to-r from-emerald-500/20 to-transparent" />
</div>
<div className="border border-emerald-500/20 rounded-xl overflow-hidden bg-gradient-to-br from-emerald-500/5 to-transparent">
{pounceItems.map((item) => (
<div
key={item.id}
className="grid grid-cols-12 gap-4 px-6 py-4 items-center border-b border-emerald-500/10 last:border-b-0 hover:bg-emerald-500/5 transition-all group"
>
{/* Domain */}
<div className="col-span-4">
<div className="col-span-5">
<div className="flex items-center gap-3">
{item.isPounce && (
<Tooltip content="Pounce Exclusive Inventory">
<Diamond className="w-4 h-4 text-emerald-400 fill-emerald-400/20" />
</Tooltip>
)}
<Diamond className="w-4 h-4 text-emerald-400 fill-emerald-400/20 flex-shrink-0" />
<div>
<div className="font-medium text-white text-[15px] tracking-tight cursor-default">{item.domain}</div>
<Tooltip content={`Source: ${item.source}`}>
<div className="text-[11px] text-zinc-500 mt-0.5 w-fit hover:text-zinc-300 cursor-help transition-colors">{item.source}</div>
</Tooltip>
<div className="font-medium text-white text-[15px] tracking-tight">{item.domain}</div>
<div className="flex items-center gap-2 mt-0.5">
<PounceBadge verified={item.verified} />
</div>
</div>
</div>
</div>
{/* Score */}
<div className="col-span-2 flex justify-center"><ScoreDisplay score={item.pounceScore} /></div>
<div className="col-span-2 flex justify-center">
<ScoreDisplay score={item.pounce_score} />
</div>
{/* Price */}
<div className="col-span-2 text-right">
<Tooltip content={`${item.numBids || 0} bids placed`}>
<div className="cursor-help">
<div className="font-mono text-white font-medium">{formatPrice(item.price)}</div>
{item.numBids !== undefined && item.numBids > 0 && <div className="text-[10px] text-zinc-500 mt-0.5">{item.numBids} bids</div>}
</div>
</Tooltip>
<div className="font-mono text-white font-medium">{formatPrice(item.price, item.currency)}</div>
<div className="text-[10px] text-emerald-400 mt-0.5">Instant Buy</div>
</div>
{/* Time */}
<div className="col-span-2 flex justify-center">
<Tooltip content="Auction ends soon">
<div className={clsx("flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium cursor-help", isUrgent ? "text-red-400 bg-red-500/10" : "text-zinc-400 bg-zinc-800/50")}>
<Clock className="w-3 h-3" />
{item.timeLeft}
</div>
</Tooltip>
</div>
{/* Actions */}
<div className="col-span-2 flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity">
{/* Monitor Button - Distinct Style & Spacing */}
<Tooltip content={trackedDomains.has(item.domain) ? "Already tracking" : "Add to Watchlist"}>
{/* Action */}
<div className="col-span-3 flex items-center justify-end gap-3 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip content="Add to Watchlist">
<button
onClick={() => handleTrack(item.domain)}
disabled={trackedDomains.has(item.domain)}
onClick={() => handleTrack(item.domain)}
disabled={trackedDomains.has(item.domain)}
className={clsx(
"w-8 h-8 flex items-center justify-center rounded-full border transition-all mr-4", // Added margin-right for separation
"w-8 h-8 flex items-center justify-center rounded-full border transition-all",
trackedDomains.has(item.domain)
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20 cursor-default"
: "border-zinc-700 bg-zinc-900 text-zinc-400 hover:text-white hover:border-zinc-500 hover:scale-105 active:scale-95"
: "border-zinc-700 bg-zinc-900 text-zinc-400 hover:text-white hover:border-zinc-500 hover:scale-105"
)}
>
{trackedDomains.has(item.domain) ? <Check className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</Tooltip>
{/* Buy Button */}
<Tooltip content={item.isPounce ? "Buy Instantly" : "Place Bid on External Site"}>
<a href={item.affiliateUrl || '#'} target="_blank" rel="noopener noreferrer" className="h-9 px-4 flex items-center gap-2 bg-white text-zinc-950 rounded-lg text-xs font-bold hover:bg-zinc-200 transition-all hover:scale-105 active:scale-95 shadow-lg shadow-white/5">
{item.isPounce ? 'Buy Now' : 'Place Bid'}
<ExternalLink className="w-3 h-3" />
</a>
</Tooltip>
<Link
href={item.url}
className="h-9 px-5 flex items-center gap-2 bg-emerald-500 text-white rounded-lg text-xs font-bold hover:bg-emerald-400 transition-all hover:scale-105 shadow-lg shadow-emerald-500/20"
>
Buy Now
<Zap className="w-3 h-3" />
</Link>
</div>
</div>
)
})}
))}
</div>
</div>
</div>
{/* MOBILE CARDS */}
<div className="md:hidden space-y-3">
{marketItems.map((item) => {
const timeLeftSec = parseTimeToSeconds(item.timeLeft)
const isUrgent = timeLeftSec < 3600
return (
<div key={item.id} className="bg-zinc-900/40 border border-white/5 rounded-xl p-4 active:bg-zinc-900/60 transition-colors">
<div className="flex justify-between items-start mb-3">
<div className="flex items-center gap-2">
{item.isPounce && <Diamond className="w-3.5 h-3.5 text-emerald-400 fill-emerald-400/20" />}
<span className="font-medium text-white text-base">{item.domain}</span>
</div>
<ScoreDisplay score={item.pounceScore} mobile />
)}
{/* EXTERNAL AUCTIONS */}
{externalItems.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-3 px-2">
<div className="flex items-center gap-2 text-zinc-400">
<Store className="w-4 h-4" />
<span className="text-xs font-bold uppercase tracking-widest">External Auctions</span>
</div>
<span className="text-[10px] text-zinc-500">{externalItems.length} from global platforms</span>
<div className="flex-1 h-px bg-gradient-to-r from-zinc-700/50 to-transparent" />
</div>
{/* Desktop Table */}
<div className="hidden md:block border border-white/5 rounded-xl overflow-hidden bg-zinc-900/40 backdrop-blur-sm">
<div className="grid grid-cols-12 gap-4 px-6 py-3 border-b border-white/5 bg-white/[0.02]">
<div className="col-span-4">
<SortableHeader label="Domain" field="domain" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} />
</div>
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-[10px] text-zinc-500 uppercase tracking-wider mb-0.5">Current Bid</div>
<div className="font-mono text-lg font-medium text-white">{formatPrice(item.price)}</div>
</div>
<div className="text-right">
<div className="text-[10px] text-zinc-500 uppercase tracking-wider mb-0.5">Ends In</div>
<div className={clsx("flex items-center gap-1.5 justify-end font-medium", isUrgent ? "text-red-400" : "text-zinc-400")}>
<Clock className="w-3 h-3" />
{item.timeLeft}
</div>
</div>
<div className="col-span-2 text-center">
<SortableHeader label="Score" field="score" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" tooltip="Pounce Score based on length, TLD, and demand" />
</div>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => handleTrack(item.domain)}
disabled={trackedDomains.has(item.domain)}
className={clsx(
"flex items-center justify-center gap-2 py-3 rounded-xl text-sm font-medium border transition-all",
trackedDomains.has(item.domain)
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
: "bg-zinc-800/30 text-zinc-400 border-zinc-700/50 active:scale-95"
)}
>
{trackedDomains.has(item.domain) ? (
<><Check className="w-4 h-4" /> Tracked</>
) : (
<><Eye className="w-4 h-4" /> Watch</>
)}
</button>
<a
href={item.affiliateUrl || '#'}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 py-3 rounded-xl text-sm font-bold bg-white text-black hover:bg-zinc-200 active:scale-95 transition-all shadow-lg shadow-white/5"
>
{item.isPounce ? 'Buy Now' : 'Place Bid'}
<ExternalLink className="w-3 h-3 opacity-50" />
</a>
<div className="col-span-2 text-right">
<SortableHeader label="Price" field="price" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="right" />
</div>
<div className="col-span-2 text-center">
<SortableHeader label="Time" field="time" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" />
</div>
<div className="col-span-2 text-right">
<span className="text-[10px] font-bold uppercase tracking-widest text-zinc-600 py-2 block">Action</span>
</div>
</div>
)
})}
</div>
</>
<div className="divide-y divide-white/5">
{externalItems.map((item) => {
const timeLeftSec = parseTimeToSeconds(item.time_remaining)
const isUrgent = timeLeftSec < 3600
return (
<div key={item.id} className="grid grid-cols-12 gap-4 px-6 py-4 items-center hover:bg-white/[0.04] transition-all group">
{/* Domain */}
<div className="col-span-4">
<div className="font-medium text-white text-[15px] tracking-tight">{item.domain}</div>
<div className="text-[11px] text-zinc-500 mt-0.5">{item.source}</div>
</div>
{/* Score */}
<div className="col-span-2 flex justify-center">
<ScoreDisplay score={item.pounce_score} />
</div>
{/* Price */}
<div className="col-span-2 text-right">
<div className="font-mono text-white font-medium">{formatPrice(item.price, item.currency)}</div>
{item.num_bids !== undefined && item.num_bids > 0 && (
<div className="text-[10px] text-zinc-500 mt-0.5">{item.num_bids} bids</div>
)}
</div>
{/* Time */}
<div className="col-span-2 flex justify-center">
<div className={clsx(
"flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium",
isUrgent ? "text-red-400 bg-red-500/10" : "text-zinc-400 bg-zinc-800/50"
)}>
<Clock className="w-3 h-3" />
{item.time_remaining || 'N/A'}
</div>
</div>
{/* Actions */}
<div className="col-span-2 flex items-center justify-end gap-3 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip content="Add to Watchlist">
<button
onClick={() => handleTrack(item.domain)}
disabled={trackedDomains.has(item.domain)}
className={clsx(
"w-8 h-8 flex items-center justify-center rounded-full border transition-all",
trackedDomains.has(item.domain)
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20 cursor-default"
: "border-zinc-700 bg-zinc-900 text-zinc-400 hover:text-white hover:border-zinc-500 hover:scale-105"
)}
>
{trackedDomains.has(item.domain) ? <Check className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</Tooltip>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="h-9 px-4 flex items-center gap-2 bg-white text-zinc-950 rounded-lg text-xs font-bold hover:bg-zinc-200 transition-all hover:scale-105"
>
Place Bid
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
)
})}
</div>
</div>
{/* Mobile Cards */}
<div className="md:hidden space-y-3">
{externalItems.map((item) => {
const timeLeftSec = parseTimeToSeconds(item.time_remaining)
const isUrgent = timeLeftSec < 3600
return (
<div key={item.id} className="bg-zinc-900/40 border border-white/5 rounded-xl p-4">
<div className="flex justify-between items-start mb-3">
<span className="font-medium text-white text-base">{item.domain}</span>
<ScoreDisplay score={item.pounce_score} mobile />
</div>
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-[10px] text-zinc-500 uppercase tracking-wider mb-0.5">Current Bid</div>
<div className="font-mono text-lg font-medium text-white">{formatPrice(item.price, item.currency)}</div>
</div>
<div className="text-right">
<div className="text-[10px] text-zinc-500 uppercase tracking-wider mb-0.5">Ends In</div>
<div className={clsx("flex items-center gap-1.5 justify-end font-medium", isUrgent ? "text-red-400" : "text-zinc-400")}>
<Clock className="w-3 h-3" />
{item.time_remaining || 'N/A'}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => handleTrack(item.domain)}
disabled={trackedDomains.has(item.domain)}
className={clsx(
"flex items-center justify-center gap-2 py-3 rounded-xl text-sm font-medium border transition-all",
trackedDomains.has(item.domain)
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
: "bg-zinc-800/30 text-zinc-400 border-zinc-700/50 active:scale-95"
)}
>
{trackedDomains.has(item.domain) ? (
<><Check className="w-4 h-4" /> Tracked</>
) : (
<><Eye className="w-4 h-4" /> Watch</>
)}
</button>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 py-3 rounded-xl text-sm font-bold bg-white text-black active:scale-95 transition-all"
>
Place Bid
<ExternalLink className="w-3 h-3 opacity-50" />
</a>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
)}
</div>
</div>

View File

@ -572,6 +572,69 @@ class ApiClient {
return this.request<DomainValuation>(`/portfolio/valuation/${domain}`)
}
// ============== Market Feed (Unified) ==============
/**
* Get unified market feed combining Pounce Direct listings + external auctions.
* This is the main feed for the Market page.
*/
async getMarketFeed(options: {
source?: 'all' | 'pounce' | 'external'
keyword?: string
tld?: string
minPrice?: number
maxPrice?: number
minScore?: number
endingWithin?: number
verifiedOnly?: boolean
sortBy?: 'score' | 'price_asc' | 'price_desc' | 'time' | 'newest'
limit?: number
offset?: number
} = {}) {
const params = new URLSearchParams()
if (options.source) params.append('source', options.source)
if (options.keyword) params.append('keyword', options.keyword)
if (options.tld) params.append('tld', options.tld)
if (options.minPrice !== undefined) params.append('min_price', options.minPrice.toString())
if (options.maxPrice !== undefined) params.append('max_price', options.maxPrice.toString())
if (options.minScore !== undefined) params.append('min_score', options.minScore.toString())
if (options.endingWithin !== undefined) params.append('ending_within', options.endingWithin.toString())
if (options.verifiedOnly) params.append('verified_only', 'true')
if (options.sortBy) params.append('sort_by', options.sortBy)
if (options.limit !== undefined) params.append('limit', options.limit.toString())
if (options.offset !== undefined) params.append('offset', options.offset.toString())
return this.request<{
items: Array<{
id: string
domain: string
tld: string
price: number
currency: string
price_type: 'bid' | 'fixed' | 'negotiable'
status: 'auction' | 'instant'
source: string
is_pounce: boolean
verified: boolean
time_remaining?: string
end_time?: string
num_bids?: number
slug?: string
seller_verified: boolean
url: string
is_external: boolean
pounce_score: number
}>
total: number
pounce_direct_count: number
auction_count: number
sources: string[]
last_updated: string
filters_applied: Record<string, any>
}>(`/auctions/feed?${params.toString()}`)
}
// ============== Auctions (Smart Pounce) ==============
async getAuctions(