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:
1415
MARKET_CONCEPT.md
Normal file
1415
MARKET_CONCEPT.md
Normal file
File diff suppressed because it is too large
Load Diff
307
ZONE_FILE_ACCESS.md
Normal file
307
ZONE_FILE_ACCESS.md
Normal 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.** 🦄
|
||||
|
||||
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(
|
||||
|
||||
Reference in New Issue
Block a user