Compare commits
59 Commits
main
...
b9882d6945
| Author | SHA1 | Date | |
|---|---|---|---|
| b9882d6945 | |||
| 990bd29598 | |||
| f3c5613569 | |||
| 5a67f8fd59 | |||
| 2a08b9f8dc | |||
| 0bb2b6fc9d | |||
| ff05d5b2b5 | |||
| 39c7e905e1 | |||
| 811e4776c8 | |||
| c1316d8b38 | |||
| 4f79a3cf2f | |||
| 3f83185ed4 | |||
| a64c172f9c | |||
| eda676265d | |||
| 7ffaa8265c | |||
| 80ae280941 | |||
| cc771be4e1 | |||
| d105a610dc | |||
| 2c6d62afca | |||
| 848b87dd5e | |||
| 2e8bd4a440 | |||
| 43f89bbb90 | |||
| 24e7337cb8 | |||
| 096b2313ed | |||
| 9f918002ea | |||
| feded3eec2 | |||
| 9a98b75681 | |||
| 20d321a394 | |||
| 307f465ebb | |||
| dac46180b4 | |||
| bd1f81a804 | |||
| a33d57ccb4 | |||
| ff8d6e8eb1 | |||
| 36a361332a | |||
| bd05ea19ec | |||
| 6891afe3d4 | |||
| ed250b4e44 | |||
| b14303fe56 | |||
| 2e3a55bcef | |||
| a5acdfed04 | |||
| a4df5a8487 | |||
| fb418b91ad | |||
| a58db843e0 | |||
| 41abd8214f | |||
| a42435c24d | |||
| 940622a7b7 | |||
| 641b5c1dc2 | |||
| 26ea22899c | |||
| 35d943a372 | |||
| f648457353 | |||
| ae1416bd34 | |||
| f40d11edb7 | |||
| d5ee48e0e2 | |||
| d5e8dcb197 | |||
| 70a710ca83 | |||
| 0582b26be7 | |||
| 3f456658ee | |||
| d815c0780f | |||
| 170eef6d0a |
245
ARCHITECTURE_ANALYSIS.md
Normal file
245
ARCHITECTURE_ANALYSIS.md
Normal file
@ -0,0 +1,245 @@
|
||||
# 🏗️ Pounce - Informationsarchitektur & Navigation
|
||||
|
||||
**Stand:** 10. Dezember 2024
|
||||
**Status:** ✅ Implementiert
|
||||
|
||||
---
|
||||
|
||||
## 📊 Navigation Konzept
|
||||
|
||||
Die Navigation ist klar zwischen **öffentlichem** und **eingeloggtem** Zustand getrennt.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 PUBLIC SITE (Besucher ohne Login)
|
||||
|
||||
### Navigation
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ POUNCE | [Market] [TLD Intel] [Pricing] | [Sign In] [Get Started] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Seiten
|
||||
|
||||
| Route | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `/` | Landing Page mit Hero, Ticker, Discover/Track/Acquire |
|
||||
| `/auctions` | Öffentliche Auktions-Vorschau |
|
||||
| `/tld-pricing` | TLD Preisdaten (SEO-optimiert) |
|
||||
| `/tld-pricing/[tld]` | Detail-Seite pro TLD |
|
||||
| `/pricing` | Preisvergleich Scout/Trader/Tycoon |
|
||||
| `/blog` | Blog-Artikel |
|
||||
| `/about`, `/contact` | Info-Seiten |
|
||||
| `/login`, `/register` | Auth-Seiten |
|
||||
|
||||
### Für eingeloggte User auf Public Pages
|
||||
|
||||
Statt "Sign In / Get Started" wird angezeigt:
|
||||
|
||||
```
|
||||
[🟢 Command Center] (Button → /dashboard)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 COMMAND CENTER (Eingeloggte User)
|
||||
|
||||
### Layout mit Sidebar
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ [P] POUNCE [🔍 Search] [🔔] │ Top Bar │
|
||||
├──────────┬─────────────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ Dashboard│ Content Area │
|
||||
│ Watchlist│ │
|
||||
│ Portfolio│ │
|
||||
│ Market │ │
|
||||
│ Intel │ │
|
||||
│ ──────── │ │
|
||||
│ Settings │ │
|
||||
│ [User] │ │
|
||||
│ │ │
|
||||
└──────────┴─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Sidebar Features
|
||||
|
||||
- **Collapsible**: Toggle-Button zum Minimieren
|
||||
- **Badges**: Notification-Count auf Watchlist
|
||||
- **User-Info**: Tier, Domain-Nutzung, Upgrade-Link
|
||||
- **Admin-Link**: Nur für Admins sichtbar
|
||||
- **Responsive**: Versteckt auf Mobile (→ Mobile Nav)
|
||||
|
||||
### Seiten
|
||||
|
||||
| Route | Beschreibung | Konzept-Feature |
|
||||
|-------|--------------|-----------------|
|
||||
| `/dashboard` | Übersicht mit Activity Feed + Market Pulse | ✅ |
|
||||
| `/watchlist` | Domain-Watchlist mit Ampel-System | ✅ |
|
||||
| `/portfolio` | Portfolio-Verwaltung | ✅ |
|
||||
| `/market` | Auktions-Aggregator | ✅ |
|
||||
| `/intelligence` | TLD-Daten & Analysen | ✅ |
|
||||
| `/settings` | Profil, Notifications, Billing | ✅ |
|
||||
| `/admin` | Admin-Panel (nur für Admins) | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 🚦 Status-Indikatoren (Ampel-System)
|
||||
|
||||
### Watchlist Domain Status
|
||||
|
||||
| Status | Farbe | Bedeutung |
|
||||
|--------|-------|-----------|
|
||||
| 🟢 **Available** | Grün (pulsierend) | Domain ist verfügbar! |
|
||||
| 🟡 **Watching** | Gelb | Wird überwacht, Änderungen erkannt |
|
||||
| 🔴 **Stable** | Grau | Domain ist registriert und aktiv |
|
||||
|
||||
---
|
||||
|
||||
## 📱 Mobile Navigation
|
||||
|
||||
### Public
|
||||
|
||||
```
|
||||
┌────────────────────────────────────┐
|
||||
│ POUNCE [☰] │
|
||||
├────────────────────────────────────┤
|
||||
│ (Hamburger Menu öffnet) │
|
||||
│ • Market │
|
||||
│ • TLD Intel │
|
||||
│ • Pricing │
|
||||
│ ─────────────── │
|
||||
│ [Sign In] │
|
||||
│ [Get Started] │
|
||||
└────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Command Center (Logged In)
|
||||
|
||||
Sidebar wird zum Hamburger-Menu auf Mobil.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 User Flows
|
||||
|
||||
### Flow 1: Besucher → Registrierung
|
||||
|
||||
```
|
||||
Landing Page → Domain suchen → "Taken"
|
||||
→ "Track this domain" → Login Prompt
|
||||
→ Registrieren → Dashboard → Watchlist
|
||||
```
|
||||
|
||||
### Flow 2: Free User → Upgrade
|
||||
|
||||
```
|
||||
Watchlist → Limit erreicht (5 Domains)
|
||||
→ "Upgrade to track more" Banner → Pricing
|
||||
→ Stripe Checkout → Dashboard (upgraded)
|
||||
```
|
||||
|
||||
### Flow 3: Daily User Flow
|
||||
|
||||
```
|
||||
Login → Dashboard (Activity Feed)
|
||||
→ "Domain X is available!" Notification
|
||||
→ Click → Watchlist → "Register" Button → Registrar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Dateistruktur
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── components/
|
||||
│ ├── Header.tsx # Public Header
|
||||
│ ├── Sidebar.tsx # Command Center Sidebar
|
||||
│ ├── CommandCenterLayout.tsx # Layout für logged-in
|
||||
│ └── Footer.tsx # Public Footer
|
||||
│
|
||||
├── app/
|
||||
│ ├── page.tsx # Landing Page (public)
|
||||
│ ├── auctions/ # Public auctions
|
||||
│ ├── tld-pricing/ # Public TLD data
|
||||
│ ├── pricing/ # Pricing page
|
||||
│ ├── blog/ # Blog
|
||||
│ │
|
||||
│ ├── dashboard/ # Command Center Home
|
||||
│ ├── watchlist/ # Watchlist (logged-in)
|
||||
│ ├── portfolio/ # Portfolio (logged-in)
|
||||
│ ├── market/ # Market Scanner (logged-in)
|
||||
│ ├── intelligence/ # TLD Intelligence (logged-in)
|
||||
│ ├── settings/ # Settings (logged-in)
|
||||
│ └── admin/ # Admin Panel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Implementierte Features
|
||||
|
||||
### Navigation & Layout
|
||||
- [x] Sidebar-Navigation für Command Center
|
||||
- [x] Collapsible Sidebar mit localStorage
|
||||
- [x] Header für Public Pages
|
||||
- [x] Command Center Button für eingeloggte User auf Public Pages
|
||||
|
||||
### Dashboard
|
||||
- [x] Activity Feed mit verfügbaren Domains
|
||||
- [x] Market Pulse mit auslaufenden Auktionen
|
||||
- [x] Trending TLDs
|
||||
- [x] Quick Add to Watchlist
|
||||
- [x] Stats Overview (Domains, Available, Portfolio, Tier)
|
||||
|
||||
### Watchlist
|
||||
- [x] Ampel-System (Available/Watching/Stable)
|
||||
- [x] Add/Remove Domains
|
||||
- [x] Notification Toggle
|
||||
- [x] History View
|
||||
- [x] Filter nach Status
|
||||
- [x] Suche
|
||||
|
||||
### Portfolio
|
||||
- [x] Add/Edit/Delete Domains
|
||||
- [x] Valuation
|
||||
- [x] Sell Tracking
|
||||
- [x] Summary Stats
|
||||
|
||||
### Market Scanner
|
||||
- [x] Tabs: All/Ending Soon/Hot/Opportunities
|
||||
- [x] Platform Filter
|
||||
- [x] Search
|
||||
- [x] Sorting
|
||||
|
||||
### Intelligence
|
||||
- [x] TLD Overview
|
||||
- [x] Price Data
|
||||
- [x] Trend Indicators
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Design-Prinzipien
|
||||
|
||||
1. **Dark Mode First**: Dunkles Design mit Accent-Grün
|
||||
2. **Bloomberg Vibe**: Datenintensiv aber aufgeräumt
|
||||
3. **Minimalistisch**: Keine Ablenkung, Fokus auf Aktionen
|
||||
4. **Responsive**: Mobile-first mit adaptierbarer Navigation
|
||||
5. **Pro-Tool Feel**: Sidebar vermittelt "Werkzeug"-Charakter
|
||||
|
||||
---
|
||||
|
||||
## 📊 Konzept-Alignment: 95%
|
||||
|
||||
| Feature | Konzept | Status |
|
||||
|---------|---------|--------|
|
||||
| Sidebar Navigation | ✅ | Implementiert |
|
||||
| Activity Feed | ✅ | Implementiert |
|
||||
| Market Pulse | ✅ | Implementiert |
|
||||
| Watchlist (Ampel) | ✅ | Implementiert |
|
||||
| Separate Routes | ✅ | Implementiert |
|
||||
| Quick Search (⌘K) | ✅ | Implementiert |
|
||||
| Saved Filters | ❌ | Noch nicht |
|
||||
| Pre-Drop Alerts | ⚠️ | Backend ready, UI pending |
|
||||
340
DATABASE_MIGRATIONS.md
Normal file
340
DATABASE_MIGRATIONS.md
Normal file
@ -0,0 +1,340 @@
|
||||
# Database Migrations Guide
|
||||
|
||||
## Quick Overview
|
||||
|
||||
When deploying Pounce to a new server, these tables need to be created:
|
||||
|
||||
```
|
||||
✅ Core Tables (17) - User, Subscription, Domain, TLD, etc.
|
||||
🆕 New Tables (6) - Listings, Sniper Alerts, SEO Data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Automatic Migration
|
||||
|
||||
The easiest way to create all tables:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
source venv/bin/activate
|
||||
python scripts/init_db.py
|
||||
```
|
||||
|
||||
This creates all tables from the SQLAlchemy models automatically.
|
||||
|
||||
---
|
||||
|
||||
## Manual SQL Migration
|
||||
|
||||
If you need to run migrations manually (e.g., on an existing database), use the SQL below.
|
||||
|
||||
### NEW Table 1: Domain Listings (For Sale Marketplace)
|
||||
|
||||
```sql
|
||||
-- Main listing table
|
||||
CREATE TABLE domain_listings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
domain VARCHAR(255) NOT NULL UNIQUE,
|
||||
slug VARCHAR(300) NOT NULL UNIQUE,
|
||||
title VARCHAR(200),
|
||||
description TEXT,
|
||||
asking_price FLOAT,
|
||||
min_offer FLOAT,
|
||||
currency VARCHAR(3) DEFAULT 'USD',
|
||||
price_type VARCHAR(20) DEFAULT 'fixed', -- 'fixed', 'negotiable', 'make_offer'
|
||||
pounce_score INTEGER,
|
||||
estimated_value FLOAT,
|
||||
verification_status VARCHAR(20) DEFAULT 'not_started', -- 'not_started', 'pending', 'verified', 'failed'
|
||||
verification_code VARCHAR(64),
|
||||
verified_at TIMESTAMP,
|
||||
status VARCHAR(30) DEFAULT 'draft', -- 'draft', 'published', 'sold', 'expired', 'removed'
|
||||
show_valuation BOOLEAN DEFAULT TRUE,
|
||||
allow_offers BOOLEAN DEFAULT TRUE,
|
||||
featured BOOLEAN DEFAULT FALSE,
|
||||
view_count INTEGER DEFAULT 0,
|
||||
inquiry_count INTEGER DEFAULT 0,
|
||||
expires_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
published_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_listings_user_id ON domain_listings(user_id);
|
||||
CREATE INDEX idx_listings_domain ON domain_listings(domain);
|
||||
CREATE INDEX idx_listings_slug ON domain_listings(slug);
|
||||
CREATE INDEX idx_listings_status ON domain_listings(status);
|
||||
CREATE INDEX idx_listings_price ON domain_listings(asking_price);
|
||||
```
|
||||
|
||||
### NEW Table 2: Listing Inquiries
|
||||
|
||||
```sql
|
||||
-- Contact inquiries from potential buyers
|
||||
CREATE TABLE listing_inquiries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
listing_id INTEGER NOT NULL REFERENCES domain_listings(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
phone VARCHAR(50),
|
||||
company VARCHAR(200),
|
||||
message TEXT NOT NULL,
|
||||
offer_amount FLOAT,
|
||||
status VARCHAR(20) DEFAULT 'new', -- 'new', 'read', 'replied', 'archived'
|
||||
ip_address VARCHAR(45),
|
||||
user_agent VARCHAR(500),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
read_at TIMESTAMP,
|
||||
replied_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_inquiries_listing_id ON listing_inquiries(listing_id);
|
||||
CREATE INDEX idx_inquiries_status ON listing_inquiries(status);
|
||||
```
|
||||
|
||||
### NEW Table 3: Listing Views
|
||||
|
||||
```sql
|
||||
-- Analytics: page views
|
||||
CREATE TABLE listing_views (
|
||||
id SERIAL PRIMARY KEY,
|
||||
listing_id INTEGER NOT NULL REFERENCES domain_listings(id) ON DELETE CASCADE,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent VARCHAR(500),
|
||||
referrer VARCHAR(500),
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
viewed_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_views_listing_id ON listing_views(listing_id);
|
||||
CREATE INDEX idx_views_date ON listing_views(viewed_at);
|
||||
```
|
||||
|
||||
### NEW Table 4: Sniper Alerts
|
||||
|
||||
```sql
|
||||
-- Saved filter configurations for personalized auction alerts
|
||||
CREATE TABLE sniper_alerts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description VARCHAR(500),
|
||||
|
||||
-- Filter criteria (stored as JSON for flexibility)
|
||||
filter_criteria JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Quick filters (also stored as columns for fast queries)
|
||||
tlds VARCHAR(500), -- comma-separated: "com,net,io"
|
||||
keywords VARCHAR(500), -- comma-separated search terms
|
||||
exclude_keywords VARCHAR(500), -- words to exclude
|
||||
max_length INTEGER,
|
||||
min_length INTEGER,
|
||||
max_price FLOAT,
|
||||
min_price FLOAT,
|
||||
max_bids INTEGER,
|
||||
ending_within_hours INTEGER,
|
||||
platforms VARCHAR(200), -- "GoDaddy,Sedo,NameJet"
|
||||
|
||||
-- Vanity filters
|
||||
no_numbers BOOLEAN DEFAULT FALSE,
|
||||
no_hyphens BOOLEAN DEFAULT FALSE,
|
||||
exclude_chars VARCHAR(50),
|
||||
|
||||
-- Notification settings
|
||||
notify_email BOOLEAN DEFAULT TRUE,
|
||||
notify_sms BOOLEAN DEFAULT FALSE,
|
||||
notify_push BOOLEAN DEFAULT FALSE,
|
||||
max_notifications_per_day INTEGER DEFAULT 10,
|
||||
cooldown_minutes INTEGER DEFAULT 30,
|
||||
|
||||
-- Status
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
matches_count INTEGER DEFAULT 0,
|
||||
notifications_sent INTEGER DEFAULT 0,
|
||||
last_matched_at TIMESTAMP,
|
||||
last_notified_at TIMESTAMP,
|
||||
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_alerts_user_id ON sniper_alerts(user_id);
|
||||
CREATE INDEX idx_alerts_active ON sniper_alerts(is_active);
|
||||
```
|
||||
|
||||
### NEW Table 5: Sniper Alert Matches
|
||||
|
||||
```sql
|
||||
-- Matched auctions for each alert
|
||||
CREATE TABLE sniper_alert_matches (
|
||||
id SERIAL PRIMARY KEY,
|
||||
alert_id INTEGER NOT NULL REFERENCES sniper_alerts(id) ON DELETE CASCADE,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
platform VARCHAR(50) NOT NULL,
|
||||
current_bid FLOAT NOT NULL,
|
||||
end_time TIMESTAMP NOT NULL,
|
||||
auction_url VARCHAR(500),
|
||||
notified BOOLEAN DEFAULT FALSE,
|
||||
clicked BOOLEAN DEFAULT FALSE,
|
||||
matched_at TIMESTAMP DEFAULT NOW(),
|
||||
notified_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_matches_alert_id ON sniper_alert_matches(alert_id);
|
||||
CREATE INDEX idx_matches_domain ON sniper_alert_matches(domain);
|
||||
CREATE INDEX idx_matches_notified ON sniper_alert_matches(notified);
|
||||
```
|
||||
|
||||
### NEW Table 6: SEO Data (Tycoon Feature)
|
||||
|
||||
```sql
|
||||
-- Cached SEO metrics for domains (Moz API or estimation)
|
||||
CREATE TABLE domain_seo_data (
|
||||
id SERIAL PRIMARY KEY,
|
||||
domain VARCHAR(255) NOT NULL UNIQUE,
|
||||
|
||||
-- Core metrics
|
||||
domain_authority INTEGER, -- 0-100
|
||||
page_authority INTEGER, -- 0-100
|
||||
spam_score INTEGER, -- 0-100
|
||||
total_backlinks INTEGER,
|
||||
referring_domains INTEGER,
|
||||
|
||||
-- Backlink analysis
|
||||
top_backlinks JSONB, -- [{domain, authority, page}, ...]
|
||||
notable_backlinks TEXT, -- comma-separated high-value domains
|
||||
|
||||
-- Notable link flags
|
||||
has_wikipedia_link BOOLEAN DEFAULT FALSE,
|
||||
has_gov_link BOOLEAN DEFAULT FALSE,
|
||||
has_edu_link BOOLEAN DEFAULT FALSE,
|
||||
has_news_link BOOLEAN DEFAULT FALSE,
|
||||
|
||||
-- Value estimation
|
||||
seo_value_estimate FLOAT, -- Estimated $ value based on SEO metrics
|
||||
|
||||
-- Metadata
|
||||
data_source VARCHAR(50) DEFAULT 'estimated', -- 'moz', 'estimated'
|
||||
last_updated TIMESTAMP DEFAULT NOW(),
|
||||
expires_at TIMESTAMP, -- Cache expiry (7 days)
|
||||
fetch_count INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_seo_domain ON domain_seo_data(domain);
|
||||
CREATE INDEX idx_seo_da ON domain_seo_data(domain_authority);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## All Tables Summary
|
||||
|
||||
### Core Tables (Already Implemented)
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `users` | User accounts and authentication |
|
||||
| `subscriptions` | Subscription plans (Scout, Trader, Tycoon) |
|
||||
| `domains` | Tracked domains in watchlists |
|
||||
| `domain_checks` | Domain availability check history |
|
||||
| `tld_prices` | TLD price history (886+ TLDs) |
|
||||
| `tld_info` | TLD metadata and categories |
|
||||
| `portfolio_domains` | User-owned domains |
|
||||
| `domain_valuations` | Domain valuation history |
|
||||
| `domain_auctions` | Scraped auction listings |
|
||||
| `auction_scrape_logs` | Scraping job logs |
|
||||
| `newsletter_subscribers` | Email newsletter list |
|
||||
| `price_alerts` | TLD price change alerts |
|
||||
| `admin_activity_logs` | Admin action audit log |
|
||||
| `blog_posts` | Blog content |
|
||||
|
||||
### New Tables (v2.0)
|
||||
|
||||
| Table | Purpose | Required For |
|
||||
|-------|---------|--------------|
|
||||
| `domain_listings` | For Sale marketplace | `/command/listings`, `/buy` |
|
||||
| `listing_inquiries` | Buyer messages | Marketplace inquiries |
|
||||
| `listing_views` | View analytics | Listing stats |
|
||||
| `sniper_alerts` | Alert configurations | `/command/alerts` |
|
||||
| `sniper_alert_matches` | Matched auctions | Alert notifications |
|
||||
| `domain_seo_data` | SEO metrics cache | `/command/seo` (Tycoon) |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
After migration, verify all tables exist:
|
||||
|
||||
```sql
|
||||
-- PostgreSQL
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name;
|
||||
|
||||
-- Should include:
|
||||
-- domain_listings
|
||||
-- listing_inquiries
|
||||
-- listing_views
|
||||
-- sniper_alerts
|
||||
-- sniper_alert_matches
|
||||
-- domain_seo_data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables for New Features
|
||||
|
||||
### Moz API (Optional - for real SEO data)
|
||||
|
||||
```env
|
||||
MOZ_ACCESS_ID=your_moz_access_id
|
||||
MOZ_SECRET_KEY=your_moz_secret_key
|
||||
```
|
||||
|
||||
Without these variables, the SEO analyzer uses **estimation mode** based on domain characteristics (length, TLD, keywords).
|
||||
|
||||
### Stripe (Required for payments)
|
||||
|
||||
```env
|
||||
STRIPE_SECRET_KEY=sk_live_xxx
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
STRIPE_PRICE_TRADER=price_xxx # €9/month
|
||||
STRIPE_PRICE_TYCOON=price_xxx # €29/month
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scheduler Jobs
|
||||
|
||||
These background jobs run automatically when the backend starts:
|
||||
|
||||
| Job | Schedule | Table Affected |
|
||||
|-----|----------|----------------|
|
||||
| Sniper Alert Matching | Every 15 min | `sniper_alert_matches` |
|
||||
| Auction Scrape | Hourly | `domain_auctions` |
|
||||
| TLD Price Scrape | Daily 03:00 | `tld_prices` |
|
||||
| Domain Check | Daily 06:00 | `domain_checks` |
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
If you need to remove the new tables:
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS sniper_alert_matches CASCADE;
|
||||
DROP TABLE IF EXISTS sniper_alerts CASCADE;
|
||||
DROP TABLE IF EXISTS listing_views CASCADE;
|
||||
DROP TABLE IF EXISTS listing_inquiries CASCADE;
|
||||
DROP TABLE IF EXISTS domain_listings CASCADE;
|
||||
DROP TABLE IF EXISTS domain_seo_data CASCADE;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `README.md` - Full deployment guide
|
||||
- `DEPLOYMENT.md` - Server setup details
|
||||
- `backend/app/models/` - SQLAlchemy model definitions
|
||||
247
DATA_INDEPENDENCE_REPORT.md
Normal file
247
DATA_INDEPENDENCE_REPORT.md
Normal file
@ -0,0 +1,247 @@
|
||||
# 🔒 Pounce Data Independence Report
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Status: 🟡 PARTIALLY INDEPENDENT**
|
||||
|
||||
Pounce hat eine solide Basis für Unabhängigkeit, aber es gibt kritische Bereiche, die verbessert werden müssen, um als "Premium-Dienstleister" aufzutreten.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Aktuelle Datenquellen-Analyse
|
||||
|
||||
### 1. TLD-Preise (TLD Intel)
|
||||
|
||||
| Aspekt | Status | Details |
|
||||
|--------|--------|---------|
|
||||
| **Quelle** | Porkbun Public API | ✅ Keine API-Keys erforderlich |
|
||||
| **Zuverlässigkeit** | 🟡 Mittel | API kann jederzeit geändert werden |
|
||||
| **Abdeckung** | 896+ TLDs | ✅ Excellent |
|
||||
| **Genauigkeit** | 100% | ✅ Offizielle Preise |
|
||||
| **Unabhängigkeit** | ⚠️ Fragil | Abhängig von einem Registrar |
|
||||
|
||||
**Risiko:** Wenn Porkbun seine API ändert oder blockt, fallen alle TLD-Preise weg.
|
||||
|
||||
**Empfehlung:** Mehrere Registrare hinzufügen (Namecheap, Cloudflare, Google Domains Public Pricing).
|
||||
|
||||
---
|
||||
|
||||
### 2. Domain-Auktionen (Acquire)
|
||||
|
||||
| Aspekt | Status | Details |
|
||||
|--------|--------|---------|
|
||||
| **Quelle** | Web Scraping | 5 Plattformen |
|
||||
| **Plattformen** | GoDaddy, Sedo, NameJet, DropCatch, ExpiredDomains | ✅ Diverse |
|
||||
| **Zuverlässigkeit** | 🔴 Niedrig | Websites können Layouts jederzeit ändern |
|
||||
| **Genauigkeit** | ⚠️ Variabel | Abhängig von Scraping-Qualität |
|
||||
| **Rate Limiting** | ✅ Implementiert | 5-10 req/min pro Plattform |
|
||||
|
||||
**Risiko:**
|
||||
- Web-Scraping ist fragil - Layout-Änderungen brechen Scraper
|
||||
- Plattformen können Scraping blocken (Captcha, IP-Bans)
|
||||
- Keine rechtliche Grundlage für Daten-Nutzung
|
||||
|
||||
**Aktueller Code-Zustand:**
|
||||
```python
|
||||
# backend/app/services/auction_scraper.py
|
||||
# Zeilen 1-19 zeigen, dass ALLE Daten gescrapt werden
|
||||
# Kein API-Zugriff, nur Web-Parsing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Domain-Verfügbarkeit (Track/Watchlist)
|
||||
|
||||
| Aspekt | Status | Details |
|
||||
|--------|--------|---------|
|
||||
| **Methode 1** | RDAP (Modern) | ✅ Beste Methode |
|
||||
| **Methode 2** | Custom RDAP (.ch, .li) | ✅ Speziell implementiert |
|
||||
| **Methode 3** | WHOIS (Fallback) | ✅ Universal-Fallback |
|
||||
| **Methode 4** | DNS Check | ✅ Schnellste Methode |
|
||||
| **Unabhängigkeit** | ✅ 100% | Direkte Protokolle, keine APIs |
|
||||
|
||||
**Dies ist der STÄRKSTE Teil der Architektur!**
|
||||
|
||||
---
|
||||
|
||||
### 4. Domain-Valuation (Pounce Score)
|
||||
|
||||
| Aspekt | Status | Details |
|
||||
|--------|--------|---------|
|
||||
| **Quelle** | Intern | ✅ Keine externen APIs |
|
||||
| **Algorithmus** | Eigene Logik | TLD-Wert + Länge + Keywords |
|
||||
| **Transparenz** | ✅ Vollständig | Code zeigt alle Faktoren |
|
||||
| **Estibot/GoDaddy** | ❌ Nicht integriert | ✅ GUT - Unabhängig |
|
||||
|
||||
**Aktueller Score-Algorithmus:**
|
||||
```python
|
||||
# backend/app/services/valuation.py
|
||||
TLD_VALUES = {
|
||||
"com": 1.0, # Baseline
|
||||
"ai": 1.20, # Premium (AI-Boom)
|
||||
"io": 0.75, # Startup-Favorit
|
||||
"net": 0.65, # Klassiker
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Empfehlungen für 100% Premium-Unabhängigkeit
|
||||
|
||||
### Priorität 1: Zone File Integration (KRITISCH)
|
||||
|
||||
Die `analysis_2.md` beschreibt es perfekt: **Zone Files sind der Rohstoff**.
|
||||
|
||||
**Was sind Zone Files?**
|
||||
- Tägliche Listen ALLER registrierten Domains einer TLD
|
||||
- Bereitgestellt von Registries (Verisign, SWITCH, etc.)
|
||||
- Durch Vergleich von "gestern vs heute" = gelöschte/neue Domains
|
||||
|
||||
**Umsetzung:**
|
||||
```python
|
||||
# Neuer Service: backend/app/services/zone_file_processor.py
|
||||
class ZoneFileProcessor:
|
||||
async def download_zone_file(self, tld: str) -> str:
|
||||
"""Download Zone File von CZDS oder Registry"""
|
||||
pass
|
||||
|
||||
async def compute_diff(self, yesterday: str, today: str) -> dict:
|
||||
"""Finde: added_domains, deleted_domains"""
|
||||
pass
|
||||
|
||||
async def filter_premium(self, domains: list) -> list:
|
||||
"""Wende Pounce-Filter an"""
|
||||
# Keine Zahlen, max 12 Zeichen, Wörterbuch-Match
|
||||
pass
|
||||
```
|
||||
|
||||
**Zugang:**
|
||||
- **.com/.net**: ICANN CZDS (Centralized Zone Data Service) - Kostenlos beantragen
|
||||
- **.ch/.li**: SWITCH (nic.ch) - Open Data verfügbar
|
||||
- **.de**: DENIC - Zone File Zugang beantragbar
|
||||
|
||||
### Priorität 2: Multi-Registrar TLD-Preise
|
||||
|
||||
Statt nur Porkbun, sollten wir Preise von mehreren Quellen sammeln:
|
||||
|
||||
```python
|
||||
# backend/app/services/tld_scraper/aggregator.py
|
||||
self.scrapers = [
|
||||
PorkbunScraper(), # ✅ Bereits implementiert
|
||||
NamecheapScraper(), # 📌 TODO: Public Pricing Page
|
||||
CloudflareScraper(), # 📌 TODO: Public Pricing API
|
||||
GandiScraper(), # 📌 TODO: Pricing Page
|
||||
]
|
||||
```
|
||||
|
||||
**Vorteil:** Preis-Vergleich über Registrare = echte "Intel"
|
||||
|
||||
### Priorität 3: Auction-Daten Härtung
|
||||
|
||||
**Option A: Offizieller API-Zugang**
|
||||
- GoDaddy Affiliate-Programm für Auktions-API
|
||||
- Sedo Partner-Programm
|
||||
- → Kosten, aber zuverlässig
|
||||
|
||||
**Option B: Robusteres Scraping**
|
||||
- Playwright statt httpx (JavaScript-Rendering)
|
||||
- Proxy-Rotation für IP-Diversität
|
||||
- ML-basiertes HTML-Parsing (weniger Layout-abhängig)
|
||||
|
||||
**Option C: User-Generated Data (Hybrid)**
|
||||
- User können Auktionen melden
|
||||
- Community-validiert
|
||||
- Reduziert Scraping-Last
|
||||
|
||||
---
|
||||
|
||||
## 📋 Implementierungs-Roadmap
|
||||
|
||||
### Phase 1: Stabilisierung (Sofort)
|
||||
- [x] Eigene Domain-Valuation (Pounce Score)
|
||||
- [x] Multi-Methoden Domain-Check (RDAP/WHOIS/DNS)
|
||||
- [ ] Zweiten TLD-Preis-Scraper hinzufügen (Namecheap)
|
||||
|
||||
### Phase 2: Zone Files (2-4 Wochen)
|
||||
- [ ] CZDS-Zugang beantragen (.com, .net)
|
||||
- [ ] SWITCH Open Data integrieren (.ch, .li)
|
||||
- [ ] Zone File Diff-Processor bauen
|
||||
- [ ] "Daily Drop Gems" Feature launchen
|
||||
|
||||
### Phase 3: Premium-Ausbau (1-2 Monate)
|
||||
- [ ] GoDaddy Affiliate-API für Auktionen
|
||||
- [ ] DNS-Change-Monitoring (Pre-Drop-Signale)
|
||||
- [ ] HTTP-Health-Check für Watchlist-Domains
|
||||
|
||||
---
|
||||
|
||||
## ✅ Was bereits EXZELLENT ist
|
||||
|
||||
1. **Domain-Checker**: RDAP → WHOIS → DNS Fallback-Kette
|
||||
2. **Valuation**: 100% intern, keine Estibot-Abhängigkeit
|
||||
3. **Vanity Filter**: Eigener Spam-Erkennungs-Algorithmus
|
||||
4. **TLD-Typisierung**: Automatische Klassifizierung
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Fazit
|
||||
|
||||
Pounce hat die richtige Architektur für Unabhängigkeit. Die kritischsten Schritte sind:
|
||||
|
||||
1. **Zone Files** = Unabhängige Datenquelle für "Drops"
|
||||
2. **Multi-Registrar Preise** = Robustheit gegen API-Ausfälle
|
||||
3. **Offizieller Auktions-Zugang** = Rechtlich sauber & zuverlässig
|
||||
|
||||
Mit diesen Verbesserungen wird Pounce ein **echtes Premium-Tool**, das keine externen APIs braucht - sondern eigene, proprietäre Daten hat.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ KRITISCHES PROBLEM: Sample-Daten vs. Echte Daten
|
||||
|
||||
### Aktueller Zustand der Auktions-Daten:
|
||||
|
||||
**Das Scraping ist implementiert ABER:**
|
||||
|
||||
1. **ExpiredDomains.net**: Funktioniert, aber:
|
||||
- Preise sind **geschätzt** (nicht echt): `estimated_price = base_prices.get(tld, 15)`
|
||||
- Dies sind Registrierungspreise, KEINE Auktionspreise
|
||||
|
||||
2. **GoDaddy/Sedo/NameJet/DropCatch**: Scraping existiert, aber:
|
||||
- Websites haben Anti-Bot-Maßnahmen
|
||||
- Layouts ändern sich regelmäßig
|
||||
- **Aktuell werden oft Sample-Daten als Fallback verwendet**
|
||||
|
||||
3. **In der Praxis zeigt die Seite oft:**
|
||||
```python
|
||||
# backend/app/services/auction_scraper.py:689-780
|
||||
async def seed_sample_auctions(self, db: AsyncSession):
|
||||
# DIESE DATEN SIND FAKE (Demo-Daten)!
|
||||
sample_auctions = [
|
||||
{"domain": "techflow.io", "platform": "GoDaddy", "current_bid": 250, ...},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
### 🚨 Für Premium-Qualität erforderlich:
|
||||
|
||||
1. **Keine geschätzten Preise** - Nur echte Auktionspreise anzeigen
|
||||
2. **Klare Kennzeichnung** - Wenn Daten unsicher sind, transparent kommunizieren
|
||||
3. **Fallback-Strategie** - Wenn Scraping fehlschlägt, keine Fake-Daten zeigen
|
||||
|
||||
### Empfohlene Änderungen:
|
||||
|
||||
```python
|
||||
# Statt geschätzter Preise:
|
||||
"current_bid": float(estimated_price), # ❌ FALSCH
|
||||
|
||||
# Besser:
|
||||
"current_bid": None, # Kein Preis = keine falsche Info
|
||||
"price_type": "registration_estimate", # Kennzeichnung
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Generiert am: 2024-12-10*
|
||||
*Für: pounce.ch*
|
||||
|
||||
221
DEPLOYMENT_INSTRUCTIONS.md
Normal file
221
DEPLOYMENT_INSTRUCTIONS.md
Normal file
@ -0,0 +1,221 @@
|
||||
# 🚀 Deployment Instructions für pounce.ch
|
||||
|
||||
## Server Setup
|
||||
|
||||
### 1. Code auf den Server pullen
|
||||
```bash
|
||||
cd /path/to/server
|
||||
git clone https://git.6bit.ch/yvg/pounce.git
|
||||
cd pounce
|
||||
```
|
||||
|
||||
### 2. Environment Dateien einrichten
|
||||
|
||||
#### Backend (.env)
|
||||
```bash
|
||||
# Kopiere DEPLOY_backend.env nach backend/.env
|
||||
cp DEPLOY_backend.env backend/.env
|
||||
```
|
||||
|
||||
**Wichtige Anpassungen für Production:**
|
||||
- `DATABASE_URL`: Wenn du PostgreSQL verwendest, passe die Connection-String an
|
||||
- `CORS_ORIGINS`: Stelle sicher, dass deine Domain(s) enthalten sind
|
||||
- `ENVIRONMENT=production`
|
||||
- `DEBUG=false`
|
||||
|
||||
#### Frontend (.env.local)
|
||||
```bash
|
||||
# Kopiere DEPLOY_frontend.env nach frontend/.env.local
|
||||
cp DEPLOY_frontend.env frontend/.env.local
|
||||
```
|
||||
|
||||
**Wichtig:** `NEXT_PUBLIC_API_URL` muss auf deine Backend-URL zeigen (z.B. `https://pounce.ch/api/v1`)
|
||||
|
||||
### 3. Backend Setup
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Python Virtual Environment erstellen
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Dependencies installieren
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Datenbank initialisieren
|
||||
python init_db.py
|
||||
|
||||
# TLD Preise seeden
|
||||
python seed_tld_prices.py
|
||||
|
||||
# Auctions seeden (optional für Demo-Daten)
|
||||
python seed_auctions.py
|
||||
|
||||
# Stripe Produkte erstellen
|
||||
python -c "
|
||||
from app.services.stripe_service import create_stripe_products
|
||||
import asyncio
|
||||
asyncio.run(create_stripe_products())
|
||||
"
|
||||
```
|
||||
|
||||
### 4. Frontend Setup
|
||||
```bash
|
||||
cd ../frontend
|
||||
|
||||
# Node.js Dependencies installieren
|
||||
npm install
|
||||
|
||||
# Production Build
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 5. Server starten
|
||||
|
||||
#### Option A: Mit PM2 (empfohlen)
|
||||
```bash
|
||||
# Backend
|
||||
pm2 start backend/ecosystem.config.js
|
||||
|
||||
# Frontend
|
||||
pm2 start frontend/ecosystem.config.js
|
||||
|
||||
# Prozesse speichern
|
||||
pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
#### Option B: Mit systemd
|
||||
Siehe `deploy.sh` Skript für systemd Service-Konfiguration.
|
||||
|
||||
#### Option C: Docker
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 6. Nginx Reverse Proxy (empfohlen)
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/pounce.ch
|
||||
|
||||
upstream backend {
|
||||
server 127.0.0.1:8000;
|
||||
}
|
||||
|
||||
upstream frontend {
|
||||
server 127.0.0.1:3000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name pounce.ch www.pounce.ch;
|
||||
|
||||
# Redirect HTTP to HTTPS
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name pounce.ch www.pounce.ch;
|
||||
|
||||
# SSL Certificates (Let's Encrypt)
|
||||
ssl_certificate /etc/letsencrypt/live/pounce.ch/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/pounce.ch/privkey.pem;
|
||||
|
||||
# Backend API
|
||||
location /api/ {
|
||||
proxy_pass http://backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Frontend
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. SSL Zertifikate (Let's Encrypt)
|
||||
```bash
|
||||
sudo certbot --nginx -d pounce.ch -d www.pounce.ch
|
||||
```
|
||||
|
||||
### 8. Cronjobs einrichten
|
||||
|
||||
Für automatische TLD-Preis-Updates und Domain-Checks:
|
||||
|
||||
```bash
|
||||
crontab -e
|
||||
```
|
||||
|
||||
```cron
|
||||
# Täglich um 3:00 Uhr TLD Preise aktualisieren
|
||||
0 3 * * * cd /path/to/pounce/backend && source venv/bin/activate && python -c "from app.services.tld_scraper import scrape_all_tlds; import asyncio; asyncio.run(scrape_all_tlds())"
|
||||
|
||||
# Stündlich Auctions scrapen
|
||||
0 * * * * cd /path/to/pounce/backend && source venv/bin/activate && python -c "from app.services.auction_scraper import auction_scraper; from app.database import AsyncSessionLocal; import asyncio; async def run(): async with AsyncSessionLocal() as db: await auction_scraper.scrape_all_platforms(db); asyncio.run(run())"
|
||||
```
|
||||
|
||||
**Hinweis:** Die Domain-Checks laufen automatisch über den internen Scheduler (APScheduler), keine Cronjobs nötig!
|
||||
|
||||
## Wichtige Checks nach Deployment
|
||||
|
||||
1. ✅ Backend läuft: `curl https://pounce.ch/api/v1/health`
|
||||
2. ✅ Frontend läuft: Browser öffnen zu `https://pounce.ch`
|
||||
3. ✅ Datenbank funktioniert: Login/Register testen
|
||||
4. ✅ Email-Versand funktioniert: Password Reset testen
|
||||
5. ✅ Stripe funktioniert: Checkout Flow testen
|
||||
6. ✅ OAuth funktioniert: Google/GitHub Login testen
|
||||
|
||||
## Monitoring
|
||||
|
||||
```bash
|
||||
# PM2 Logs ansehen
|
||||
pm2 logs
|
||||
|
||||
# PM2 Status
|
||||
pm2 status
|
||||
|
||||
# PM2 Restart (bei Problemen)
|
||||
pm2 restart all
|
||||
```
|
||||
|
||||
## Backup
|
||||
|
||||
```bash
|
||||
# Datenbank Backup (SQLite)
|
||||
cp backend/domainwatch.db backend/domainwatch.db.backup.$(date +%Y%m%d)
|
||||
|
||||
# Oder mit PostgreSQL
|
||||
pg_dump pounce > pounce_backup_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
Bei Fragen oder Problemen:
|
||||
- Email: hello@pounce.ch
|
||||
- GitHub Issues: https://git.6bit.ch/yvg/pounce
|
||||
|
||||
---
|
||||
|
||||
**Neue Preise (aktualisiert):**
|
||||
- Scout: Free
|
||||
- Trader: $9/mo
|
||||
- Tycoon: $29/mo
|
||||
|
||||
**Währung:** USD (aktualisiert)
|
||||
|
||||
66
DEPLOY_backend.env
Normal file
66
DEPLOY_backend.env
Normal file
@ -0,0 +1,66 @@
|
||||
# =================================
|
||||
# pounce Backend Configuration
|
||||
# =================================
|
||||
# DEPLOY FILE - Copy this to backend/.env on the server
|
||||
|
||||
# Database
|
||||
# SQLite (Development)
|
||||
DATABASE_URL=sqlite+aiosqlite:///./domainwatch.db
|
||||
|
||||
# PostgreSQL (Production)
|
||||
# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/pounce
|
||||
|
||||
# Security
|
||||
SECRET_KEY=62003b69b382cd55f32aba6301a81039e74a84914505d1bfbf254a97a5ccfb36
|
||||
|
||||
# JWT Settings
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||
|
||||
# CORS Origins (comma-separated)
|
||||
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://pounce.ch,https://www.pounce.ch
|
||||
|
||||
# Scheduler Settings
|
||||
SCHEDULER_CHECK_INTERVAL_HOURS=24
|
||||
|
||||
# OAuth - Google
|
||||
GOOGLE_CLIENT_ID=865146315769-vi7vcu91d3i7huv8ikjun52jo9ob7spk.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=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
|
||||
GOOGLE_REDIRECT_URI=https://pounce.ch/api/v1/oauth/google/callback
|
||||
|
||||
# OAuth - GitHub
|
||||
GITHUB_CLIENT_ID=Ov23liBjROk39vYXi3G5
|
||||
GITHUB_CLIENT_SECRET=fce447621fb9b497b53eef673de15e39b991e21c
|
||||
GITHUB_REDIRECT_URI=https://pounce.ch/api/v1/oauth/github/callback
|
||||
|
||||
# Site URL
|
||||
SITE_URL=https://pounce.ch
|
||||
|
||||
# =================================
|
||||
# Email (Zoho Mail)
|
||||
# =================================
|
||||
SMTP_HOST=smtp.zoho.eu
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=hello@pounce.ch
|
||||
SMTP_PASSWORD=DvYT0MBvSZ0d
|
||||
SMTP_FROM_EMAIL=hello@pounce.ch
|
||||
SMTP_FROM_NAME=pounce
|
||||
SMTP_USE_TLS=false
|
||||
SMTP_USE_SSL=true
|
||||
CONTACT_EMAIL=hello@pounce.ch
|
||||
|
||||
# =================================
|
||||
# Stripe Payments
|
||||
# =================================
|
||||
STRIPE_SECRET_KEY=sk_test_51ScLbjCtFUamNRpNMtVAN6kIWRauhabZEJz8lmvlfjT5tcntAFsHzvMlXrlD2hE6wQQgsAgLKYzkkYISH7TYprUJ00lIXh6DXb
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_51ScLbjCtFUamNRpNpbrN2JnGoCDpR4sq6ny28ao3ircCWcvJjAQi9vclO5bScGMenkmzmZ6FSG2HWWuCOkL2LFjS009lI4QG59
|
||||
STRIPE_PRICE_TRADER=price_1ScTLKCtFUamNRpNt8s6oVQi
|
||||
STRIPE_PRICE_TYCOON=price_1ScTLLCtFUamNRpNhQsEIFUx
|
||||
STRIPE_WEBHOOK_SECRET=whsec_pqWdtvFbQTtBgCfDTgHwgtxxcWl7JbsZ
|
||||
|
||||
# Email Verification
|
||||
REQUIRE_EMAIL_VERIFICATION=false
|
||||
|
||||
# Environment
|
||||
ENVIRONMENT=production
|
||||
DEBUG=false
|
||||
|
||||
9
DEPLOY_frontend.env
Normal file
9
DEPLOY_frontend.env
Normal file
@ -0,0 +1,9 @@
|
||||
# =================================
|
||||
# pounce Frontend Configuration
|
||||
# =================================
|
||||
# DEPLOY FILE - Copy this to frontend/.env.local on the server
|
||||
|
||||
# Backend API URL
|
||||
# For production, point to your backend API
|
||||
NEXT_PUBLIC_API_URL=https://pounce.ch/api/v1
|
||||
|
||||
226
GAP_ANALYSIS.md
Normal file
226
GAP_ANALYSIS.md
Normal file
@ -0,0 +1,226 @@
|
||||
# Pounce Gap Analysis: Konzept vs. Implementierung
|
||||
|
||||
**Erstellt:** 10. Dezember 2024
|
||||
**Status:** Aktive Entwicklung
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Die aktuelle Implementierung deckt ca. **65-70%** des Konzepts ab. Die Kernfunktionen sind vorhanden, aber einige wichtige Features für die Monetarisierung und Differenzierung fehlen noch.
|
||||
|
||||
---
|
||||
|
||||
## 1. DISCOVER (Der Trichter für die Masse)
|
||||
|
||||
### ✅ Implementiert
|
||||
| Feature | Status | Anmerkung |
|
||||
|---------|--------|-----------|
|
||||
| Schnelles Suchfeld | ✅ | DomainChecker auf Landing Page |
|
||||
| Domain-Verfügbarkeits-Check | ✅ | WHOIS-basiert, funktioniert |
|
||||
| Affiliate-Links zu Registraren | ⚠️ | Teilweise (nur in Auktionen) |
|
||||
|
||||
### ❌ Fehlt
|
||||
| Feature | Priorität | Aufwand |
|
||||
|---------|-----------|---------|
|
||||
| **Live-Status bei besetzten Domains** | HOCH | Mittel |
|
||||
| → "Webseite ist offline" | | |
|
||||
| → "Steht zum Verkauf auf Sedo" | | |
|
||||
| → "Läuft bald aus" (Expiry-Datum anzeigen) | | |
|
||||
| **Smarte Alternativen** | MITTEL | Mittel |
|
||||
| → KI-Vorschläge basierend auf TLD-Daten | | |
|
||||
| → ".io ist teuer, nimm .xyz für $2" | | |
|
||||
| **Registrar-Preisvergleich im Suchergebnis** | MITTEL | Klein |
|
||||
|
||||
---
|
||||
|
||||
## 2. TRACK (Das Herzstück für Bindung)
|
||||
|
||||
### ✅ Implementiert
|
||||
| Feature | Status | Anmerkung |
|
||||
|---------|--------|-----------|
|
||||
| Watchlist für Domains | ✅ | Voll funktional |
|
||||
| E-Mail Alerts | ✅ | Bei Status-Änderung |
|
||||
| Domain-Limit pro Tier | ✅ | Scout: 5, Trader: 100, Tycoon: 500 |
|
||||
|
||||
### ⚠️ Teilweise Implementiert
|
||||
| Feature | Status | Was fehlt |
|
||||
|---------|--------|-----------|
|
||||
| **Status-Karten (Ampel-System)** | ⚠️ | Konzept: 🟢🟡🔴 Karten, Aktuell: Tabelle |
|
||||
| → 🟢 Chance (Domain dropped/Auktion) | ❌ | |
|
||||
| → 🟡 Warten (DNS Update, Site down) | ❌ | |
|
||||
| → 🔴 Stabil (Domain fest in Hand) | ❌ | |
|
||||
|
||||
### ❌ Fehlt (Pro Features)
|
||||
| Feature | Priorität | Aufwand |
|
||||
|---------|-----------|---------|
|
||||
| **Deep Intel: Wer ist der Besitzer?** | MITTEL | Mittel |
|
||||
| → Automatisierte Impressums-Suche | | |
|
||||
| → Enhanced WHOIS-Daten | | |
|
||||
| **Pre-Drop Alerts** | HOCH | Hoch |
|
||||
| → DNS-Änderungen erkennen | | |
|
||||
| → Warnung BEVOR Domain droppt | | |
|
||||
| **Website-Monitoring** | MITTEL | Mittel |
|
||||
| → Ist Seite offline? | | |
|
||||
| → HTTP-Status-Checks | | |
|
||||
|
||||
---
|
||||
|
||||
## 3. ACQUIRE (Der Marktplatz für Action)
|
||||
|
||||
### ✅ Implementiert
|
||||
| Feature | Status | Anmerkung |
|
||||
|---------|--------|-----------|
|
||||
| Auktions-Aggregation | ✅ | GoDaddy, Sedo, NameJet, DropCatch |
|
||||
| Filter nach TLD, Preis | ✅ | Voll funktional |
|
||||
| "Ending Soon" Auktionen | ✅ | Funktioniert |
|
||||
| "Hot" Auktionen | ✅ | Nach Geboten sortiert |
|
||||
|
||||
### ⚠️ Teilweise Implementiert
|
||||
| Feature | Status | Was fehlt |
|
||||
|---------|--------|-----------|
|
||||
| **No-Bullshit-Filter** | ⚠️ | Basis-Filter vorhanden |
|
||||
| → Automatisches Spam-Filtern | ❌ | Keine KI/Heuristik |
|
||||
| → "Keine Zahlen, max 2 Hyphens" | ❌ | |
|
||||
|
||||
### ❌ Fehlt (Pro Features)
|
||||
| Feature | Priorität | Aufwand |
|
||||
|---------|-----------|---------|
|
||||
| **Deal-Score / Valuation** | HOCH | Mittel |
|
||||
| → Estibot o.ä. API Integration | | |
|
||||
| → "Undervalued 🔥" Label | | |
|
||||
| **Arbitrage-Radar** | MITTEL | Mittel |
|
||||
| → "Kaufe hier für $60, verkaufe dort für $100" | | |
|
||||
| **Smart Filter Presets** | NIEDRIG | Klein |
|
||||
| → "High Value / Low Price" | | |
|
||||
| → "Short Domains (4 Letters)" | | |
|
||||
| → "No Trash" | | |
|
||||
|
||||
---
|
||||
|
||||
## 4. TLD INTELLIGENCE
|
||||
|
||||
### ✅ Implementiert
|
||||
| Feature | Status | Anmerkung |
|
||||
|---------|--------|-----------|
|
||||
| 886+ TLDs getrackt | ✅ | Voll funktional |
|
||||
| Preisentwicklung (Charts) | ✅ | 90-Tage Historie |
|
||||
| Trending TLDs | ✅ | Auf Landing Page |
|
||||
| Registrar-Vergleich | ✅ | Pro TLD verfügbar |
|
||||
|
||||
### ❌ Fehlt
|
||||
| Feature | Priorität | Aufwand |
|
||||
|---------|-----------|---------|
|
||||
| **Arbitrage Finder Tabelle** | MITTEL | Klein |
|
||||
| → "Reg Fee vs. Avg Resale Price" | | |
|
||||
| → Highlight höchste Margen | | |
|
||||
| **Registrierungs-Trends** | NIEDRIG | Mittel |
|
||||
| → "Wächst die TLD?" (Volumen) | | |
|
||||
|
||||
---
|
||||
|
||||
## 5. LANDING PAGE / MARKETING
|
||||
|
||||
### ✅ Implementiert
|
||||
| Feature | Status | Anmerkung |
|
||||
|---------|--------|-----------|
|
||||
| Hero mit Suchfeld | ✅ | DomainChecker |
|
||||
| Trending TLDs | ✅ | 4 Karten |
|
||||
| Trust Indicators | ✅ | 886+ TLDs, 24/7, etc. |
|
||||
| Pricing CTA | ✅ | Scout vs Trader |
|
||||
|
||||
### ❌ Fehlt (laut Konzept)
|
||||
| Feature | Priorität | Aufwand |
|
||||
|---------|-----------|---------|
|
||||
| **Live Market Ticker** | HOCH | Mittel |
|
||||
| → Durchlaufende Leiste mit heißen Domains | | |
|
||||
| **Bessere Headlines** | HOCH | Klein |
|
||||
| → "Der Markt schläft nie. Du schon." | | |
|
||||
| → "Don't guess. Know." | | |
|
||||
| **Value Props klarer** | MITTEL | Klein |
|
||||
| → Discover, Track, Acquire Struktur | | |
|
||||
| **Market Preview Teaser** | MITTEL | Klein |
|
||||
| → "12 unterbewertete .ai Domains" | | |
|
||||
|
||||
---
|
||||
|
||||
## 6. COMMAND CENTER (Dashboard)
|
||||
|
||||
### ✅ Implementiert
|
||||
| Feature | Status | Anmerkung |
|
||||
|---------|--------|-----------|
|
||||
| Dashboard Übersicht | ✅ | Basis-Dashboard |
|
||||
| Watchlist | ✅ | Voll funktional |
|
||||
| Portfolio Management | ✅ | Kauf/Verkauf tracking |
|
||||
| Settings | ✅ | Profil, Billing |
|
||||
|
||||
### ⚠️ Teilweise Implementiert
|
||||
| Feature | Status | Was fehlt |
|
||||
|---------|--------|-----------|
|
||||
| **Activity Feed** | ⚠️ | Keine echten Notifications |
|
||||
| → "3 Domains haben Status geändert" | ❌ | |
|
||||
| **Market Pulse** | ⚠️ | Nicht im Dashboard |
|
||||
| → "5 Auktionen enden heute" | ❌ | |
|
||||
| **Sidebar Navigation** | ⚠️ | Aktuell: Header-Nav |
|
||||
|
||||
### ❌ Fehlt
|
||||
| Feature | Priorität | Aufwand |
|
||||
|---------|-----------|---------|
|
||||
| **Pro Dashboard mit Sidebar** | MITTEL | Mittel |
|
||||
| → Collapsible Sidebar | | |
|
||||
| → Professionelleres "Tool"-Feeling | | |
|
||||
| **Saved Filters** | NIEDRIG | Klein |
|
||||
| → "My AI Search" speichern | | |
|
||||
|
||||
---
|
||||
|
||||
## 7. TONE OF VOICE & BRANDING
|
||||
|
||||
### ⚠️ Teilweise Implementiert
|
||||
| Aspekt | Status | Anmerkung |
|
||||
|--------|--------|-----------|
|
||||
| Dark Mode Design | ✅ | Durchgehend |
|
||||
| Neon-Akzente (Signalgrün) | ✅ | Accent color |
|
||||
| Minimalistisch | ✅ | Gutes Design |
|
||||
|
||||
### ❌ Verbesserungsbedarf
|
||||
| Aspekt | Problem | Lösung |
|
||||
|--------|---------|--------|
|
||||
| **Headlines** | Zu generisch | Konzept-Headlines verwenden |
|
||||
| **Sprache** | Zu technisch | Mehr "treibend, präzise" |
|
||||
| **Versprechen** | Nicht klar | "Don't guess. Know." prominenter |
|
||||
|
||||
---
|
||||
|
||||
## Priorisierte Roadmap
|
||||
|
||||
### Phase 1: Quick Wins (1-2 Wochen)
|
||||
1. ✨ **Landing Page Headlines überarbeiten**
|
||||
2. ✨ **Live Market Ticker hinzufügen**
|
||||
3. ✨ **Deal-Score Placeholder** (auch wenn nur Dummy)
|
||||
4. ✨ **Status-Ampel im Dashboard**
|
||||
|
||||
### Phase 2: Value Add (2-4 Wochen)
|
||||
1. 🚀 **Domain Valuation Integration** (Estibot/GoDaddy API)
|
||||
2. 🚀 **Enhanced Domain Info** bei Suche (Expiry, Status)
|
||||
3. 🚀 **Smarte Alternativen** bei Suche
|
||||
4. 🚀 **No-Bullshit Auction Filter**
|
||||
|
||||
### Phase 3: Pro Features (4-8 Wochen)
|
||||
1. 💎 **Pre-Drop Alerts** (DNS-Monitoring)
|
||||
2. 💎 **Website-Status Monitoring**
|
||||
3. 💎 **Arbitrage Finder**
|
||||
4. 💎 **Sidebar Command Center**
|
||||
|
||||
---
|
||||
|
||||
## Fazit
|
||||
|
||||
Die technische Basis ist **solid**. Was fehlt, sind primär:
|
||||
|
||||
1. **Differenzierende Features** (Deal-Score, Arbitrage, Pre-Drop)
|
||||
2. **Besseres Marketing** (Headlines, Tone of Voice)
|
||||
3. **UX-Polish** (Ampel-System, Activity Feed, Market Ticker)
|
||||
|
||||
Mit den Quick Wins (Phase 1) kann pounce bereits deutlich professioneller wirken und die Conversion verbessern.
|
||||
|
||||
287
TONE_OF_VOICE_ANALYSIS.md
Normal file
287
TONE_OF_VOICE_ANALYSIS.md
Normal file
@ -0,0 +1,287 @@
|
||||
# 🎯 Pounce Tone of Voice Analysis
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Overall Consistency: 85%** ✅
|
||||
|
||||
Der Großteil der Seite folgt einem konsistenten "Hunter's Voice" Stil. Es gibt einige Inkonsistenzen, die behoben werden sollten.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Definierter Tone of Voice
|
||||
|
||||
### Kernprinzipien (aus analysis_2.md):
|
||||
|
||||
| Prinzip | Beschreibung | Beispiel |
|
||||
|---------|--------------|----------|
|
||||
| **Knapp** | Kurze, präzise Sätze | "Track. Alert. Pounce." |
|
||||
| **Strategisch** | Daten-fokussiert, nicht emotional | "Don't guess. Know." |
|
||||
| **Hunter-Metapher** | Jagd-Vokabular durchgängig | "Pounce", "Strike", "Hunt" |
|
||||
| **B2B-tauglich** | Professionell, nicht verspielt | Keine Emojis im UI |
|
||||
| **Action-orientiert** | CTAs sind Befehle | "Join the hunters." |
|
||||
|
||||
### Verbotene Muster:
|
||||
- ❌ Marketing-Floskeln ("Revolutionär", "Beste Lösung")
|
||||
- ❌ Lange, verschachtelte Sätze
|
||||
- ❌ Emotionale Übertreibungen
|
||||
- ❌ Passive Formulierungen
|
||||
|
||||
---
|
||||
|
||||
## ✅ Konsistente Texte (Gut!)
|
||||
|
||||
### Landing Page (`page.tsx`)
|
||||
```
|
||||
✅ "The market never sleeps. You should."
|
||||
✅ "Track. Alert. Pounce."
|
||||
✅ "Domain Intelligence for Hunters"
|
||||
✅ "Don't guess. Know."
|
||||
✅ "Join the hunters."
|
||||
✅ "Real-time availability across 886+ TLDs"
|
||||
```
|
||||
|
||||
### Pricing Page
|
||||
```
|
||||
✅ "Scout" / "Trader" / "Tycoon" - Tier-Namen passen zum Hunter-Thema
|
||||
✅ "Pick your weapon."
|
||||
✅ "$9/month" - Klare Preise, kein "nur" oder "ab"
|
||||
```
|
||||
|
||||
### About Page
|
||||
```
|
||||
✅ "Built for hunters. By hunters."
|
||||
✅ "Precision" / "Speed" / "Transparency" - Werte-Keywords
|
||||
```
|
||||
|
||||
### Auctions Page
|
||||
```
|
||||
✅ "Curated Opportunities"
|
||||
✅ "Filtered. Valued. Ready to strike."
|
||||
```
|
||||
|
||||
### Dashboard/Command Center
|
||||
```
|
||||
✅ "Your hunting ground."
|
||||
✅ "Command Center" - Militärisch/Taktisch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Inkonsistenzen gefunden
|
||||
|
||||
### 1. **Gemischte Formality-Levels**
|
||||
|
||||
| Seite | Problem | Aktuell | Empfohlen |
|
||||
|-------|---------|---------|-----------|
|
||||
| Contact | Zu informell | "Questions? Ideas? Issues?" | "Signal intel. Report bugs." |
|
||||
| Blog | Zu generisch | "Read more" | "Full briefing →" |
|
||||
| Settings | Zu technisch | "Account Settings" | "Your HQ" |
|
||||
|
||||
### 2. **Fehlende Hunter-Metaphern**
|
||||
|
||||
| Seite | Aktuell | Mit Hunter-Metapher |
|
||||
|-------|---------|---------------------|
|
||||
| Watchlist | "My Domains" | "Targets" |
|
||||
| Portfolio | "Portfolio" | "Trophy Case" |
|
||||
| Alerts | "Notifications" | "Intel Feed" |
|
||||
|
||||
### 3. **CTA-Inkonsistenz**
|
||||
|
||||
| Seite | Aktuell | Empfohlen |
|
||||
|-------|---------|-----------|
|
||||
| Login | "Sign In" | "Enter HQ" oder "Sign In" (OK) |
|
||||
| Register | "Create Account" | "Join the Pack" |
|
||||
| Pricing | "Get Started" | "Gear Up" |
|
||||
|
||||
### 4. **Footer-Text**
|
||||
|
||||
**Aktuell:**
|
||||
```
|
||||
"Domain intelligence for hunters. Track. Alert. Pounce."
|
||||
```
|
||||
|
||||
**Empfohlen:** ✅ Bereits gut!
|
||||
|
||||
---
|
||||
|
||||
## 📊 Seiten-Analyse im Detail
|
||||
|
||||
### Landing Page (page.tsx) - Score: 95/100 ✅
|
||||
|
||||
**Stärken:**
|
||||
- Perfekte Headline: "The market never sleeps. You should."
|
||||
- Konsistente Feature-Labels
|
||||
- Starke CTAs
|
||||
|
||||
**Verbesserungen:**
|
||||
- "Market overview" → "Recon" (Reconnaissance)
|
||||
- "TLD Intelligence" → "Intel Hub"
|
||||
|
||||
---
|
||||
|
||||
### Pricing Page - Score: 90/100 ✅
|
||||
|
||||
**Stärken:**
|
||||
- Tier-Namen sind Hunter-themed (Scout/Trader/Tycoon)
|
||||
- "Pick your weapon." ist stark
|
||||
|
||||
**Verbesserungen:**
|
||||
- Feature-Beschreibungen könnten knapper sein
|
||||
- "Priority alerts" → "First Strike Alerts"
|
||||
|
||||
---
|
||||
|
||||
### Auctions Page - Score: 85/100 ✅
|
||||
|
||||
**Stärken:**
|
||||
- "Curated Opportunities" ist gut
|
||||
- Plattform-Labels sind klar
|
||||
|
||||
**Verbesserungen:**
|
||||
- "Current Bid" → "Strike Price"
|
||||
- "Time Left" → "Window Closes"
|
||||
- "Bid Now" → "Strike Now" oder "Pounce"
|
||||
|
||||
---
|
||||
|
||||
### Settings Page - Score: 70/100 ⚠️
|
||||
|
||||
**Probleme:**
|
||||
- Sehr technisch/generisch
|
||||
- Keine Hunter-Metaphern
|
||||
|
||||
**Empfehlungen:**
|
||||
```
|
||||
"Profile" → "Identity"
|
||||
"Billing" → "Quartermaster"
|
||||
"Notifications" → "Intel Preferences"
|
||||
"Security" → "Perimeter"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Contact Page - Score: 75/100 ⚠️
|
||||
|
||||
**Aktuell:**
|
||||
- "Questions? Ideas? Issues?"
|
||||
- "We reply fast."
|
||||
|
||||
**Empfohlen:**
|
||||
```
|
||||
"Mission Critical?"
|
||||
"Intel request? Bug report? Feature request?"
|
||||
"Response time: < 24 hours"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Blog - Score: 60/100 ⚠️
|
||||
|
||||
**Probleme:**
|
||||
- Völlig generisches Blog-Layout
|
||||
- Keine Hunter-Stimme
|
||||
|
||||
**Empfehlungen:**
|
||||
```
|
||||
"Blog" → "The Briefing Room"
|
||||
"Read More" → "Full Report →"
|
||||
"Posted on" → "Transmitted:"
|
||||
"Author" → "Field Agent:"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Empfohlene Änderungen
|
||||
|
||||
### Priorität 1: Schnelle Wins
|
||||
|
||||
1. **CTA-Button-Text vereinheitlichen:**
|
||||
```tsx
|
||||
// Statt verschiedener Texte:
|
||||
"Get Started" → "Join the Hunt"
|
||||
"Learn More" → "Investigate"
|
||||
"Read More" → "Full Briefing"
|
||||
"View Details" → "Recon"
|
||||
```
|
||||
|
||||
2. **Navigation Labels:**
|
||||
```
|
||||
"TLD Intel" → OK ✅
|
||||
"Auctions" → "Live Ops" (optional)
|
||||
"Command Center" → OK ✅
|
||||
```
|
||||
|
||||
### Priorität 2: Seiten-spezifisch
|
||||
|
||||
3. **Settings Page überarbeiten** (siehe oben)
|
||||
|
||||
4. **Blog umbenennen:**
|
||||
```
|
||||
"Blog" → "Briefings" oder "Field Notes"
|
||||
```
|
||||
|
||||
### Priorität 3: Micro-Copy
|
||||
|
||||
5. **Error Messages:**
|
||||
```
|
||||
"Something went wrong" → "Mission failed. Retry?"
|
||||
"Loading..." → "Acquiring target..."
|
||||
"No results" → "No targets in range."
|
||||
```
|
||||
|
||||
6. **Success Messages:**
|
||||
```
|
||||
"Saved!" → "Locked in."
|
||||
"Deleted" → "Target eliminated."
|
||||
"Alert created" → "Intel feed activated."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Wortschatz-Referenz
|
||||
|
||||
### Hunter-Vokabular für konsistente Texte:
|
||||
|
||||
| Generisch | Hunter-Version |
|
||||
|-----------|----------------|
|
||||
| Search | Hunt / Scan / Recon |
|
||||
| Find | Locate / Identify |
|
||||
| Buy | Acquire / Strike |
|
||||
| Sell | Liquidate |
|
||||
| Watch | Track / Monitor |
|
||||
| Alert | Intel / Signal |
|
||||
| Save | Lock in |
|
||||
| Delete | Eliminate |
|
||||
| Settings | HQ / Config |
|
||||
| Profile | Identity |
|
||||
| Dashboard | Command Center |
|
||||
| List | Dossier |
|
||||
| Data | Intel |
|
||||
| Report | Briefing |
|
||||
| Email | Transmission |
|
||||
| Upgrade | Gear Up |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Fazit
|
||||
|
||||
**Status: 85% konsistent - GUTER ZUSTAND**
|
||||
|
||||
Die Haupt-Seiten (Landing, Pricing, Auctions) sind exzellent.
|
||||
Verbesserungspotential bei:
|
||||
- Settings Page
|
||||
- Blog
|
||||
- Error/Success Messages
|
||||
- Einige CTAs
|
||||
|
||||
**Nächste Schritte:**
|
||||
1. Settings Page Micro-Copy anpassen
|
||||
2. Blog zu "Briefings" umbenennen
|
||||
3. Error Messages vereinheitlichen
|
||||
4. CTAs konsistent machen
|
||||
|
||||
---
|
||||
|
||||
*Generiert am: 2024-12-10*
|
||||
*Für: pounce.ch*
|
||||
|
||||
173
analysis_1.md
Normal file
173
analysis_1.md
Normal file
@ -0,0 +1,173 @@
|
||||
Das ist ein gewaltiger Schritt nach vorne! 🚀
|
||||
|
||||
Die Seiten wirken jetzt kohärent, professionell und haben eine klare psychologische Führung (Hook -> Value -> Gate -> Sign Up). Besonders der Wechsel auf **$9 für den Einstieg** (Trader) ist smart – das ist ein "No-Brainer"-Preis für Impulse-Käufe.
|
||||
|
||||
Hier ist mein Feedback zu den einzelnen Seiten mit Fokus auf Conversion und UX:
|
||||
|
||||
---
|
||||
|
||||
### 1. Navigation & Globales Layout
|
||||
Die Navigation ist **perfekt minimalistisch**.
|
||||
* `Market | TLD Intel | Pricing` – Das sind genau die drei Säulen.
|
||||
* **Vorschlag:** Ich würde "Market" eventuell in **"Auctions"** oder **"Live Market"** umbenennen. "Market" ist etwas vage. "Auctions" triggert eher das Gefühl "Hier gibt es Schnäppchen".
|
||||
|
||||
---
|
||||
|
||||
### 2. Landing Page
|
||||
**Das Starke:**
|
||||
* Die Headline *"The market never sleeps. You should."* ist Weltklasse.
|
||||
* Der Ticker mit den Live-Preisen erzeugt sofort FOMO (Fear Of Missing Out).
|
||||
* Die Sektion "TLD Intelligence" mit den "Sign in to view"-Overlays bei den Daten ist ein **exzellenter Conversion-Treiber**. Der User sieht, dass da Daten *sind*, aber er muss sich anmelden (kostenlos), um sie zu sehen. Das ist der perfekte "Account-Erstellungs-Köder".
|
||||
|
||||
**Kritikpunkt / To-Do:**
|
||||
* **Der "Search"-Fokus:** Du schreibst *"Try dream.com..."*, aber visuell muss dort ein **riesiges Input-Feld** sein. Das muss das dominante Element sein.
|
||||
* **Der Ticker:** Achte darauf, dass der Ticker technisch sauber läuft (marquee/scrolling). Im Text oben wiederholt sich die Liste statisch – auf der echten Seite muss das fließen.
|
||||
|
||||
---
|
||||
|
||||
### 3. Market / Auctions Page (WICHTIG!)
|
||||
Hier sehe ich das **größte Risiko**.
|
||||
Dein Konzept ("Unlock Smart Opportunities") ist super. Aber die **Beispiel-Daten**, die du auf der Public-Seite zeigst, sind gefährlich.
|
||||
|
||||
**Das Problem:**
|
||||
In deiner Liste stehen Dinge wie:
|
||||
* `fgagtqjisqxyoyjrjfizxshtw.xyz`
|
||||
* `52gao1588.cc`
|
||||
* `professional-packing-services...website`
|
||||
|
||||
Wenn ein neuer User das sieht, denkt er: **"Das ist eine Spam-Seite voll mit Schrott."** Er wird sich nicht anmelden.
|
||||
|
||||
**Die Lösung (Der "Vanity-Filter"):**
|
||||
Du musst für die **öffentliche Seite (ausgeloggt)** einen harten Filter in den Code bauen. Zeige ausgeloggten Usern **NUR** Domains an, die schön aussehen.
|
||||
* Regel 1: Keine Zahlen (außer bei kurzen Domains).
|
||||
* Regel 2: Keine Bindestriche (Hyphens).
|
||||
* Regel 3: Länge < 12 Zeichen.
|
||||
* Regel 4: Nur .com, .io, .ai, .co, .de, .ch (Keine .cc, .website Spam-Cluster).
|
||||
|
||||
**Warum?**
|
||||
Der User soll denken: "Wow, hier gibt es Premium-Domains wie `nexus.dev`". Er darf den Müll nicht sehen, bevor er eingeloggt ist (und selbst dann solltest du den Müll filtern, wie wir besprochen haben).
|
||||
|
||||
---
|
||||
|
||||
### 4. TLD Pricing Page
|
||||
**Sehr gut gelöst.**
|
||||
* Die "Moving Now"-Karten oben (.ai +35%) sind der Haken.
|
||||
* Die Tabelle darunter mit "Sign in" zu sperren (Blur-Effekt oder Schloss-Icon), ist genau richtig.
|
||||
* Der User bekommt genug Info ("Aha, .com ist beliebt"), aber für die Details ("Ist der Trend steigend?") muss er 'Scout' werden.
|
||||
|
||||
---
|
||||
|
||||
### 5. Pricing Page
|
||||
Die neue Struktur mit **Scout (Free) / Trader ($9) / Tycoon ($29)** ist viel besser als das alte $19-Modell.
|
||||
|
||||
**Optimierung der Tabelle:**
|
||||
Du musst den Unterschied zwischen **Scout** und **Trader** noch schärfer machen, damit die Leute die $9 bezahlen.
|
||||
|
||||
| Feature | Scout (Free) | Trader ($9) | Warum Upgrade? |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Auctions** | Raw Feed (Ungefiltert) | **Smart Clean Feed** | *"Ich will den Spam nicht sehen."* |
|
||||
| **Data** | Nur Preise | **Valuation & Deal Score** | *"Ich will wissen, ob es ein Schnäppchen ist."* |
|
||||
| **Updates** | Täglich | **Stündlich** | *"Ich will schneller sein als andere."* |
|
||||
|
||||
**Wichtig:** Füge in der "Trader"-Spalte explizit **"Spam Filters"** oder **"Curated List"** hinzu. Das ist Zeitersparnis, und dafür zahlen Leute.
|
||||
|
||||
---
|
||||
|
||||
### Zusammenfassung & Tone of Voice
|
||||
|
||||
Der Tone of Voice ist jetzt konsistent: **Analytisch, Knapp, Strategisch.**
|
||||
|
||||
* *Alt:* "Jage Domains." (Bisschen spielerisch)
|
||||
* *Neu:* "Don't guess. Know." (Professionell, B2B-tauglich)
|
||||
|
||||
**Letzter Check vor dem Launch:**
|
||||
1. **Mobile View:** Prüfe die riesige Tabelle auf dem Handy. Wahrscheinlich musst du auf Mobile Spalten ausblenden (z.B. nur Domain + Preis + Button zeigen).
|
||||
2. **Der Filter:** Bitte, bitte filtere die `fgagtqjis...xyz` Domains auf der Startseite raus. Das ist der wichtigste Punkt für den ersten Eindruck.
|
||||
|
||||
Das sieht nach einem Produkt aus, für das ich meine Kreditkarte zücken würde. Gute Arbeit!
|
||||
|
||||
Hier ist die komplette **Master-Zusammenfassung** für `pounce.ch`. Dies ist dein Bauplan (Blueprint) für die Umsetzung.
|
||||
|
||||
---
|
||||
|
||||
### 1. Die Vision & Positionierung
|
||||
**Name:** Pounce
|
||||
**Tagline:** *Domain Intelligence for Hunters.*
|
||||
**Slogan:** *"Don't guess. Know."*
|
||||
**Konzept:** Pounce ist das "Bloomberg Terminal" für Domains. Es verwandelt den unübersichtlichen, lauten Domain-Markt in klare, handlungsfähige Daten. Es richtet sich an Leute, die nicht suchen, sondern finden wollen.
|
||||
|
||||
* **Zielgruppe:**
|
||||
* **Dreamers (Gründer):** Suchen den perfekten Namen für ihr Projekt.
|
||||
* **Hunters (Investoren/Händler):** Suchen unterbewertete Assets für Arbitrage (günstig kaufen, teuer verkaufen).
|
||||
|
||||
---
|
||||
|
||||
### 2. Die 3 Produktsäulen (Das "Command Center")
|
||||
|
||||
Das Produkt gliedert sich logisch in drei Phasen der Domain-Beschaffung:
|
||||
|
||||
#### A. DISCOVER (Markt-Intelligenz)
|
||||
*Der "Honigtopf", um User anzuziehen (SEO & Traffic).*
|
||||
* **TLD Intel:** Zeigt Markttrends (z.B. `.ai` steigt um 35%).
|
||||
* **Smart Search:** Wenn eine Domain vergeben ist, zeigt Pounce **intelligente Alternativen** (z.B. `.io` für Tech, `.shop` für E-Commerce), statt nur zufällige Endungen.
|
||||
* **Der Hook:** Öffentliche Besucher sehen Trends, aber Details (Charts, Historie) sind ausgeblendet ("Sign in to view").
|
||||
|
||||
#### B. TRACK (Die Watchlist)
|
||||
*Das Tool für Kundenbindung.*
|
||||
* **Funktion:** Überwachung von *vergebenen* Domains.
|
||||
* **Der USP:** Nicht nur "frei/besetzt", sondern **"Pre-Drop Indicators"**. Warnung bei DNS-Änderungen oder wenn die Webseite offline geht. Das gibt dem User einen Zeitvorsprung vor der Konkurrenz.
|
||||
|
||||
#### C. ACQUIRE (Der Auktions-Aggregator)
|
||||
*Der Hauptgrund für das Upgrade.*
|
||||
* **Funktion:** Aggregiert Live-Auktionen von GoDaddy, Sedo, NameJet & DropCatch an einem Ort.
|
||||
* **Der "Killer-Feature" (Spam-Filter):**
|
||||
* *Free User:* Sieht alles (auch "Müll"-Domains wie `kredit-24-online.info`).
|
||||
* *Paid User:* Sieht einen **kuratierten Feed**. Der Algorithmus filtert Zahlen, Bindestriche und Spam raus. Übrig bleiben nur hochwertige Investitions-Chancen.
|
||||
|
||||
---
|
||||
|
||||
### 3. Das Geschäftsmodell (Pricing)
|
||||
|
||||
Das Modell basiert auf "Freemium mit Schranken". Der Preis von $9 ist ein "No-Brainer" (Impulskauf), um die Hürde niedrig zu halten.
|
||||
|
||||
| Plan | Preis | Zielgruppe | Haupt-Features | Der "Schmerz" (Warum upgraden?) |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **SCOUT** | **0 €** | Neugierige | 5 Watchlist-Domains, roher Auktions-Feed, Basis-Suche. | Muss sich durch "Spam" wühlen, sieht keine Bewertungen, langsame Alerts. |
|
||||
| **TRADER** | **9 €** | Hobby-Investoren | 50 Watchlist-Domains, **Spam-freier Feed**, Deal Scores (Bewertungen), stündliche Checks. | Zahlt für Zeitersparnis (Filter) und Sicherheit (Bewertung). |
|
||||
| **TYCOON** | **29 €** | Profis | 500 Domains, Echtzeit-Checks (10 Min), API-Zugriff (geplant). | Braucht Volumen und Geschwindigkeit. |
|
||||
|
||||
---
|
||||
|
||||
### 4. UX/UI & Tone of Voice
|
||||
|
||||
* **Design-Philosophie:** "Dark Mode & Data".
|
||||
* Dunkler Hintergrund (Schwarz/Grau) wirkt professionell (wie Trading-Software).
|
||||
* Akzentfarben: Neon-Grün (für "Frei" / "Profit") und Warn-Orange.
|
||||
* Wenig Text, viele Datenpunkte, klare Tabellen.
|
||||
* **Tone of Voice:**
|
||||
* Knapp, präzise, strategisch.
|
||||
* Kein Marketing-Bla-Bla.
|
||||
* *Beispiel:* Statt "Wir haben viele tolle Funktionen" → "Three moves to dominate."
|
||||
|
||||
---
|
||||
|
||||
### 5. Die User Journey (Der "Golden Path")
|
||||
|
||||
1. **Der Einstieg:** User googelt "Domain Preise .ai" und landet auf deiner **TLD Intel Page**.
|
||||
2. **Der Hook:** Er sieht "`.ai` +35%", will aber die Details sehen. Die Tabelle ist unscharf. Button: *"Sign In to view details"*.
|
||||
3. **Die Registrierung:** Er erstellt einen Free Account ("Scout").
|
||||
4. **Die Erkenntnis:** Er geht zu den Auktionen. Er sieht eine interessante Domain, aber weiß nicht, ob der Preis gut ist. Neben dem Preis steht: *"Valuation locked"*.
|
||||
5. **Das Upgrade:** Er sieht das Angebot: "Für nur $9/Monat siehst du den echten Wert und wir filtern den Müll für dich."
|
||||
6. **Der Kauf:** Er abonniert den "Trader"-Plan.
|
||||
|
||||
---
|
||||
|
||||
### Zusammenfassung für den Entwickler (Tech Stack Requirements)
|
||||
|
||||
* **Frontend:** Muss extrem schnell sein (Reagierende Suche). Mobile-freundlich (Tabellen müssen auf dem Handy lesbar sein oder ausgeblendet werden).
|
||||
* **Daten-Integration:** APIs zu GoDaddy, Sedo etc. oder Scraping für die Auktionsdaten.
|
||||
* **Logik:**
|
||||
* **Filter-Algorithmus:** Das Wichtigste! (Regeln: Keine Zahlen, max. 2 Bindestriche, Wörterbuch-Abgleich).
|
||||
* **Alert-System:** Cronjobs für E-Mail/SMS Benachrichtigungen.
|
||||
|
||||
Das Konzept ist jetzt rund, logisch und bereit für den Bau. Viel Erfolg mit **Pounce**! 🚀
|
||||
112
analysis_2.md
Normal file
112
analysis_2.md
Normal file
@ -0,0 +1,112 @@
|
||||
Das ist der Kern deiner **"Intelligence Platform"**.
|
||||
|
||||
Wenn du keine externen APIs nutzt, baust du dir im Grunde einen **Gesundheits-Check für Domains**. Dein System fungiert als digitaler Arzt, der regelmäßig den Puls der Domain fühlt. Wenn der Puls schwächer wird (Webseite offline, Mails kommen zurück), alarmierst du deinen User.
|
||||
|
||||
Hier ist der technische und logische Ablauf, wie die **Pounce Domain-Analyse** (Engine) funktioniert.
|
||||
|
||||
Wir teilen die Analyse in **4 Ebenen (Layers)** auf:
|
||||
|
||||
---
|
||||
|
||||
### Ebene 1: Der DNS-Check (Die Infrastruktur)
|
||||
*Das ist der "Wohnsitz"-Check. Wohnt hier noch wer?*
|
||||
|
||||
Hier prüfst du die DNS-Einträge (Domain Name System). Das kostet dich fast keine Rechenleistung und geht extrem schnell.
|
||||
|
||||
**Was dein Skript prüft:**
|
||||
1. **NS Records (Nameserver):** Wer verwaltet die Domain?
|
||||
* *Signal:* Wechselt der Nameserver von `ns1.hostpoint.ch` (normales Hosting) zu `ns1.sedoparking.com` oder `ns1.afternic.com`?
|
||||
* *Bedeutung:* **ALARM!** Der Besitzer hat das Projekt aufgegeben und die Domain zum Verkauf ("Parking") freigegeben. Das ist der beste Moment für ein Kaufangebot.
|
||||
2. **A Record (IP-Adresse):** Zeigt die Domain auf einen Server?
|
||||
* *Signal:* Eintrag wird gelöscht oder zeigt auf `0.0.0.0` oder `127.0.0.1`.
|
||||
* *Bedeutung:* Die Domain ist "technisch tot". Sie löst nirgendwohin auf.
|
||||
3. **MX Record (Mail Exchange):** Kann die Domain E-Mails empfangen?
|
||||
* *Signal:* MX Records verschwinden.
|
||||
* *Bedeutung:* Die Firma nutzt keine E-Mails mehr unter dieser Domain. Ein sehr starkes Zeichen für Geschäftsaufgabe.
|
||||
|
||||
---
|
||||
|
||||
### Ebene 2: Der HTTP-Check (Die Schaufenster-Analyse)
|
||||
*Das ist der visuelle Check. Ist der Laden noch offen?*
|
||||
|
||||
Hier versucht dein Bot, die Webseite tatsächlich aufzurufen (wie ein Browser, aber ohne Bilder zu laden).
|
||||
|
||||
**Was dein Skript prüft:**
|
||||
1. **Status Codes (Der Türsteher):**
|
||||
* **200 OK:** Seite ist online.
|
||||
* **404 Not Found:** Seite existiert nicht (Datei fehlt).
|
||||
* **500/503 Server Error:** Die Webseite ist kaputt.
|
||||
* **Connection Refused / Timeout:** Der Server ist abgeschaltet.
|
||||
* *Pounce Logic:* Ein Wechsel von **200** auf **Timeout** über 3 Tage hinweg ist ein starkes "Drop"-Signal.
|
||||
2. **Content-Length (Größe der Seite):**
|
||||
* *Signal:* Die Seite war früher 2MB groß, jetzt sind es nur noch 500 Bytes.
|
||||
* *Bedeutung:* Der Inhalt wurde gelöscht, es steht nur noch "Coming Soon" oder eine weiße Seite da.
|
||||
3. **Keyword-Scanning (Parked Detection):**
|
||||
* Das Problem: Park-Seiten (Werbung) geben oft auch einen "200 OK" Status zurück.
|
||||
* *Lösung:* Dein Skript scannt den HTML-Text nach Wörtern wie: *"Domain is for sale"*, *"Inquire now"*, *"Related Links"*, *"Buy this domain"*.
|
||||
* *Bedeutung:* Wenn diese Wörter auftauchen, markierst du die Domain automatisch als **"On Sale / Parked"**.
|
||||
|
||||
---
|
||||
|
||||
### Ebene 3: Der SSL-Check (Die Wartung)
|
||||
*Kümmert sich der Hausmeister noch?*
|
||||
|
||||
Sicherheitszertifikate (SSL/TLS) müssen regelmäßig erneuert werden (oft alle 90 Tage bei Let's Encrypt, oder jährlich).
|
||||
|
||||
**Was dein Skript prüft:**
|
||||
1. **Expiry Date des Zertifikats:**
|
||||
* *Signal:* Das Zertifikat ist gestern abgelaufen ("Expired").
|
||||
* *Bedeutung:* Der Admin kümmert sich nicht mehr. Moderne Browser zeigen jetzt eine Warnung ("Nicht sicher"). Besucher bleiben aus. Das Projekt stirbt.
|
||||
|
||||
---
|
||||
|
||||
### Ebene 4: Der Whois/RDAP Check (Der Vertrag)
|
||||
*Wann läuft der Mietvertrag aus?*
|
||||
|
||||
Das ist der Check direkt bei der Registry (z.B. Verisign oder SWITCH). Da Whois oft Rate-Limits hat (du darfst nicht zu oft abfragen), machst du das seltener (z.B. 1x täglich). Nutze dafür am besten **RDAP** (Registration Data Access Protocol) – das ist der moderne, maschinenlesbare Nachfolger von Whois (JSON Format).
|
||||
|
||||
**Was dein Skript prüft:**
|
||||
1. **Expiration Date:** Wann läuft die Domain aus?
|
||||
2. **Domain Status Codes (EPP Codes):**
|
||||
* `clientTransferProhibited`: Alles normal (gesperrt gegen Diebstahl).
|
||||
* `clientHold` oder `serverHold`: **JACKPOT!** Die Domain wurde deaktiviert (meist wegen Nichtzahlung). Sie wird sehr bald gelöscht.
|
||||
* `redemptionPeriod`: Die Gnadenfrist läuft. Der Besitzer muss Strafe zahlen, um sie zu retten. Tut er es nicht, droppt sie in ~30 Tagen.
|
||||
|
||||
---
|
||||
|
||||
### Zusammenfassung: Der "Pounce Health Score"
|
||||
|
||||
Damit der User nicht mit technischen Daten erschlagen wird, fasst du all diese Checks in einem einfachen Status im Dashboard zusammen.
|
||||
|
||||
**Beispiel-Logik für deine App:**
|
||||
|
||||
* **Status: 🟢 HEALTHY (Aktiv)**
|
||||
* DNS: OK
|
||||
* HTTP: 200 OK
|
||||
* SSL: Valid
|
||||
|
||||
* **Status: 🟡 WEAKENING (Schwächelnd - Watchlist Alarm!)**
|
||||
* SSL: Expired ⚠️
|
||||
* HTTP: 500 Error oder Content-Length drastisch gesunken ⚠️
|
||||
* *Nachricht an User:* "Webseite ist kaputt gegangen und Zertifikat abgelaufen. Besitzer verliert Interesse."
|
||||
|
||||
* **Status: 🟠 PARKED (Zu Verkaufen)**
|
||||
* DNS: Zeigt auf Sedo/Afternic
|
||||
* HTTP Body: Enthält "Buy this domain"
|
||||
|
||||
* **Status: 🔴 CRITICAL / PENDING DROP (Gleich weg)**
|
||||
* Whois Status: `redemptionPeriod` oder `clientHold`
|
||||
* DNS: NXDOMAIN (Existiert nicht mehr)
|
||||
* *Nachricht an User:* "Domain wurde vom Registrar deaktiviert. Drop steht bevor!"
|
||||
|
||||
### Technische Umsetzung (Tech Stack für Python)
|
||||
|
||||
Wenn du das bauen willst, brauchst du folgende Python-Libraries (alle Open Source):
|
||||
|
||||
1. **DNS:** `dnspython` (um Nameserver und MX Records abzufragen).
|
||||
2. **HTTP:** `requests` (um Status Codes und Content zu prüfen).
|
||||
3. **SSL:** `ssl` & `socket` (Standard-Libraries, um Zertifikatsdatum auszulesen).
|
||||
4. **Whois:** `python-whois` (einfacher Wrapper) oder direkte RDAP-Abfragen via `requests`.
|
||||
|
||||
**Pro-Tipp für deinen Server:**
|
||||
Da du viele Domains checkst, darfst du das nicht "hintereinander" machen (dauert zu lange). Du musst es **asynchron** machen (viele gleichzeitig). Schau dir dafür **Python `asyncio`** und **`aiohttp`** an. Damit kannst du Tausende Domains in wenigen Minuten prüfen.
|
||||
166
analysis_3.md
Normal file
166
analysis_3.md
Normal file
@ -0,0 +1,166 @@
|
||||
Um die Churn Rate (Absprungrate) zu senken und den Umsatz pro Kunde (LTV - Lifetime Value) zu steigern, musst du das Mindset des Nutzers ändern:
|
||||
|
||||
**Von:** *"Ich nutze Pounce, um eine Domain zu **finden**."* (Einmaliges Projekt)
|
||||
**Zu:** *"Ich nutze Pounce, um mein Domain-Business zu **betreiben**."* (Laufender Prozess)
|
||||
|
||||
Wenn Pounce nur ein "Such-Tool" ist, kündigen die Leute, sobald sie fündig wurden. Wenn Pounce aber ihr "Betriebssystem" wird, bleiben sie für immer.
|
||||
|
||||
Hier sind 4 Strategien, um Pounce unverzichtbar zu machen:
|
||||
|
||||
---
|
||||
|
||||
### 1. Strategie: Vom "Jäger" zum "Wächter" (Portfolio Monitoring)
|
||||
*Ziel: Den Nutzer binden, auch wenn er gerade nichts kaufen will.*
|
||||
|
||||
Viele Domainer und Agenturen besitzen bereits 50-500 Domains. Sie haben Angst, eine Verlängerung zu verpassen oder technische Fehler nicht zu bemerken.
|
||||
|
||||
* **Das Feature:** **"My Portfolio Health"**
|
||||
Der Nutzer importiert seine *eigenen* Domains in Pounce (nicht um sie zu kaufen, sondern zu verwalten).
|
||||
* **Uptime Monitor:** Ist meine Seite noch online?
|
||||
* **SSL Monitor:** Läuft mein Zertifikat ab?
|
||||
* **Expiration Alert:** Erinnere mich 30 Tage vor Ablauf (besser als die Spam-Mails der Registrare).
|
||||
* **Blacklist Check:** Landet meine Domain auf einer Spam-Liste?
|
||||
|
||||
* **Der Lock-in Effekt:**
|
||||
Niemand kündigt das Tool, das seine Assets überwacht ("Versicherungs-Psychologie"). Wenn du ihre 50 Domains überwachst, bist du unverzichtbar.
|
||||
|
||||
### 2. Strategie: Der "Micro-Marktplatz" (Liquidity)
|
||||
*Ziel: Mehr Umsatz durch Transaktionen.*
|
||||
|
||||
Wenn ein "Hunter" eine Domain über Pounce findet, will er sie oft später wieder verkaufen (Flipping). Aktuell schickst du ihn dafür weg zu Sedo. Warum nicht im Haus behalten?
|
||||
|
||||
* **Das Feature:** **"Pounce 'For Sale' Landing Pages"**
|
||||
Ein User (Trader/Tycoon) kann für seine Domains mit einem Klick eine schicke Verkaufsseite erstellen.
|
||||
* *Domain:* `super-startup.ai`
|
||||
* *Pounce generiert:* `pounce.ch/buy/super-startup-ai`
|
||||
* *Design:* Hochwertig, zeigt deine "Valuation Daten" (Pounce Score) an, um den Preis zu rechtfertigen.
|
||||
* *Kontakt:* Ein einfaches Kontaktformular, das die Anfrage direkt an den User leitet.
|
||||
|
||||
* **Das Geld:**
|
||||
* Entweder Teil des Abo-Preises ("Erstelle 5 Verkaufsseiten kostenlos").
|
||||
* Oder: Du nimmst keine Provision, aber der Käufer muss sich bei Pounce registrieren, um den Verkäufer zu kontaktieren (Lead Gen).
|
||||
|
||||
### 3. Strategie: SEO-Daten & Backlinks (Neue Zielgruppe)
|
||||
*Ziel: Kunden mit hohem Budget gewinnen (Agenturen).*
|
||||
|
||||
SEO-Agenturen kündigen fast nie, weil sie monatliche Budgets für Tools haben. Sie suchen Domains nicht wegen dem Namen, sondern wegen der **Power** (Backlinks).
|
||||
|
||||
* **Das Feature:** **"SEO Juice Detector"**
|
||||
Wenn eine Domain droppt, prüfst du nicht nur den Namen, sondern (über günstige APIs wie Moz oder durch Scraping öffentlicher Daten), ob Backlinks existieren.
|
||||
* *Anzeige:* "Domain `alte-bäckerei-münchen.de` ist frei. Hat Links von `sueddeutsche.de` und `wikipedia.org`."
|
||||
* **Der Wert:** Solche Domains sind für SEOs 100€ - 500€ wert, auch wenn der Name hässlich ist.
|
||||
* **Monetarisierung:** Das ist ein reines **Tycoon-Feature ($29 oder sogar $49/Monat)**.
|
||||
|
||||
### 4. Strategie: Alerts "nach Maß" (Hyper-Personalisierung)
|
||||
*Ziel: Den Nutzer täglich zurückholen.*
|
||||
|
||||
Wenn ich nur eine Mail bekomme "Hier sind 100 neue Domains", ist das oft Spam für mich. Ich will nur *genau das*, was ich suche.
|
||||
|
||||
* **Das Feature:** **"Sniper Alerts"**
|
||||
Der User kann extrem spezifische Filter speichern:
|
||||
* *"Informiere mich NUR, wenn eine 4-Letter .com Domain droppt, die kein 'q' oder 'x' enthält."*
|
||||
* *"Informiere mich, wenn eine .ch Domain droppt, die das Wort 'Immo' enthält."*
|
||||
* **Der Effekt:** Wenn die SMS/Mail kommt, weiß der User: "Das ist relevant". Er klickt, loggt sich ein, bleibt aktiv.
|
||||
|
||||
---
|
||||
|
||||
### Zusammenfassung des erweiterten Business-Modells
|
||||
|
||||
So sieht deine Umsatz-Maschine dann aus:
|
||||
|
||||
| Stufe | Was der User tut | Warum er bleibt (Retention) | Dein Umsatz |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Phase 1: Finding** | Sucht freie/droppende Domains. | Findet bessere Deals durch Spam-Filter. | $9 / Monat |
|
||||
| **Phase 2: Monitoring** | Überwacht Wettbewerber & eigene Domains. | Angst, Status-Änderungen zu verpassen (Versicherung). | Churn sinkt drastisch. |
|
||||
| **Phase 3: Selling** | Erstellt Verkaufs-Landings via Pounce. | Nutzt Pounce als Schaufenster für sein Business. | User ist "locked in". |
|
||||
| **Phase 4: SEO** | Sucht Backlink-Monster. | Verdient Geld mit deinen Daten (ROI). | $29 - $49 / Monat |
|
||||
|
||||
### Mein Tipp für den Start:
|
||||
Konzentriere dich auf **Strategie 1 (Portfolio Monitoring)** als erstes Zusatz-Feature nach dem Launch.
|
||||
|
||||
Warum?
|
||||
Es ist technisch einfach (du hast die Ping-Skripte ja schon für die Analyse gebaut). Du erlaubst dem User einfach, Domains *manuell* hinzuzufügen.
|
||||
Sobald ein User mal 50 seiner eigenen Domains eingetragen hat, wird er sein Abo **niemals kündigen**, weil er sonst seine Überwachung verliert. Das ist der ultimative "Golden Handcuff".
|
||||
|
||||
Vertrauen ist im Domain-Business tatsächlich die **härteste Währung**. Die Branche ist leider voll von Betrügern (Domain-Diebstahl, Phishing, Fake-Auktionen).
|
||||
|
||||
Wenn `pounce.ch` als "Command Center" wahrgenommen werden soll, muss die Plattform **sauberer sein als der Rest**.
|
||||
|
||||
Hier ist ein **4-Säulen-Sicherheitskonzept**, mit dem du Missbrauch verhinderst und gleichzeitig massives Vertrauen bei deinen echten Nutzern aufbaust.
|
||||
|
||||
---
|
||||
|
||||
### Säule 1: Identity Verification (Wer bist du?)
|
||||
*Hürde: Betrüger hassen Identifikation.*
|
||||
|
||||
Du darfst "Tycoon"-Features (und vor allem Verkaufs-Features) nicht einfach jedem geben, der eine E-Mail-Adresse hat.
|
||||
|
||||
1. **Stripe Identity / Radar:**
|
||||
Nutze für die Zahlungsabwicklung Stripe. Stripe hat eingebaute Betrugserkennung ("Radar"). Wenn jemand eine gestohlene Kreditkarte nutzt, blockiert Stripe ihn meist sofort. Das ist deine erste Firewall.
|
||||
2. **SMS-Verifizierung (2FA):**
|
||||
Jeder Account, der Domains verkaufen oder überwachen will, muss eine **Handynummer verifizieren**. Wegwerf-Nummern (VoIP) werden blockiert. Das erhöht die Hürde für Spammer massiv.
|
||||
3. **LinkedIn-Login (Optional für Trust):**
|
||||
Biete an: "Verbinde dein LinkedIn für den 'Verified Professional' Status". Ein Profil mit 500+ Kontakten und Historie ist selten ein Fake.
|
||||
|
||||
---
|
||||
|
||||
### Säule 2: Asset Verification (Gehört dir das wirklich?)
|
||||
*Hürde: Verhindern, dass Leute fremde Domains als ihre eigenen ausgeben.*
|
||||
|
||||
Das ist der wichtigste Punkt, wenn du Features wie "Portfolio Monitoring" oder "For Sale Pages" anbietest.
|
||||
|
||||
**Die technische Lösung: DNS Ownership Verify**
|
||||
Bevor ein Nutzer eine Domain in sein Portfolio aufnehmen kann, um sie zu verkaufen oder tief zu analysieren, muss er beweisen, dass er der Admin ist.
|
||||
* **Wie es funktioniert:**
|
||||
1. User fügt `mein-startup.ch` hinzu.
|
||||
2. Pounce sagt: "Bitte erstelle einen TXT-Record in deinen DNS-Einstellungen mit dem Inhalt: `pounce-verification=847392`."
|
||||
3. Dein System prüft den Record.
|
||||
4. Nur wenn er da ist -> **Domain Verified ✅**.
|
||||
|
||||
*Das ist der Industriestandard (macht Google auch). Wer keinen Zugriff auf die DNS hat, kann die Domain nicht claimen.*
|
||||
|
||||
---
|
||||
|
||||
### Säule 3: Content Monitoring (Was machst du damit?)
|
||||
*Hürde: Verhindern, dass deine "For Sale"-Seiten für Phishing genutzt werden.*
|
||||
|
||||
Wenn User über Pounce Verkaufsseiten ("Landers") erstellen können, könnten sie dort versuchen, Bankdaten abzugreifen.
|
||||
|
||||
1. **Automatischer Blacklist-Scan:**
|
||||
Jede Domain, die ins System kommt, wird sofort gegen **Google Safe Browsing** und **Spamhaus** geprüft. Ist die Domain dort als "Malware" gelistet? -> **Sofortiger Ban.**
|
||||
2. **Keyword-Blocking:**
|
||||
Erlaube keine Titel oder Texte auf Verkaufsseiten, die Wörter enthalten wie: "Login", "Bank", "Verify", "Paypal", "Password".
|
||||
3. **No Custom HTML:**
|
||||
Erlaube Usern auf ihren Verkaufsseiten *kein* eigenes HTML/JavaScript. Nur Text und vordefinierte Buttons. So können sie keine Schadsoftware einschleusen.
|
||||
|
||||
---
|
||||
|
||||
### Säule 4: The "Safe Harbor" Badge (Marketing)
|
||||
*Nutzen: Du machst die Sicherheit zu deinem Verkaufsargument.*
|
||||
|
||||
Du kommunizierst diese Strenge nicht als "Nervigkeit", sondern als **Qualitätsmerkmal**.
|
||||
|
||||
* **Das "Pounce Verified" Siegel:**
|
||||
Auf jeder Verkaufsseite oder in jedem Profil zeigst du an:
|
||||
* ✅ **ID Verified** (Handy/Zahlung geprüft)
|
||||
* ✅ **Owner Verified** (DNS geprüft)
|
||||
* ✅ **Clean History** (Keine Spam-Reports)
|
||||
|
||||
---
|
||||
|
||||
### Prozess bei Verstößen ("Zero Tolerance")
|
||||
|
||||
Du brauchst klare AGBs ("Terms of Service"):
|
||||
1. **One Strike Policy:** Wer versucht, Phishing zu betreiben oder gestohlene Domains anzubieten, wird sofort permanent gesperrt. Keine Diskussion.
|
||||
2. **Reporting Button:** Gib der Community Macht. Ein "Report Abuse"-Button auf jeder Seite. Wenn 2-3 unabhängige User etwas melden, wird das Asset automatisch offline genommen, bis du es geprüft hast.
|
||||
|
||||
### Zusammenfassung: Der "Trust Stack"
|
||||
|
||||
| Ebene | Maßnahme | Effekt |
|
||||
| :--- | :--- | :--- |
|
||||
| **Login** | SMS / 2FA + Stripe Radar | Hält Bots und Kreditkartenbetrüger fern. |
|
||||
| **Portfolio** | **DNS TXT Record (Zwingend)** | Nur der echte Besitzer kann Domains verwalten. |
|
||||
| **Marktplatz** | Google Safe Browsing Check | Verhindert Malware/Phishing auf deiner Plattform. |
|
||||
| **Frontend** | "Verified Owner" Badge | Käufer wissen: Das hier ist sicher. |
|
||||
|
||||
**Damit positionierst du Pounce als den "Safe Space" im wilden Westen des Domain-Handels.** Das ist für seriöse Investoren oft wichtiger als der Preis.
|
||||
149
analysis_4.md
Normal file
149
analysis_4.md
Normal file
@ -0,0 +1,149 @@
|
||||
Deine TLD-Pricing-Seite ist ein guter Start, aber für eine **"Intelligence Platform"** ist sie noch zu sehr eine reine "Liste".
|
||||
|
||||
Das Problem: Du zeigst nur den **Status Quo** (aktueller Preis).
|
||||
Ein "Hunter" will aber wissen: **"Wo ist der Haken?"** und **"Wo ist die Marge?"**
|
||||
|
||||
Hier sind die konkreten Optimierungen, um diese Seite von "nett" zu **"unverzichtbar"** zu machen.
|
||||
|
||||
---
|
||||
|
||||
### 1. Das "Hidden Cost" Problem lösen (Killer-Feature)
|
||||
|
||||
Der größte Schmerzpunkt bei Domains sind die **Verlängerungspreise (Renewals)**. Viele TLDs ködern mit $1.99 im ersten Jahr und verlangen dann $50.
|
||||
* **Aktuell:** Du zeigst nur einen Preis (vermutlich Registration).
|
||||
* **Optimierung:** Splitte die Preis-Spalte.
|
||||
* Spalte A: **Buy Now** (z.B. $1.99)
|
||||
* Spalte B: **Renews at** (z.B. $49.00)
|
||||
* **Pounce-Alert:** Wenn die Differenz > 200% ist, markiere es mit einem kleinen Warndreieck ⚠️ ("Trap Alert"). Das baut massiv Vertrauen auf.
|
||||
|
||||
### 2. Visuelle "Sparklines" statt nackter Zahlen
|
||||
In der Spalte "12-Month Trend" zeigst du aktuell zwei Zahlen (`$10.75` -> `$9.58`). Das muss das Gehirn erst rechnen.
|
||||
* **Optimierung:** Ersetze die Zahlen durch eine **Mini-Chart (Sparkline)**.
|
||||
* Eine kleine grüne oder rote Linie, die den Verlauf zeigt.
|
||||
* Das wirkt sofort wie ein Trading-Terminal (Bloomberg-Style).
|
||||
* *Beispiel:* `.ai` hat eine steil ansteigende Kurve 📈. `.xyz` hat eine flache Linie.
|
||||
|
||||
### 3. "Arbitrage" Spalte (Der "Hunter"-Faktor)
|
||||
Du hast Zugang zu verschiedenen Registraren. Zeige die Preisspanne!
|
||||
* **Optimierung:** Füge eine Spalte **"Spread"** oder **"Arbitrage"** hinzu.
|
||||
* *"Low: $60 (Namecheap) - High: $90 (GoDaddy)"*
|
||||
* Zeige dem User: *"Hier sparst du $30, wenn du den richtigen Anbieter wählst."*
|
||||
* Das ist der perfekte Ort für deinen Affiliate-Link ("Buy at lowest price").
|
||||
|
||||
### 4. Smarte Filter (UX)
|
||||
886 TLDs sind zu viel zum Scrollen. Deine "Discovery"-Sektion oben ist gut, aber die Tabelle braucht **Tabs**.
|
||||
* **Vorschlag für Tabs oberhalb der Tabelle:**
|
||||
* **[All]**
|
||||
* **[Tech]** (.ai, .io, .app, .dev)
|
||||
* **[Geo]** (.ch, .de, .uk, .nyc)
|
||||
* **[Budget]** (Alles unter $5)
|
||||
* **[Premium]** (Alles über $100)
|
||||
|
||||
---
|
||||
|
||||
### Visueller Entwurf (Mockup der Tabelle)
|
||||
|
||||
Hier ist, wie die Tabelle im **Command Center** aussehen sollte:
|
||||
|
||||
| TLD | Trend (12m) | Buy (1y) | Renew (1y) | Spread | Pounce Intel |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **.ai** | 📈 *(Sparkline)* | **$71.63** | $71.63 | $15.00 | 🔥 High Demand |
|
||||
| **.xyz** | 📉 *(Sparkline)* | **$0.99** | $13.99 | ⚠️ | 🚩 Renewal Trap |
|
||||
| **.io** | ➖ *(Sparkline)* | **$32.00** | $32.00 | $4.50 | ✅ Stable Asset |
|
||||
| **.ch** | ➖ *(Sparkline)* | **$11.56** | $11.56 | $1.20 | 🛡️ Trust Signal |
|
||||
|
||||
---
|
||||
|
||||
### 5. Conversion-Elemente (Psychologie)
|
||||
|
||||
* **Das "Login"-Schloss:**
|
||||
Lass die ersten 3-5 Zeilen (wie .com, .net, .ai) **offen sichtbar**.
|
||||
Ab Zeile 6 legst du einen **Blur-Effekt** über die Spalten "Renew" und "Trend".
|
||||
* *CTA:* "Stop overpaying via GoDaddy. Unlock renewal prices & arbitrage data for 800+ TLDs. [Start Free]"
|
||||
|
||||
* **Data-Tooltips:**
|
||||
Wenn man über `.ai` hovert, zeige ein kleines Popup:
|
||||
*"Preisanstieg +35% getrieben durch KI-Boom. Empfohlener Registrar: Dynadot ($69)."*
|
||||
|
||||
### Zusammenfassung der To-Dos:
|
||||
|
||||
1. **Renew-Spalte hinzufügen:** Das ist Pflicht für Transparenz.
|
||||
2. **Sparklines einbauen:** Macht die Seite optisch hochwertiger.
|
||||
3. **Kategorien-Tabs:** Erleichtert die Navigation.
|
||||
4. **Blur-Effekt strategisch nutzen:** Gib Daten ("Teaser"), aber verstecke das Gold (Trends & Renewals).
|
||||
|
||||
Damit wird die Seite von einer bloßen Preisliste zu einem echten **Investment-Tool**.
|
||||
|
||||
Du hast absolut recht. "Arbitrage" ist der falsche Begriff, wenn es nicht um den direkten An- und Verkauf (Trading), sondern um die Registrierung geht. Und du willst den Fokus auf die **Preisentwicklung der Endung** selbst legen (Inflation, Registry-Preiserhöhungen).
|
||||
|
||||
Wir müssen die Seite also von einem "Trading-Tool" zu einem **"Inflation & Market Monitor"** umbauen. Der User soll sehen: *Wird diese Endung teurer oder billiger? Lohnt es sich, jetzt für 10 Jahre im Voraus zu verlängern?*
|
||||
|
||||
Hier ist das korrigierte Konzept für die **TLD Pricing & Trends Optimierung**:
|
||||
|
||||
### 1. Das neue Kern-Konzept: "Inflation Monitor"
|
||||
Statt "Arbitrage" zeigen wir die **"Price Stability"**.
|
||||
Registries (wie Verisign bei .com) erhöhen regelmäßig die Preise. Dein Tool warnt davor.
|
||||
|
||||
* **Die neue Spalte:** **"Volatility / Stability"**
|
||||
* **Der Wert:**
|
||||
* **Stable:** Preis hat sich seit 2 Jahren nicht geändert (z.B. .ch).
|
||||
* **Rising:** Registry hat Preise erhöht (z.B. .com erhöht oft um 7% pro Jahr).
|
||||
* **Promo-Driven:** Preis schwankt stark (oft bei .xyz oder .store, die mal $0.99, mal $10 kosten).
|
||||
|
||||
### 2. Preistrend-Visualisierung (Deine Anforderung)
|
||||
Du möchtest zeigen, wie sich der Preis für die *Endung* verändert hat.
|
||||
|
||||
* **Die Visualisierung:** Statt einer einfachen Sparkline, zeige (für Pro User im Detail, für Free User vereinfacht) die **"Wholesale Price History"**.
|
||||
* **Die Spalten in der Tabelle:**
|
||||
* **Current Price:** $71.63
|
||||
* **1y Change:** **+12% 📈** (Das ist der entscheidende Indikator!)
|
||||
* **3y Change:** **+35%**
|
||||
|
||||
### 3. Das "Renewal Trap" Feature (Vertrauen)
|
||||
Das bleibt extrem wichtig. Da dir die Domain nicht gehört, mietest du sie. Der Mietpreis (Renewal) ist wichtiger als der Einstiegspreis.
|
||||
|
||||
* **Logic:**
|
||||
* Registration: $1.99
|
||||
* Renewal: $45.00
|
||||
* **Pounce Index:** Zeige ein Verhältnis an.
|
||||
* *Ratio 1.0:* Fair (Reg = Renew).
|
||||
* *Ratio 20.0:* Falle (Reg billig, Renew teuer).
|
||||
|
||||
---
|
||||
|
||||
### Das optimierte Tabellen-Layout
|
||||
|
||||
Hier ist der konkrete Vorschlag für die Spalten deiner Tabelle auf `pounce.ch/tld-prices`:
|
||||
|
||||
| TLD | Price (Buy) | Price (Renew) | 1y Trend | 3y Trend | Risk Level |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **.ai** | **$71.63** | $71.63 | **+15% 📈** | **+35% 📈** | 🟢 Low (Stable but rising) |
|
||||
| **.com** | **$10.75** | $10.75 | **+7% 📈** | **+14% 📈** | 🟢 Low (Predictable) |
|
||||
| **.xyz** | **$0.99** | $13.99 | **-10% 📉** | **-5%** | 🔴 High (Renewal Trap) |
|
||||
| **.io** | **$32.00** | $32.00 | **0% ➖** | **+5%** | 🟢 Low |
|
||||
| **.tech** | **$5.00** | $55.00 | **0% ➖** | **0%** | 🔴 High (High Renewal) |
|
||||
|
||||
**Erklärung der Spalten für den User:**
|
||||
|
||||
* **1y Trend:** *"Der Einkaufspreis für diese Endung ist im letzten Jahr um 15% gestiegen. Jetzt sichern, bevor es teurer wird!"*
|
||||
* **Risk Level:** *"Achtung, diese Endung lockt mit günstigen Einstiegspreisen, wird aber im zweiten Jahr 10x teurer."*
|
||||
|
||||
---
|
||||
|
||||
### Feature-Idee: "Lock-in Calculator" (Mehrwert)
|
||||
|
||||
Unterhalb der Tabelle oder im Detail-View einer TLD bietest du einen Rechner an:
|
||||
|
||||
> **Should I renew early?**
|
||||
> *TLD: .com*
|
||||
> *Trend: +7% p.a.*
|
||||
>
|
||||
> 💡 **Pounce Empfehlung:** *"Ja. Wenn du deine .com jetzt für 10 Jahre verlängerst, sparst du voraussichtlich $15 gegenüber jährlicher Verlängerung."*
|
||||
|
||||
**Das ist echte "Domain Intelligence".** Du hilfst dem User, Geld zu sparen, indem er Marktmechanismen (Preiserhöhungen der Registry) versteht.
|
||||
|
||||
### Zusammenfassung
|
||||
|
||||
Wir entfernen "Arbitrage" und ersetzen es durch **"Inflation Tracking"**.
|
||||
Die Story für den User ist:
|
||||
*"Domain-Preise ändern sich. .ai wird teurer, .xyz ist eine Falle. Pounce zeigt dir die wahren Kosten über 10 Jahre, nicht nur den Lockvogel-Preis von heute."*
|
||||
@ -14,6 +14,9 @@ from app.api.webhooks import router as webhooks_router
|
||||
from app.api.contact import router as contact_router
|
||||
from app.api.price_alerts import router as price_alerts_router
|
||||
from app.api.blog import router as blog_router
|
||||
from app.api.listings import router as listings_router
|
||||
from app.api.sniper_alerts import router as sniper_alerts_router
|
||||
from app.api.seo import router as seo_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@ -28,6 +31,15 @@ api_router.include_router(price_alerts_router, prefix="/price-alerts", tags=["Pr
|
||||
api_router.include_router(portfolio_router, prefix="/portfolio", tags=["Portfolio"])
|
||||
api_router.include_router(auctions_router, prefix="/auctions", tags=["Smart Pounce - Auctions"])
|
||||
|
||||
# Marketplace (For Sale) - from analysis_3.md
|
||||
api_router.include_router(listings_router, prefix="/listings", tags=["Marketplace - For Sale"])
|
||||
|
||||
# Sniper Alerts - from analysis_3.md
|
||||
api_router.include_router(sniper_alerts_router, prefix="/sniper-alerts", tags=["Sniper Alerts"])
|
||||
|
||||
# SEO Data / Backlinks - from analysis_3.md (Tycoon-only)
|
||||
api_router.include_router(seo_router, prefix="/seo", tags=["SEO Data - Tycoon"])
|
||||
|
||||
# Support & Communication
|
||||
api_router.include_router(contact_router, prefix="/contact", tags=["Contact & Newsletter"])
|
||||
|
||||
|
||||
@ -390,6 +390,9 @@ async def delete_user(
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Delete a user and all their data."""
|
||||
from app.models.blog import BlogPost
|
||||
from app.models.admin_log import AdminActivityLog
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
@ -399,10 +402,29 @@ async def delete_user(
|
||||
if user.is_admin:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete admin user")
|
||||
|
||||
user_email = user.email
|
||||
|
||||
# Delete user's blog posts (or set author_id to NULL if you want to keep them)
|
||||
await db.execute(
|
||||
BlogPost.__table__.delete().where(BlogPost.author_id == user_id)
|
||||
)
|
||||
|
||||
# Delete user's admin activity logs (if any)
|
||||
await db.execute(
|
||||
AdminActivityLog.__table__.delete().where(AdminActivityLog.admin_id == user_id)
|
||||
)
|
||||
|
||||
# Now delete the user (cascades to domains, subscriptions, portfolio, price_alerts)
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
|
||||
return {"message": f"User {user.email} deleted"}
|
||||
# Log this action
|
||||
await log_admin_activity(
|
||||
db, admin.id, "user_delete",
|
||||
f"Deleted user {user_email} and all their data"
|
||||
)
|
||||
|
||||
return {"message": f"User {user_email} and all their data have been deleted"}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/upgrade")
|
||||
|
||||
@ -224,6 +224,15 @@ async def search_auctions(
|
||||
# Build query
|
||||
query = select(DomainAuction).where(DomainAuction.is_active == True)
|
||||
|
||||
# VANITY FILTER: For public (non-logged-in) users, only show premium-looking domains
|
||||
# This ensures the first impression is high-quality, not spam domains
|
||||
if current_user is None:
|
||||
# Premium TLDs only (no .cc, .website, .info spam clusters)
|
||||
premium_tlds = ['com', 'io', 'ai', 'co', 'de', 'ch', 'net', 'org', 'app', 'dev', 'xyz']
|
||||
query = query.where(DomainAuction.tld.in_(premium_tlds))
|
||||
# No domains with more than 15 characters (excluding TLD)
|
||||
# Note: We filter further in Python for complex rules
|
||||
|
||||
if keyword:
|
||||
query = query.where(DomainAuction.domain.ilike(f"%{keyword}%"))
|
||||
|
||||
@ -266,6 +275,49 @@ async def search_auctions(
|
||||
result = await db.execute(query)
|
||||
auctions = list(result.scalars().all())
|
||||
|
||||
# VANITY FILTER PART 2: Apply Python-side filtering for public users
|
||||
# This ensures only premium-looking domains are shown to non-logged-in users
|
||||
if current_user is None:
|
||||
def is_premium_domain(domain_name: str) -> bool:
|
||||
"""Check if a domain looks premium/professional"""
|
||||
# Extract just the domain part (without TLD)
|
||||
parts = domain_name.rsplit('.', 1)
|
||||
name = parts[0] if parts else domain_name
|
||||
|
||||
# Rule 1: No more than 15 characters
|
||||
if len(name) > 15:
|
||||
return False
|
||||
|
||||
# Rule 2: No more than 1 hyphen
|
||||
if name.count('-') > 1:
|
||||
return False
|
||||
|
||||
# Rule 3: No more than 2 digits total
|
||||
digit_count = sum(1 for c in name if c.isdigit())
|
||||
if digit_count > 2:
|
||||
return False
|
||||
|
||||
# Rule 4: Must be at least 3 characters
|
||||
if len(name) < 3:
|
||||
return False
|
||||
|
||||
# Rule 5: No random-looking strings (too many consonants in a row)
|
||||
consonants = 'bcdfghjklmnpqrstvwxyz'
|
||||
consonant_streak = 0
|
||||
max_streak = 0
|
||||
for c in name.lower():
|
||||
if c in consonants:
|
||||
consonant_streak += 1
|
||||
max_streak = max(max_streak, consonant_streak)
|
||||
else:
|
||||
consonant_streak = 0
|
||||
if max_streak > 4:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
auctions = [a for a in auctions if is_premium_domain(a.domain)]
|
||||
|
||||
# Convert to response with valuations
|
||||
listings = []
|
||||
for auction in auctions:
|
||||
|
||||
@ -11,6 +11,7 @@ from app.models.domain import Domain, DomainCheck, DomainStatus
|
||||
from app.models.subscription import TIER_CONFIG, SubscriptionTier
|
||||
from app.schemas.domain import DomainCreate, DomainResponse, DomainListResponse
|
||||
from app.services.domain_checker import domain_checker
|
||||
from app.services.domain_health import get_health_checker, HealthStatus
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -312,3 +313,60 @@ async def get_domain_history(
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{domain_id}/health")
|
||||
async def get_domain_health(
|
||||
domain_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
):
|
||||
"""
|
||||
Get comprehensive health report for a domain.
|
||||
|
||||
Checks 4 layers:
|
||||
- DNS: Nameservers, MX records, A records
|
||||
- HTTP: Website availability, parking detection
|
||||
- SSL: Certificate validity and expiration
|
||||
- Status signals and recommendations
|
||||
|
||||
Returns:
|
||||
Health report with score (0-100) and status
|
||||
"""
|
||||
# Get domain
|
||||
result = await db.execute(
|
||||
select(Domain).where(
|
||||
Domain.id == domain_id,
|
||||
Domain.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
domain = result.scalar_one_or_none()
|
||||
|
||||
if not domain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
# Run health check
|
||||
health_checker = get_health_checker()
|
||||
report = await health_checker.check_domain(domain.name)
|
||||
|
||||
return report.to_dict()
|
||||
|
||||
|
||||
@router.post("/health-check")
|
||||
async def quick_health_check(
|
||||
current_user: CurrentUser,
|
||||
domain: str = Query(..., description="Domain to check"),
|
||||
):
|
||||
"""
|
||||
Quick health check for any domain (doesn't need to be in watchlist).
|
||||
|
||||
Premium feature - checks DNS, HTTP, and SSL layers.
|
||||
"""
|
||||
# Run health check
|
||||
health_checker = get_health_checker()
|
||||
report = await health_checker.check_domain(domain)
|
||||
|
||||
return report.to_dict()
|
||||
|
||||
|
||||
818
backend/app/api/listings.py
Normal file
818
backend/app/api/listings.py
Normal file
@ -0,0 +1,818 @@
|
||||
"""
|
||||
Domain Listings API - Pounce Marketplace
|
||||
|
||||
This implements the "Micro-Marktplatz" from analysis_3.md:
|
||||
- Create professional "For Sale" landing pages
|
||||
- DNS verification for ownership
|
||||
- Contact form for buyers
|
||||
- Analytics
|
||||
|
||||
Endpoints:
|
||||
- GET /listings - Public: Browse active listings
|
||||
- GET /listings/{slug} - Public: View listing details
|
||||
- POST /listings/{slug}/inquire - Public: Contact seller
|
||||
- POST /listings - Auth: Create new listing
|
||||
- GET /listings/my - Auth: Get user's listings
|
||||
- PUT /listings/{id} - Auth: Update listing
|
||||
- DELETE /listings/{id} - Auth: Delete listing
|
||||
- POST /listings/{id}/verify-dns - Auth: Start DNS verification
|
||||
- GET /listings/{id}/verify-dns/check - Auth: Check verification status
|
||||
"""
|
||||
import logging
|
||||
import secrets
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, Request
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
from sqlalchemy import select, func, and_
|
||||
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.listing import DomainListing, ListingInquiry, ListingView, ListingStatus, VerificationStatus
|
||||
from app.services.valuation import valuation_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============== Schemas ==============
|
||||
|
||||
class ListingCreate(BaseModel):
|
||||
"""Create a new domain listing."""
|
||||
domain: str = Field(..., min_length=3, max_length=255)
|
||||
title: Optional[str] = Field(None, max_length=200)
|
||||
description: Optional[str] = None
|
||||
asking_price: Optional[float] = Field(None, ge=0)
|
||||
min_offer: Optional[float] = Field(None, ge=0)
|
||||
currency: str = Field("USD", max_length=3)
|
||||
price_type: str = Field("negotiable") # fixed, negotiable, make_offer
|
||||
show_valuation: bool = True
|
||||
allow_offers: bool = True
|
||||
|
||||
|
||||
class ListingUpdate(BaseModel):
|
||||
"""Update a listing."""
|
||||
title: Optional[str] = Field(None, max_length=200)
|
||||
description: Optional[str] = None
|
||||
asking_price: Optional[float] = Field(None, ge=0)
|
||||
min_offer: Optional[float] = Field(None, ge=0)
|
||||
price_type: Optional[str] = None
|
||||
show_valuation: Optional[bool] = None
|
||||
allow_offers: Optional[bool] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class ListingResponse(BaseModel):
|
||||
"""Listing response."""
|
||||
id: int
|
||||
domain: str
|
||||
slug: str
|
||||
title: Optional[str]
|
||||
description: Optional[str]
|
||||
asking_price: Optional[float]
|
||||
min_offer: Optional[float]
|
||||
currency: str
|
||||
price_type: str
|
||||
pounce_score: Optional[int]
|
||||
estimated_value: Optional[float]
|
||||
verification_status: str
|
||||
is_verified: bool
|
||||
status: str
|
||||
show_valuation: bool
|
||||
allow_offers: bool
|
||||
view_count: int
|
||||
inquiry_count: int
|
||||
public_url: str
|
||||
created_at: datetime
|
||||
published_at: Optional[datetime]
|
||||
|
||||
# Seller info (minimal for privacy)
|
||||
seller_verified: bool = False
|
||||
seller_member_since: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ListingPublicResponse(BaseModel):
|
||||
"""Public listing response (limited info)."""
|
||||
domain: str
|
||||
slug: str
|
||||
title: Optional[str]
|
||||
description: Optional[str]
|
||||
asking_price: Optional[float]
|
||||
currency: str
|
||||
price_type: str
|
||||
pounce_score: Optional[int]
|
||||
estimated_value: Optional[float]
|
||||
is_verified: bool
|
||||
allow_offers: bool
|
||||
public_url: str
|
||||
|
||||
# Seller trust indicators
|
||||
seller_verified: bool
|
||||
seller_member_since: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class InquiryCreate(BaseModel):
|
||||
"""Create an inquiry for a listing."""
|
||||
name: str = Field(..., min_length=2, max_length=100)
|
||||
email: EmailStr
|
||||
phone: Optional[str] = Field(None, max_length=50)
|
||||
company: Optional[str] = Field(None, max_length=200)
|
||||
message: str = Field(..., min_length=10, max_length=2000)
|
||||
offer_amount: Optional[float] = Field(None, ge=0)
|
||||
|
||||
|
||||
class InquiryResponse(BaseModel):
|
||||
"""Inquiry response for listing owner."""
|
||||
id: int
|
||||
name: str
|
||||
email: str
|
||||
phone: Optional[str]
|
||||
company: Optional[str]
|
||||
message: str
|
||||
offer_amount: Optional[float]
|
||||
status: str
|
||||
created_at: datetime
|
||||
read_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class VerificationResponse(BaseModel):
|
||||
"""DNS verification response."""
|
||||
verification_code: str
|
||||
dns_record_type: str = "TXT"
|
||||
dns_record_name: str
|
||||
dns_record_value: str
|
||||
instructions: str
|
||||
status: str
|
||||
|
||||
|
||||
# ============== Helper Functions ==============
|
||||
|
||||
def _generate_slug(domain: str) -> str:
|
||||
"""Generate URL-friendly slug from domain."""
|
||||
# Remove TLD for cleaner slug
|
||||
slug = domain.lower().replace('.', '-')
|
||||
# Remove any non-alphanumeric chars except hyphens
|
||||
slug = re.sub(r'[^a-z0-9-]', '', slug)
|
||||
return slug
|
||||
|
||||
|
||||
def _generate_verification_code() -> str:
|
||||
"""Generate a unique verification code."""
|
||||
return f"pounce-verify-{secrets.token_hex(16)}"
|
||||
|
||||
|
||||
# Security: Block phishing keywords (from analysis_3.md - Säule 3)
|
||||
BLOCKED_KEYWORDS = [
|
||||
'login', 'bank', 'verify', 'paypal', 'password', 'account',
|
||||
'credit', 'social security', 'ssn', 'wire', 'transfer'
|
||||
]
|
||||
|
||||
|
||||
def _check_content_safety(text: str) -> bool:
|
||||
"""Check if content contains phishing keywords."""
|
||||
text_lower = text.lower()
|
||||
return not any(keyword in text_lower for keyword in BLOCKED_KEYWORDS)
|
||||
|
||||
|
||||
# ============== Public Endpoints ==============
|
||||
|
||||
@router.get("", response_model=List[ListingPublicResponse])
|
||||
async def browse_listings(
|
||||
keyword: Optional[str] = Query(None),
|
||||
min_price: Optional[float] = Query(None, ge=0),
|
||||
max_price: Optional[float] = Query(None, ge=0),
|
||||
verified_only: bool = Query(False),
|
||||
sort_by: str = Query("newest", enum=["newest", "price_asc", "price_desc", "popular"]),
|
||||
limit: int = Query(20, le=50),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Browse active domain listings (public)."""
|
||||
query = select(DomainListing).where(
|
||||
DomainListing.status == ListingStatus.ACTIVE.value
|
||||
)
|
||||
|
||||
if keyword:
|
||||
query = query.where(DomainListing.domain.ilike(f"%{keyword}%"))
|
||||
|
||||
if min_price is not None:
|
||||
query = query.where(DomainListing.asking_price >= min_price)
|
||||
|
||||
if max_price is not None:
|
||||
query = query.where(DomainListing.asking_price <= max_price)
|
||||
|
||||
if verified_only:
|
||||
query = query.where(
|
||||
DomainListing.verification_status == VerificationStatus.VERIFIED.value
|
||||
)
|
||||
|
||||
# Sorting
|
||||
if sort_by == "price_asc":
|
||||
query = query.order_by(DomainListing.asking_price.asc().nullslast())
|
||||
elif sort_by == "price_desc":
|
||||
query = query.order_by(DomainListing.asking_price.desc().nullsfirst())
|
||||
elif sort_by == "popular":
|
||||
query = query.order_by(DomainListing.view_count.desc())
|
||||
else: # newest
|
||||
query = query.order_by(DomainListing.published_at.desc())
|
||||
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
listings = list(result.scalars().all())
|
||||
|
||||
responses = []
|
||||
for listing in listings:
|
||||
responses.append(ListingPublicResponse(
|
||||
domain=listing.domain,
|
||||
slug=listing.slug,
|
||||
title=listing.title,
|
||||
description=listing.description,
|
||||
asking_price=listing.asking_price,
|
||||
currency=listing.currency,
|
||||
price_type=listing.price_type,
|
||||
pounce_score=listing.pounce_score if listing.show_valuation else None,
|
||||
estimated_value=listing.estimated_value if listing.show_valuation else None,
|
||||
is_verified=listing.is_verified,
|
||||
allow_offers=listing.allow_offers,
|
||||
public_url=listing.public_url,
|
||||
seller_verified=listing.is_verified,
|
||||
seller_member_since=listing.user.created_at if listing.user else None,
|
||||
))
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
# ============== Authenticated Endpoints (before dynamic routes!) ==============
|
||||
|
||||
@router.get("/my", response_model=List[ListingResponse])
|
||||
async def get_my_listings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get current user's listings."""
|
||||
result = await db.execute(
|
||||
select(DomainListing)
|
||||
.where(DomainListing.user_id == current_user.id)
|
||||
.order_by(DomainListing.created_at.desc())
|
||||
)
|
||||
listings = list(result.scalars().all())
|
||||
|
||||
return [
|
||||
ListingResponse(
|
||||
id=listing.id,
|
||||
domain=listing.domain,
|
||||
slug=listing.slug,
|
||||
title=listing.title,
|
||||
description=listing.description,
|
||||
asking_price=listing.asking_price,
|
||||
min_offer=listing.min_offer,
|
||||
currency=listing.currency,
|
||||
price_type=listing.price_type,
|
||||
pounce_score=listing.pounce_score,
|
||||
estimated_value=listing.estimated_value,
|
||||
verification_status=listing.verification_status,
|
||||
is_verified=listing.is_verified,
|
||||
status=listing.status,
|
||||
show_valuation=listing.show_valuation,
|
||||
allow_offers=listing.allow_offers,
|
||||
view_count=listing.view_count,
|
||||
inquiry_count=listing.inquiry_count,
|
||||
public_url=listing.public_url,
|
||||
created_at=listing.created_at,
|
||||
published_at=listing.published_at,
|
||||
seller_verified=current_user.is_verified,
|
||||
seller_member_since=current_user.created_at,
|
||||
)
|
||||
for listing in listings
|
||||
]
|
||||
|
||||
|
||||
# ============== Public Dynamic Routes ==============
|
||||
|
||||
@router.get("/{slug}", response_model=ListingPublicResponse)
|
||||
async def get_listing_by_slug(
|
||||
slug: str,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""Get listing details by slug (public)."""
|
||||
result = await db.execute(
|
||||
select(DomainListing).where(
|
||||
and_(
|
||||
DomainListing.slug == slug,
|
||||
DomainListing.status == ListingStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
)
|
||||
listing = result.scalar_one_or_none()
|
||||
|
||||
if not listing:
|
||||
raise HTTPException(status_code=404, detail="Listing not found")
|
||||
|
||||
# Record view
|
||||
view = ListingView(
|
||||
listing_id=listing.id,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent", "")[:500],
|
||||
referrer=request.headers.get("referer", "")[:500],
|
||||
user_id=current_user.id if current_user else None,
|
||||
)
|
||||
db.add(view)
|
||||
|
||||
# Increment view count
|
||||
listing.view_count += 1
|
||||
await db.commit()
|
||||
|
||||
return ListingPublicResponse(
|
||||
domain=listing.domain,
|
||||
slug=listing.slug,
|
||||
title=listing.title,
|
||||
description=listing.description,
|
||||
asking_price=listing.asking_price,
|
||||
currency=listing.currency,
|
||||
price_type=listing.price_type,
|
||||
pounce_score=listing.pounce_score if listing.show_valuation else None,
|
||||
estimated_value=listing.estimated_value if listing.show_valuation else None,
|
||||
is_verified=listing.is_verified,
|
||||
allow_offers=listing.allow_offers,
|
||||
public_url=listing.public_url,
|
||||
seller_verified=listing.is_verified,
|
||||
seller_member_since=listing.user.created_at if listing.user else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{slug}/inquire")
|
||||
async def submit_inquiry(
|
||||
slug: str,
|
||||
inquiry: InquiryCreate,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Submit an inquiry for a listing (public)."""
|
||||
# Find listing
|
||||
result = await db.execute(
|
||||
select(DomainListing).where(
|
||||
and_(
|
||||
DomainListing.slug == slug,
|
||||
DomainListing.status == ListingStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
)
|
||||
listing = result.scalar_one_or_none()
|
||||
|
||||
if not listing:
|
||||
raise HTTPException(status_code=404, detail="Listing not found")
|
||||
|
||||
# Security: Check for phishing keywords
|
||||
if not _check_content_safety(inquiry.message):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Message contains blocked content. Please revise."
|
||||
)
|
||||
|
||||
# Rate limiting check (simple: max 3 inquiries per email per listing per day)
|
||||
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
existing_count = await db.execute(
|
||||
select(func.count(ListingInquiry.id)).where(
|
||||
and_(
|
||||
ListingInquiry.listing_id == listing.id,
|
||||
ListingInquiry.email == inquiry.email.lower(),
|
||||
ListingInquiry.created_at >= today_start,
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing_count.scalar() >= 3:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many inquiries. Please try again tomorrow."
|
||||
)
|
||||
|
||||
# Create inquiry
|
||||
new_inquiry = ListingInquiry(
|
||||
listing_id=listing.id,
|
||||
name=inquiry.name,
|
||||
email=inquiry.email.lower(),
|
||||
phone=inquiry.phone,
|
||||
company=inquiry.company,
|
||||
message=inquiry.message,
|
||||
offer_amount=inquiry.offer_amount,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent", "")[:500],
|
||||
)
|
||||
db.add(new_inquiry)
|
||||
|
||||
# Increment inquiry count
|
||||
listing.inquiry_count += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
# TODO: Send email notification to seller
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Your inquiry has been sent to the seller.",
|
||||
}
|
||||
|
||||
|
||||
# ============== Listing Management (Authenticated) ==============
|
||||
|
||||
@router.post("", response_model=ListingResponse)
|
||||
async def create_listing(
|
||||
data: ListingCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new domain listing."""
|
||||
# Check if domain is already listed
|
||||
existing = await db.execute(
|
||||
select(DomainListing).where(DomainListing.domain == data.domain.lower())
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="This domain is already listed")
|
||||
|
||||
# Check user's listing limit based on subscription
|
||||
user_listings = await db.execute(
|
||||
select(func.count(DomainListing.id)).where(
|
||||
DomainListing.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
listing_count = user_listings.scalar() or 0
|
||||
|
||||
# Listing limits by tier
|
||||
tier = current_user.subscription.tier if current_user.subscription else "scout"
|
||||
limits = {"scout": 2, "trader": 10, "tycoon": 50}
|
||||
max_listings = limits.get(tier, 2)
|
||||
|
||||
if listing_count >= max_listings:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Listing limit reached ({max_listings}). Upgrade your plan for more."
|
||||
)
|
||||
|
||||
# Generate slug
|
||||
slug = _generate_slug(data.domain)
|
||||
|
||||
# Check slug uniqueness
|
||||
slug_check = await db.execute(
|
||||
select(DomainListing).where(DomainListing.slug == slug)
|
||||
)
|
||||
if slug_check.scalar_one_or_none():
|
||||
slug = f"{slug}-{secrets.token_hex(4)}"
|
||||
|
||||
# Get valuation
|
||||
try:
|
||||
valuation = await valuation_service.estimate_value(data.domain, db, save_result=False)
|
||||
pounce_score = min(100, int(valuation.get("score", 50)))
|
||||
estimated_value = valuation.get("estimated_value", 0)
|
||||
except Exception:
|
||||
pounce_score = 50
|
||||
estimated_value = None
|
||||
|
||||
# Create listing
|
||||
listing = DomainListing(
|
||||
user_id=current_user.id,
|
||||
domain=data.domain.lower(),
|
||||
slug=slug,
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
asking_price=data.asking_price,
|
||||
min_offer=data.min_offer,
|
||||
currency=data.currency.upper(),
|
||||
price_type=data.price_type,
|
||||
show_valuation=data.show_valuation,
|
||||
allow_offers=data.allow_offers,
|
||||
pounce_score=pounce_score,
|
||||
estimated_value=estimated_value,
|
||||
verification_code=_generate_verification_code(),
|
||||
status=ListingStatus.DRAFT.value,
|
||||
)
|
||||
|
||||
db.add(listing)
|
||||
await db.commit()
|
||||
await db.refresh(listing)
|
||||
|
||||
return ListingResponse(
|
||||
id=listing.id,
|
||||
domain=listing.domain,
|
||||
slug=listing.slug,
|
||||
title=listing.title,
|
||||
description=listing.description,
|
||||
asking_price=listing.asking_price,
|
||||
min_offer=listing.min_offer,
|
||||
currency=listing.currency,
|
||||
price_type=listing.price_type,
|
||||
pounce_score=listing.pounce_score,
|
||||
estimated_value=listing.estimated_value,
|
||||
verification_status=listing.verification_status,
|
||||
is_verified=listing.is_verified,
|
||||
status=listing.status,
|
||||
show_valuation=listing.show_valuation,
|
||||
allow_offers=listing.allow_offers,
|
||||
view_count=listing.view_count,
|
||||
inquiry_count=listing.inquiry_count,
|
||||
public_url=listing.public_url,
|
||||
created_at=listing.created_at,
|
||||
published_at=listing.published_at,
|
||||
seller_verified=current_user.is_verified,
|
||||
seller_member_since=current_user.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{id}/inquiries", response_model=List[InquiryResponse])
|
||||
async def get_listing_inquiries(
|
||||
id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get inquiries for a listing."""
|
||||
# Verify ownership
|
||||
result = await db.execute(
|
||||
select(DomainListing).where(
|
||||
and_(
|
||||
DomainListing.id == id,
|
||||
DomainListing.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
listing = result.scalar_one_or_none()
|
||||
|
||||
if not listing:
|
||||
raise HTTPException(status_code=404, detail="Listing not found")
|
||||
|
||||
inquiries_result = await db.execute(
|
||||
select(ListingInquiry)
|
||||
.where(ListingInquiry.listing_id == id)
|
||||
.order_by(ListingInquiry.created_at.desc())
|
||||
)
|
||||
inquiries = list(inquiries_result.scalars().all())
|
||||
|
||||
return [
|
||||
InquiryResponse(
|
||||
id=inq.id,
|
||||
name=inq.name,
|
||||
email=inq.email,
|
||||
phone=inq.phone,
|
||||
company=inq.company,
|
||||
message=inq.message,
|
||||
offer_amount=inq.offer_amount,
|
||||
status=inq.status,
|
||||
created_at=inq.created_at,
|
||||
read_at=inq.read_at,
|
||||
)
|
||||
for inq in inquiries
|
||||
]
|
||||
|
||||
|
||||
@router.put("/{id}", response_model=ListingResponse)
|
||||
async def update_listing(
|
||||
id: int,
|
||||
data: ListingUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a listing."""
|
||||
result = await db.execute(
|
||||
select(DomainListing).where(
|
||||
and_(
|
||||
DomainListing.id == id,
|
||||
DomainListing.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
listing = result.scalar_one_or_none()
|
||||
|
||||
if not listing:
|
||||
raise HTTPException(status_code=404, detail="Listing not found")
|
||||
|
||||
# Update fields
|
||||
if data.title is not None:
|
||||
listing.title = data.title
|
||||
if data.description is not None:
|
||||
listing.description = data.description
|
||||
if data.asking_price is not None:
|
||||
listing.asking_price = data.asking_price
|
||||
if data.min_offer is not None:
|
||||
listing.min_offer = data.min_offer
|
||||
if data.price_type is not None:
|
||||
listing.price_type = data.price_type
|
||||
if data.show_valuation is not None:
|
||||
listing.show_valuation = data.show_valuation
|
||||
if data.allow_offers is not None:
|
||||
listing.allow_offers = data.allow_offers
|
||||
|
||||
# Status change
|
||||
if data.status is not None:
|
||||
if data.status == "active" and listing.status == "draft":
|
||||
# Publish listing
|
||||
if not listing.is_verified:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot publish without DNS verification"
|
||||
)
|
||||
listing.status = ListingStatus.ACTIVE.value
|
||||
listing.published_at = datetime.utcnow()
|
||||
elif data.status in ["draft", "sold", "expired"]:
|
||||
listing.status = data.status
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(listing)
|
||||
|
||||
return ListingResponse(
|
||||
id=listing.id,
|
||||
domain=listing.domain,
|
||||
slug=listing.slug,
|
||||
title=listing.title,
|
||||
description=listing.description,
|
||||
asking_price=listing.asking_price,
|
||||
min_offer=listing.min_offer,
|
||||
currency=listing.currency,
|
||||
price_type=listing.price_type,
|
||||
pounce_score=listing.pounce_score,
|
||||
estimated_value=listing.estimated_value,
|
||||
verification_status=listing.verification_status,
|
||||
is_verified=listing.is_verified,
|
||||
status=listing.status,
|
||||
show_valuation=listing.show_valuation,
|
||||
allow_offers=listing.allow_offers,
|
||||
view_count=listing.view_count,
|
||||
inquiry_count=listing.inquiry_count,
|
||||
public_url=listing.public_url,
|
||||
created_at=listing.created_at,
|
||||
published_at=listing.published_at,
|
||||
seller_verified=current_user.is_verified,
|
||||
seller_member_since=current_user.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{id}")
|
||||
async def delete_listing(
|
||||
id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a listing."""
|
||||
result = await db.execute(
|
||||
select(DomainListing).where(
|
||||
and_(
|
||||
DomainListing.id == id,
|
||||
DomainListing.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
listing = result.scalar_one_or_none()
|
||||
|
||||
if not listing:
|
||||
raise HTTPException(status_code=404, detail="Listing not found")
|
||||
|
||||
await db.delete(listing)
|
||||
await db.commit()
|
||||
|
||||
return {"success": True, "message": "Listing deleted"}
|
||||
|
||||
|
||||
@router.post("/{id}/verify-dns", response_model=VerificationResponse)
|
||||
async def start_dns_verification(
|
||||
id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Start DNS verification for a listing."""
|
||||
result = await db.execute(
|
||||
select(DomainListing).where(
|
||||
and_(
|
||||
DomainListing.id == id,
|
||||
DomainListing.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
listing = result.scalar_one_or_none()
|
||||
|
||||
if not listing:
|
||||
raise HTTPException(status_code=404, detail="Listing not found")
|
||||
|
||||
# Generate new code if needed
|
||||
if not listing.verification_code:
|
||||
listing.verification_code = _generate_verification_code()
|
||||
|
||||
listing.verification_status = VerificationStatus.PENDING.value
|
||||
await db.commit()
|
||||
|
||||
# Extract domain root for DNS
|
||||
domain_parts = listing.domain.split('.')
|
||||
if len(domain_parts) > 2:
|
||||
dns_name = f"_pounce.{'.'.join(domain_parts[-2:])}"
|
||||
else:
|
||||
dns_name = f"_pounce.{listing.domain}"
|
||||
|
||||
return VerificationResponse(
|
||||
verification_code=listing.verification_code,
|
||||
dns_record_type="TXT",
|
||||
dns_record_name=dns_name,
|
||||
dns_record_value=listing.verification_code,
|
||||
instructions=f"""
|
||||
To verify ownership of {listing.domain}:
|
||||
|
||||
1. Go to your domain registrar's DNS settings
|
||||
2. Add a new TXT record:
|
||||
- Name/Host: _pounce (or _pounce.{listing.domain})
|
||||
- Value: {listing.verification_code}
|
||||
- TTL: 300 (or lowest available)
|
||||
3. Wait 1-5 minutes for DNS propagation
|
||||
4. Click "Check Verification" to complete
|
||||
|
||||
This proves you control the domain's DNS, confirming ownership.
|
||||
""".strip(),
|
||||
status=listing.verification_status,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{id}/verify-dns/check")
|
||||
async def check_dns_verification(
|
||||
id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Check DNS verification status."""
|
||||
result = await db.execute(
|
||||
select(DomainListing).where(
|
||||
and_(
|
||||
DomainListing.id == id,
|
||||
DomainListing.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
listing = result.scalar_one_or_none()
|
||||
|
||||
if not listing:
|
||||
raise HTTPException(status_code=404, detail="Listing not found")
|
||||
|
||||
if not listing.verification_code:
|
||||
raise HTTPException(status_code=400, detail="Start verification first")
|
||||
|
||||
# Check DNS TXT record
|
||||
import dns.resolver
|
||||
|
||||
try:
|
||||
domain_parts = listing.domain.split('.')
|
||||
if len(domain_parts) > 2:
|
||||
dns_name = f"_pounce.{'.'.join(domain_parts[-2:])}"
|
||||
else:
|
||||
dns_name = f"_pounce.{listing.domain}"
|
||||
|
||||
answers = dns.resolver.resolve(dns_name, 'TXT')
|
||||
|
||||
for rdata in answers:
|
||||
txt_value = str(rdata).strip('"')
|
||||
if txt_value == listing.verification_code:
|
||||
# Verified!
|
||||
listing.verification_status = VerificationStatus.VERIFIED.value
|
||||
listing.verified_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"verified": True,
|
||||
"status": "verified",
|
||||
"message": "DNS verification successful! You can now publish your listing.",
|
||||
}
|
||||
|
||||
# Code not found
|
||||
return {
|
||||
"verified": False,
|
||||
"status": "pending",
|
||||
"message": "TXT record found but value doesn't match. Please check the value.",
|
||||
}
|
||||
|
||||
except dns.resolver.NXDOMAIN:
|
||||
return {
|
||||
"verified": False,
|
||||
"status": "pending",
|
||||
"message": "DNS record not found. Please add the TXT record and wait for propagation.",
|
||||
}
|
||||
except dns.resolver.NoAnswer:
|
||||
return {
|
||||
"verified": False,
|
||||
"status": "pending",
|
||||
"message": "No TXT record found. Please add the record.",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"DNS check failed for {listing.domain}: {e}")
|
||||
return {
|
||||
"verified": False,
|
||||
"status": "error",
|
||||
"message": "DNS check failed. Please try again in a few minutes.",
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ from sqlalchemy import select
|
||||
from app.api.deps import Database
|
||||
from app.config import get_settings
|
||||
from app.models.user import User
|
||||
from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG
|
||||
from app.services.auth import AuthService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -110,15 +111,30 @@ async def get_or_create_oauth_user(
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# Auto-admin for specific email
|
||||
# Auto-admin for specific email - always admin + verified + Tycoon
|
||||
ADMIN_EMAILS = ["guggeryves@hotmail.com"]
|
||||
if user.email.lower() in [e.lower() for e in ADMIN_EMAILS]:
|
||||
is_admin_user = user.email.lower() in [e.lower() for e in ADMIN_EMAILS]
|
||||
|
||||
if is_admin_user:
|
||||
user.is_admin = True
|
||||
user.is_verified = True
|
||||
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Create Tycoon subscription for admin users
|
||||
if is_admin_user:
|
||||
tycoon_config = TIER_CONFIG.get(SubscriptionTier.TYCOON, {})
|
||||
subscription = Subscription(
|
||||
user_id=user.id,
|
||||
tier=SubscriptionTier.TYCOON,
|
||||
status=SubscriptionStatus.ACTIVE,
|
||||
max_domains=tycoon_config.get("domain_limit", 500),
|
||||
)
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
|
||||
return user, True
|
||||
|
||||
|
||||
@ -187,7 +203,7 @@ async def google_callback(
|
||||
)
|
||||
|
||||
# Parse redirect from state
|
||||
redirect_path = "/dashboard"
|
||||
redirect_path = "/command/dashboard"
|
||||
if ":" in state:
|
||||
_, redirect_path = state.split(":", 1)
|
||||
|
||||
@ -296,7 +312,7 @@ async def github_callback(
|
||||
)
|
||||
|
||||
# Parse redirect from state
|
||||
redirect_path = "/dashboard"
|
||||
redirect_path = "/command/dashboard"
|
||||
if ":" in state:
|
||||
_, redirect_path = state.split(":", 1)
|
||||
|
||||
|
||||
242
backend/app/api/seo.py
Normal file
242
backend/app/api/seo.py
Normal file
@ -0,0 +1,242 @@
|
||||
"""
|
||||
SEO Data API - "SEO Juice Detector"
|
||||
|
||||
This implements Strategie 3 from analysis_3.md:
|
||||
"Das Feature: 'SEO Juice Detector'
|
||||
Wenn eine Domain droppt, prüfst du nicht nur den Namen,
|
||||
sondern ob Backlinks existieren.
|
||||
Monetarisierung: Das ist ein reines Tycoon-Feature ($29/Monat)."
|
||||
|
||||
Endpoints:
|
||||
- GET /seo/{domain} - Get SEO data for a domain (TYCOON ONLY)
|
||||
- POST /seo/batch - Analyze multiple domains (TYCOON ONLY)
|
||||
"""
|
||||
import logging
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.seo_analyzer import seo_analyzer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============== Schemas ==============
|
||||
|
||||
class SEOMetrics(BaseModel):
|
||||
domain_authority: int | None
|
||||
page_authority: int | None
|
||||
spam_score: int | None
|
||||
total_backlinks: int | None
|
||||
referring_domains: int | None
|
||||
|
||||
|
||||
class NotableLinks(BaseModel):
|
||||
has_wikipedia: bool
|
||||
has_gov: bool
|
||||
has_edu: bool
|
||||
has_news: bool
|
||||
notable_domains: List[str]
|
||||
|
||||
|
||||
class BacklinkInfo(BaseModel):
|
||||
domain: str
|
||||
authority: int
|
||||
page: str = ""
|
||||
|
||||
|
||||
class SEOResponse(BaseModel):
|
||||
domain: str
|
||||
seo_score: int
|
||||
value_category: str
|
||||
metrics: SEOMetrics
|
||||
notable_links: NotableLinks
|
||||
top_backlinks: List[BacklinkInfo]
|
||||
estimated_value: float | None
|
||||
data_source: str
|
||||
last_updated: str | None
|
||||
is_estimated: bool
|
||||
|
||||
|
||||
class BatchSEORequest(BaseModel):
|
||||
domains: List[str]
|
||||
|
||||
|
||||
class BatchSEOResponse(BaseModel):
|
||||
results: List[SEOResponse]
|
||||
total_requested: int
|
||||
total_processed: int
|
||||
|
||||
|
||||
# ============== Helper ==============
|
||||
|
||||
def _check_tycoon_access(user: User) -> None:
|
||||
"""Verify user has Tycoon tier access."""
|
||||
if not user.subscription:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="SEO data is a Tycoon feature. Please upgrade your subscription."
|
||||
)
|
||||
|
||||
tier = user.subscription.tier.lower() if user.subscription.tier else ""
|
||||
if tier != "tycoon":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="SEO data is a Tycoon-only feature. Please upgrade to access backlink analysis."
|
||||
)
|
||||
|
||||
|
||||
# ============== Endpoints ==============
|
||||
|
||||
@router.get("/{domain}", response_model=SEOResponse)
|
||||
async def get_seo_data(
|
||||
domain: str,
|
||||
force_refresh: bool = Query(False, description="Force refresh from API"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get SEO data for a domain.
|
||||
|
||||
TYCOON FEATURE ONLY.
|
||||
|
||||
Returns:
|
||||
- Domain Authority (0-100)
|
||||
- Page Authority (0-100)
|
||||
- Spam Score (0-100)
|
||||
- Total Backlinks
|
||||
- Referring Domains
|
||||
- Notable links (Wikipedia, .gov, .edu, news sites)
|
||||
- Top backlinks with authority scores
|
||||
- Estimated SEO value
|
||||
|
||||
From analysis_3.md:
|
||||
"Domain `alte-bäckerei-münchen.de` ist frei.
|
||||
Hat Links von `sueddeutsche.de` und `wikipedia.org`."
|
||||
"""
|
||||
# Check Tycoon access
|
||||
_check_tycoon_access(current_user)
|
||||
|
||||
# Clean domain input
|
||||
domain = domain.lower().strip()
|
||||
if domain.startswith('http://'):
|
||||
domain = domain[7:]
|
||||
if domain.startswith('https://'):
|
||||
domain = domain[8:]
|
||||
if domain.startswith('www.'):
|
||||
domain = domain[4:]
|
||||
domain = domain.rstrip('/')
|
||||
|
||||
# Get SEO data
|
||||
result = await seo_analyzer.analyze_domain(domain, db, force_refresh)
|
||||
|
||||
return SEOResponse(**result)
|
||||
|
||||
|
||||
@router.post("/batch", response_model=BatchSEOResponse)
|
||||
async def batch_seo_analysis(
|
||||
request: BatchSEORequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Analyze multiple domains for SEO data.
|
||||
|
||||
TYCOON FEATURE ONLY.
|
||||
|
||||
Limited to 10 domains per request to prevent abuse.
|
||||
"""
|
||||
# Check Tycoon access
|
||||
_check_tycoon_access(current_user)
|
||||
|
||||
# Limit batch size
|
||||
domains = request.domains[:10]
|
||||
|
||||
results = []
|
||||
for domain in domains:
|
||||
try:
|
||||
# Clean domain
|
||||
domain = domain.lower().strip()
|
||||
if domain.startswith('http://'):
|
||||
domain = domain[7:]
|
||||
if domain.startswith('https://'):
|
||||
domain = domain[8:]
|
||||
if domain.startswith('www.'):
|
||||
domain = domain[4:]
|
||||
domain = domain.rstrip('/')
|
||||
|
||||
result = await seo_analyzer.analyze_domain(domain, db)
|
||||
results.append(SEOResponse(**result))
|
||||
except Exception as e:
|
||||
logger.error(f"Error analyzing {domain}: {e}")
|
||||
# Skip failed domains
|
||||
continue
|
||||
|
||||
return BatchSEOResponse(
|
||||
results=results,
|
||||
total_requested=len(request.domains),
|
||||
total_processed=len(results),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{domain}/quick")
|
||||
async def get_seo_quick_summary(
|
||||
domain: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get a quick SEO summary for a domain.
|
||||
|
||||
This is a lighter version that shows basic metrics without full backlink analysis.
|
||||
Available to Trader+ users.
|
||||
"""
|
||||
# Check at least Trader access
|
||||
if not current_user.subscription:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="SEO data requires a paid subscription."
|
||||
)
|
||||
|
||||
tier = current_user.subscription.tier.lower() if current_user.subscription.tier else ""
|
||||
if tier == "scout":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="SEO data requires Trader or higher subscription."
|
||||
)
|
||||
|
||||
# Clean domain
|
||||
domain = domain.lower().strip().rstrip('/')
|
||||
if domain.startswith('http://'):
|
||||
domain = domain[7:]
|
||||
if domain.startswith('https://'):
|
||||
domain = domain[8:]
|
||||
if domain.startswith('www.'):
|
||||
domain = domain[4:]
|
||||
|
||||
result = await seo_analyzer.analyze_domain(domain, db)
|
||||
|
||||
# Return limited data for non-Tycoon
|
||||
if tier != "tycoon":
|
||||
return {
|
||||
'domain': result['domain'],
|
||||
'seo_score': result['seo_score'],
|
||||
'value_category': result['value_category'],
|
||||
'domain_authority': result['metrics']['domain_authority'],
|
||||
'has_notable_links': (
|
||||
result['notable_links']['has_wikipedia'] or
|
||||
result['notable_links']['has_gov'] or
|
||||
result['notable_links']['has_news']
|
||||
),
|
||||
'is_estimated': result['is_estimated'],
|
||||
'upgrade_for_details': True,
|
||||
'message': "Upgrade to Tycoon for full backlink analysis"
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
457
backend/app/api/sniper_alerts.py
Normal file
457
backend/app/api/sniper_alerts.py
Normal file
@ -0,0 +1,457 @@
|
||||
"""
|
||||
Sniper Alerts API - Hyper-personalized auction notifications
|
||||
|
||||
This implements "Strategie 4: Alerts nach Maß" from analysis_3.md:
|
||||
"Der User kann extrem spezifische Filter speichern:
|
||||
- Informiere mich NUR, wenn eine 4-Letter .com Domain droppt, die kein 'q' oder 'x' enthält."
|
||||
|
||||
Endpoints:
|
||||
- GET /sniper-alerts - Get user's alerts
|
||||
- POST /sniper-alerts - Create new alert
|
||||
- PUT /sniper-alerts/{id} - Update alert
|
||||
- DELETE /sniper-alerts/{id} - Delete alert
|
||||
- GET /sniper-alerts/{id}/matches - Get matched auctions
|
||||
- POST /sniper-alerts/{id}/test - Test alert against current auctions
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.sniper_alert import SniperAlert, SniperAlertMatch
|
||||
from app.models.auction import DomainAuction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============== Schemas ==============
|
||||
|
||||
class SniperAlertCreate(BaseModel):
|
||||
"""Create a new sniper alert."""
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
# Filter criteria
|
||||
tlds: Optional[str] = Field(None, description="Comma-separated TLDs: com,io,ai")
|
||||
keywords: Optional[str] = Field(None, description="Must contain (comma-separated)")
|
||||
exclude_keywords: Optional[str] = Field(None, description="Must not contain")
|
||||
max_length: Optional[int] = Field(None, ge=1, le=63)
|
||||
min_length: Optional[int] = Field(None, ge=1, le=63)
|
||||
max_price: Optional[float] = Field(None, ge=0)
|
||||
min_price: Optional[float] = Field(None, ge=0)
|
||||
max_bids: Optional[int] = Field(None, ge=0, description="Max bids (low competition)")
|
||||
ending_within_hours: Optional[int] = Field(None, ge=1, le=168)
|
||||
platforms: Optional[str] = Field(None, description="Comma-separated platforms")
|
||||
|
||||
# Advanced
|
||||
no_numbers: bool = False
|
||||
no_hyphens: bool = False
|
||||
exclude_chars: Optional[str] = Field(None, description="Chars to exclude: q,x,z")
|
||||
|
||||
# Notifications
|
||||
notify_email: bool = True
|
||||
notify_sms: bool = False
|
||||
|
||||
|
||||
class SniperAlertUpdate(BaseModel):
|
||||
"""Update a sniper alert."""
|
||||
name: Optional[str] = Field(None, max_length=100)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
tlds: Optional[str] = None
|
||||
keywords: Optional[str] = None
|
||||
exclude_keywords: Optional[str] = None
|
||||
max_length: Optional[int] = Field(None, ge=1, le=63)
|
||||
min_length: Optional[int] = Field(None, ge=1, le=63)
|
||||
max_price: Optional[float] = Field(None, ge=0)
|
||||
min_price: Optional[float] = Field(None, ge=0)
|
||||
max_bids: Optional[int] = Field(None, ge=0)
|
||||
ending_within_hours: Optional[int] = Field(None, ge=1, le=168)
|
||||
platforms: Optional[str] = None
|
||||
no_numbers: Optional[bool] = None
|
||||
no_hyphens: Optional[bool] = None
|
||||
exclude_chars: Optional[str] = None
|
||||
notify_email: Optional[bool] = None
|
||||
notify_sms: Optional[bool] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class SniperAlertResponse(BaseModel):
|
||||
"""Sniper alert response."""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
tlds: Optional[str]
|
||||
keywords: Optional[str]
|
||||
exclude_keywords: Optional[str]
|
||||
max_length: Optional[int]
|
||||
min_length: Optional[int]
|
||||
max_price: Optional[float]
|
||||
min_price: Optional[float]
|
||||
max_bids: Optional[int]
|
||||
ending_within_hours: Optional[int]
|
||||
platforms: Optional[str]
|
||||
no_numbers: bool
|
||||
no_hyphens: bool
|
||||
exclude_chars: Optional[str]
|
||||
notify_email: bool
|
||||
notify_sms: bool
|
||||
is_active: bool
|
||||
matches_count: int
|
||||
notifications_sent: int
|
||||
last_matched_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MatchResponse(BaseModel):
|
||||
"""Alert match response."""
|
||||
id: int
|
||||
domain: str
|
||||
platform: str
|
||||
current_bid: float
|
||||
end_time: datetime
|
||||
auction_url: Optional[str]
|
||||
matched_at: datetime
|
||||
notified: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============== Endpoints ==============
|
||||
|
||||
@router.get("", response_model=List[SniperAlertResponse])
|
||||
async def get_sniper_alerts(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get user's sniper alerts."""
|
||||
result = await db.execute(
|
||||
select(SniperAlert)
|
||||
.where(SniperAlert.user_id == current_user.id)
|
||||
.order_by(SniperAlert.created_at.desc())
|
||||
)
|
||||
alerts = list(result.scalars().all())
|
||||
|
||||
return [
|
||||
SniperAlertResponse(
|
||||
id=alert.id,
|
||||
name=alert.name,
|
||||
description=alert.description,
|
||||
tlds=alert.tlds,
|
||||
keywords=alert.keywords,
|
||||
exclude_keywords=alert.exclude_keywords,
|
||||
max_length=alert.max_length,
|
||||
min_length=alert.min_length,
|
||||
max_price=alert.max_price,
|
||||
min_price=alert.min_price,
|
||||
max_bids=alert.max_bids,
|
||||
ending_within_hours=alert.ending_within_hours,
|
||||
platforms=alert.platforms,
|
||||
no_numbers=alert.no_numbers,
|
||||
no_hyphens=alert.no_hyphens,
|
||||
exclude_chars=alert.exclude_chars,
|
||||
notify_email=alert.notify_email,
|
||||
notify_sms=alert.notify_sms,
|
||||
is_active=alert.is_active,
|
||||
matches_count=alert.matches_count,
|
||||
notifications_sent=alert.notifications_sent,
|
||||
last_matched_at=alert.last_matched_at,
|
||||
created_at=alert.created_at,
|
||||
)
|
||||
for alert in alerts
|
||||
]
|
||||
|
||||
|
||||
@router.post("", response_model=SniperAlertResponse)
|
||||
async def create_sniper_alert(
|
||||
data: SniperAlertCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new sniper alert."""
|
||||
# Check alert limit based on subscription
|
||||
user_alerts = await db.execute(
|
||||
select(func.count(SniperAlert.id)).where(
|
||||
SniperAlert.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
alert_count = user_alerts.scalar() or 0
|
||||
|
||||
tier = current_user.subscription.tier if current_user.subscription else "scout"
|
||||
limits = {"scout": 2, "trader": 10, "tycoon": 50}
|
||||
max_alerts = limits.get(tier, 2)
|
||||
|
||||
if alert_count >= max_alerts:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Alert limit reached ({max_alerts}). Upgrade for more."
|
||||
)
|
||||
|
||||
# SMS notifications are Tycoon only
|
||||
if data.notify_sms and tier != "tycoon":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="SMS notifications are a Tycoon feature"
|
||||
)
|
||||
|
||||
# Build filter criteria JSON
|
||||
filter_criteria = {
|
||||
"tlds": data.tlds.split(',') if data.tlds else None,
|
||||
"keywords": data.keywords.split(',') if data.keywords else None,
|
||||
"exclude_keywords": data.exclude_keywords.split(',') if data.exclude_keywords else None,
|
||||
"max_length": data.max_length,
|
||||
"min_length": data.min_length,
|
||||
"max_price": data.max_price,
|
||||
"min_price": data.min_price,
|
||||
"max_bids": data.max_bids,
|
||||
"ending_within_hours": data.ending_within_hours,
|
||||
"platforms": data.platforms.split(',') if data.platforms else None,
|
||||
"no_numbers": data.no_numbers,
|
||||
"no_hyphens": data.no_hyphens,
|
||||
"exclude_chars": data.exclude_chars.split(',') if data.exclude_chars else None,
|
||||
}
|
||||
|
||||
alert = SniperAlert(
|
||||
user_id=current_user.id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
filter_criteria=filter_criteria,
|
||||
tlds=data.tlds,
|
||||
keywords=data.keywords,
|
||||
exclude_keywords=data.exclude_keywords,
|
||||
max_length=data.max_length,
|
||||
min_length=data.min_length,
|
||||
max_price=data.max_price,
|
||||
min_price=data.min_price,
|
||||
max_bids=data.max_bids,
|
||||
ending_within_hours=data.ending_within_hours,
|
||||
platforms=data.platforms,
|
||||
no_numbers=data.no_numbers,
|
||||
no_hyphens=data.no_hyphens,
|
||||
exclude_chars=data.exclude_chars,
|
||||
notify_email=data.notify_email,
|
||||
notify_sms=data.notify_sms,
|
||||
)
|
||||
|
||||
db.add(alert)
|
||||
await db.commit()
|
||||
await db.refresh(alert)
|
||||
|
||||
return SniperAlertResponse(
|
||||
id=alert.id,
|
||||
name=alert.name,
|
||||
description=alert.description,
|
||||
tlds=alert.tlds,
|
||||
keywords=alert.keywords,
|
||||
exclude_keywords=alert.exclude_keywords,
|
||||
max_length=alert.max_length,
|
||||
min_length=alert.min_length,
|
||||
max_price=alert.max_price,
|
||||
min_price=alert.min_price,
|
||||
max_bids=alert.max_bids,
|
||||
ending_within_hours=alert.ending_within_hours,
|
||||
platforms=alert.platforms,
|
||||
no_numbers=alert.no_numbers,
|
||||
no_hyphens=alert.no_hyphens,
|
||||
exclude_chars=alert.exclude_chars,
|
||||
notify_email=alert.notify_email,
|
||||
notify_sms=alert.notify_sms,
|
||||
is_active=alert.is_active,
|
||||
matches_count=alert.matches_count,
|
||||
notifications_sent=alert.notifications_sent,
|
||||
last_matched_at=alert.last_matched_at,
|
||||
created_at=alert.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{id}", response_model=SniperAlertResponse)
|
||||
async def update_sniper_alert(
|
||||
id: int,
|
||||
data: SniperAlertUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a sniper alert."""
|
||||
result = await db.execute(
|
||||
select(SniperAlert).where(
|
||||
and_(
|
||||
SniperAlert.id == id,
|
||||
SniperAlert.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
alert = result.scalar_one_or_none()
|
||||
|
||||
if not alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
|
||||
# Update fields
|
||||
update_fields = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_fields.items():
|
||||
if hasattr(alert, field):
|
||||
setattr(alert, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(alert)
|
||||
|
||||
return SniperAlertResponse(
|
||||
id=alert.id,
|
||||
name=alert.name,
|
||||
description=alert.description,
|
||||
tlds=alert.tlds,
|
||||
keywords=alert.keywords,
|
||||
exclude_keywords=alert.exclude_keywords,
|
||||
max_length=alert.max_length,
|
||||
min_length=alert.min_length,
|
||||
max_price=alert.max_price,
|
||||
min_price=alert.min_price,
|
||||
max_bids=alert.max_bids,
|
||||
ending_within_hours=alert.ending_within_hours,
|
||||
platforms=alert.platforms,
|
||||
no_numbers=alert.no_numbers,
|
||||
no_hyphens=alert.no_hyphens,
|
||||
exclude_chars=alert.exclude_chars,
|
||||
notify_email=alert.notify_email,
|
||||
notify_sms=alert.notify_sms,
|
||||
is_active=alert.is_active,
|
||||
matches_count=alert.matches_count,
|
||||
notifications_sent=alert.notifications_sent,
|
||||
last_matched_at=alert.last_matched_at,
|
||||
created_at=alert.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{id}")
|
||||
async def delete_sniper_alert(
|
||||
id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a sniper alert."""
|
||||
result = await db.execute(
|
||||
select(SniperAlert).where(
|
||||
and_(
|
||||
SniperAlert.id == id,
|
||||
SniperAlert.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
alert = result.scalar_one_or_none()
|
||||
|
||||
if not alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
|
||||
await db.delete(alert)
|
||||
await db.commit()
|
||||
|
||||
return {"success": True, "message": "Alert deleted"}
|
||||
|
||||
|
||||
@router.get("/{id}/matches", response_model=List[MatchResponse])
|
||||
async def get_alert_matches(
|
||||
id: int,
|
||||
limit: int = 50,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get matched auctions for an alert."""
|
||||
# Verify ownership
|
||||
result = await db.execute(
|
||||
select(SniperAlert).where(
|
||||
and_(
|
||||
SniperAlert.id == id,
|
||||
SniperAlert.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
alert = result.scalar_one_or_none()
|
||||
|
||||
if not alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
|
||||
matches_result = await db.execute(
|
||||
select(SniperAlertMatch)
|
||||
.where(SniperAlertMatch.alert_id == id)
|
||||
.order_by(SniperAlertMatch.matched_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
matches = list(matches_result.scalars().all())
|
||||
|
||||
return [
|
||||
MatchResponse(
|
||||
id=m.id,
|
||||
domain=m.domain,
|
||||
platform=m.platform,
|
||||
current_bid=m.current_bid,
|
||||
end_time=m.end_time,
|
||||
auction_url=m.auction_url,
|
||||
matched_at=m.matched_at,
|
||||
notified=m.notified,
|
||||
)
|
||||
for m in matches
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{id}/test")
|
||||
async def test_sniper_alert(
|
||||
id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Test alert against current auctions."""
|
||||
# Verify ownership
|
||||
result = await db.execute(
|
||||
select(SniperAlert).where(
|
||||
and_(
|
||||
SniperAlert.id == id,
|
||||
SniperAlert.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
alert = result.scalar_one_or_none()
|
||||
|
||||
if not alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
|
||||
# Get active auctions
|
||||
auctions_result = await db.execute(
|
||||
select(DomainAuction)
|
||||
.where(DomainAuction.is_active == True)
|
||||
.limit(500)
|
||||
)
|
||||
auctions = list(auctions_result.scalars().all())
|
||||
|
||||
matches = []
|
||||
for auction in auctions:
|
||||
if alert.matches_domain(
|
||||
auction.domain,
|
||||
auction.tld,
|
||||
auction.current_bid,
|
||||
auction.num_bids
|
||||
):
|
||||
matches.append({
|
||||
"domain": auction.domain,
|
||||
"platform": auction.platform,
|
||||
"current_bid": auction.current_bid,
|
||||
"num_bids": auction.num_bids,
|
||||
"end_time": auction.end_time.isoformat(),
|
||||
})
|
||||
|
||||
return {
|
||||
"alert_name": alert.name,
|
||||
"auctions_checked": len(auctions),
|
||||
"matches_found": len(matches),
|
||||
"matches": matches[:20], # Limit to 20 for preview
|
||||
"message": f"Found {len(matches)} matching auctions" if matches else "No matches found. Try adjusting your criteria.",
|
||||
}
|
||||
|
||||
@ -225,7 +225,7 @@ async def create_checkout_session(
|
||||
# Get site URL from environment
|
||||
site_url = os.getenv("SITE_URL", "http://localhost:3000")
|
||||
|
||||
success_url = request.success_url or f"{site_url}/dashboard?upgraded=true"
|
||||
success_url = request.success_url or f"{site_url}/command/welcome?plan={request.plan}"
|
||||
cancel_url = request.cancel_url or f"{site_url}/pricing?cancelled=true"
|
||||
|
||||
try:
|
||||
@ -285,7 +285,7 @@ async def create_portal_session(
|
||||
)
|
||||
|
||||
site_url = os.getenv("SITE_URL", "http://localhost:3000")
|
||||
return_url = f"{site_url}/dashboard"
|
||||
return_url = f"{site_url}/command/settings"
|
||||
|
||||
try:
|
||||
portal_url = await StripeService.create_portal_session(
|
||||
|
||||
@ -326,6 +326,89 @@ def get_max_price(tld_data: dict) -> float:
|
||||
return max(r["register"] for r in tld_data["registrars"].values())
|
||||
|
||||
|
||||
def get_min_renewal_price(tld_data: dict) -> float:
|
||||
"""Get minimum renewal price."""
|
||||
return min(r["renew"] for r in tld_data["registrars"].values())
|
||||
|
||||
|
||||
def get_avg_renewal_price(tld_data: dict) -> float:
|
||||
"""Calculate average renewal price across registrars."""
|
||||
prices = [r["renew"] for r in tld_data["registrars"].values()]
|
||||
return round(sum(prices) / len(prices), 2)
|
||||
|
||||
|
||||
def calculate_price_trends(tld: str, trend: str) -> dict:
|
||||
"""
|
||||
Calculate price change trends based on TLD characteristics.
|
||||
|
||||
In a real implementation, this would query historical price data.
|
||||
For now, we estimate based on known market trends.
|
||||
"""
|
||||
# Known TLD price trend data (based on market research)
|
||||
KNOWN_TRENDS = {
|
||||
# Rising TLDs (AI boom, tech demand)
|
||||
"ai": {"1y": 15.0, "3y": 45.0},
|
||||
"io": {"1y": 5.0, "3y": 12.0},
|
||||
"app": {"1y": 3.0, "3y": 8.0},
|
||||
"dev": {"1y": 2.0, "3y": 5.0},
|
||||
|
||||
# Stable/Slight increase (registry price increases)
|
||||
"com": {"1y": 7.0, "3y": 14.0},
|
||||
"net": {"1y": 5.0, "3y": 10.0},
|
||||
"org": {"1y": 4.0, "3y": 8.0},
|
||||
|
||||
# ccTLDs (mostly stable)
|
||||
"ch": {"1y": 0.0, "3y": 2.0},
|
||||
"de": {"1y": 0.0, "3y": 1.0},
|
||||
"uk": {"1y": 1.0, "3y": 3.0},
|
||||
"co": {"1y": 3.0, "3y": 7.0},
|
||||
"eu": {"1y": 0.0, "3y": 2.0},
|
||||
|
||||
# Promo-driven (volatile)
|
||||
"xyz": {"1y": -10.0, "3y": -5.0},
|
||||
"online": {"1y": -5.0, "3y": 0.0},
|
||||
"store": {"1y": -8.0, "3y": -3.0},
|
||||
"tech": {"1y": 0.0, "3y": 5.0},
|
||||
"site": {"1y": -5.0, "3y": 0.0},
|
||||
}
|
||||
|
||||
if tld in KNOWN_TRENDS:
|
||||
return KNOWN_TRENDS[tld]
|
||||
|
||||
# Default based on trend field
|
||||
if trend == "up":
|
||||
return {"1y": 8.0, "3y": 20.0}
|
||||
elif trend == "down":
|
||||
return {"1y": -5.0, "3y": -10.0}
|
||||
else:
|
||||
return {"1y": 2.0, "3y": 5.0}
|
||||
|
||||
|
||||
def calculate_risk_level(min_price: float, min_renewal: float, trend_1y: float) -> dict:
|
||||
"""
|
||||
Calculate risk level for a TLD based on renewal ratio and volatility.
|
||||
|
||||
Returns:
|
||||
dict with 'level' (low/medium/high) and 'reason'
|
||||
"""
|
||||
renewal_ratio = min_renewal / min_price if min_price > 0 else 1
|
||||
|
||||
# High risk: Renewal trap (ratio > 3x) or very volatile
|
||||
if renewal_ratio > 3:
|
||||
return {"level": "high", "reason": "Renewal Trap"}
|
||||
|
||||
# Medium risk: Moderate renewal (2-3x) or rising fast
|
||||
if renewal_ratio > 2:
|
||||
return {"level": "medium", "reason": "High Renewal"}
|
||||
if trend_1y > 20:
|
||||
return {"level": "medium", "reason": "Rising Fast"}
|
||||
|
||||
# Low risk
|
||||
if trend_1y > 0:
|
||||
return {"level": "low", "reason": "Stable Rising"}
|
||||
return {"level": "low", "reason": "Stable"}
|
||||
|
||||
|
||||
# Top TLDs by popularity (based on actual domain registration volumes)
|
||||
TOP_TLDS_BY_POPULARITY = [
|
||||
"com", "net", "org", "de", "uk", "cn", "ru", "nl", "br", "au",
|
||||
@ -366,15 +449,28 @@ async def get_tld_overview(
|
||||
# This ensures consistency with /compare endpoint which also uses static data first
|
||||
if source in ["auto", "static"]:
|
||||
for tld, data in TLD_DATA.items():
|
||||
min_price = get_min_price(data)
|
||||
min_renewal = get_min_renewal_price(data)
|
||||
trend = data.get("trend", "stable")
|
||||
price_trends = calculate_price_trends(tld, trend)
|
||||
risk = calculate_risk_level(min_price, min_renewal, price_trends["1y"])
|
||||
|
||||
tld_list.append({
|
||||
"tld": tld,
|
||||
"type": data["type"],
|
||||
"description": data["description"],
|
||||
"avg_registration_price": get_avg_price(data),
|
||||
"min_registration_price": get_min_price(data),
|
||||
"min_registration_price": min_price,
|
||||
"max_registration_price": get_max_price(data),
|
||||
"min_renewal_price": min_renewal,
|
||||
"avg_renewal_price": get_avg_renewal_price(data),
|
||||
"registrar_count": len(data["registrars"]),
|
||||
"trend": data["trend"],
|
||||
"trend": trend,
|
||||
"price_change_7d": round(price_trends["1y"] / 52, 2), # Weekly estimate
|
||||
"price_change_1y": price_trends["1y"],
|
||||
"price_change_3y": price_trends["3y"],
|
||||
"risk_level": risk["level"],
|
||||
"risk_reason": risk["reason"],
|
||||
"popularity_rank": TOP_TLDS_BY_POPULARITY.index(tld) if tld in TOP_TLDS_BY_POPULARITY else 999,
|
||||
})
|
||||
tld_seen.add(tld)
|
||||
@ -389,15 +485,34 @@ async def get_tld_overview(
|
||||
for tld, data in db_prices.items():
|
||||
if tld not in tld_seen: # Only add if not already from static
|
||||
prices = data["prices"]
|
||||
min_price = min(prices)
|
||||
avg_price = round(sum(prices) / len(prices), 2)
|
||||
|
||||
# Get renewal prices from registrar data
|
||||
renewal_prices = [r["renew"] for r in data["registrars"].values() if r.get("renew")]
|
||||
min_renewal = min(renewal_prices) if renewal_prices else avg_price
|
||||
avg_renewal = round(sum(renewal_prices) / len(renewal_prices), 2) if renewal_prices else avg_price
|
||||
|
||||
# Calculate trends and risk
|
||||
price_trends = calculate_price_trends(tld, "stable")
|
||||
risk = calculate_risk_level(min_price, min_renewal, price_trends["1y"])
|
||||
|
||||
tld_list.append({
|
||||
"tld": tld,
|
||||
"type": guess_tld_type(tld),
|
||||
"description": f".{tld} domain extension",
|
||||
"avg_registration_price": round(sum(prices) / len(prices), 2),
|
||||
"min_registration_price": min(prices),
|
||||
"avg_registration_price": avg_price,
|
||||
"min_registration_price": min_price,
|
||||
"max_registration_price": max(prices),
|
||||
"min_renewal_price": min_renewal,
|
||||
"avg_renewal_price": avg_renewal,
|
||||
"registrar_count": len(data["registrars"]),
|
||||
"trend": "stable",
|
||||
"price_change_7d": round(price_trends["1y"] / 52, 2),
|
||||
"price_change_1y": price_trends["1y"],
|
||||
"price_change_3y": price_trends["3y"],
|
||||
"risk_level": risk["level"],
|
||||
"risk_reason": risk["reason"],
|
||||
"popularity_rank": TOP_TLDS_BY_POPULARITY.index(tld) if tld in TOP_TLDS_BY_POPULARITY else 999,
|
||||
})
|
||||
tld_seen.add(tld)
|
||||
|
||||
@ -9,6 +9,9 @@ from app.models.newsletter import NewsletterSubscriber
|
||||
from app.models.price_alert import PriceAlert
|
||||
from app.models.admin_log import AdminActivityLog
|
||||
from app.models.blog import BlogPost
|
||||
from app.models.listing import DomainListing, ListingInquiry, ListingView
|
||||
from app.models.sniper_alert import SniperAlert, SniperAlertMatch
|
||||
from app.models.seo_data import DomainSEOData
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@ -25,4 +28,13 @@ __all__ = [
|
||||
"PriceAlert",
|
||||
"AdminActivityLog",
|
||||
"BlogPost",
|
||||
# New: For Sale / Marketplace
|
||||
"DomainListing",
|
||||
"ListingInquiry",
|
||||
"ListingView",
|
||||
# New: Sniper Alerts
|
||||
"SniperAlert",
|
||||
"SniperAlertMatch",
|
||||
# New: SEO Data (Tycoon feature)
|
||||
"DomainSEOData",
|
||||
]
|
||||
|
||||
203
backend/app/models/listing.py
Normal file
203
backend/app/models/listing.py
Normal file
@ -0,0 +1,203 @@
|
||||
"""
|
||||
Domain Listing models for "Pounce For Sale" feature.
|
||||
|
||||
This implements the "Micro-Marktplatz" strategy from analysis_3.md:
|
||||
- Users can create professional landing pages for domains they want to sell
|
||||
- Buyers can contact sellers through Pounce
|
||||
- DNS verification ensures only real owners can list domains
|
||||
|
||||
DATABASE TABLES TO CREATE:
|
||||
1. domain_listings - Main listing table
|
||||
2. listing_inquiries - Contact requests from potential buyers
|
||||
3. listing_views - Track views for analytics
|
||||
|
||||
Run migrations: alembic upgrade head
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import String, DateTime, Float, Integer, Text, ForeignKey, Boolean, Enum as SQLEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
import enum
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ListingStatus(str, enum.Enum):
|
||||
"""Status of a domain listing."""
|
||||
DRAFT = "draft" # Not yet published
|
||||
PENDING_VERIFICATION = "pending_verification" # Awaiting DNS verification
|
||||
ACTIVE = "active" # Live and visible
|
||||
SOLD = "sold" # Marked as sold
|
||||
EXPIRED = "expired" # Listing expired
|
||||
SUSPENDED = "suspended" # Suspended by admin
|
||||
|
||||
|
||||
class VerificationStatus(str, enum.Enum):
|
||||
"""DNS verification status."""
|
||||
NOT_STARTED = "not_started"
|
||||
PENDING = "pending"
|
||||
VERIFIED = "verified"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class DomainListing(Base):
|
||||
"""
|
||||
Domain listing for the Pounce marketplace.
|
||||
|
||||
Users can list their domains for sale with a professional landing page.
|
||||
URL: pounce.ch/buy/{slug}
|
||||
|
||||
Features:
|
||||
- DNS verification for ownership proof
|
||||
- Professional landing page with valuation
|
||||
- Contact form for buyers
|
||||
- Analytics (views, inquiries)
|
||||
|
||||
From analysis_3.md:
|
||||
"Ein User (Trader/Tycoon) kann für seine Domains mit einem Klick
|
||||
eine schicke Verkaufsseite erstellen."
|
||||
"""
|
||||
|
||||
__tablename__ = "domain_listings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
|
||||
|
||||
# Domain info
|
||||
domain: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(300), unique=True, nullable=False, index=True)
|
||||
|
||||
# Listing details
|
||||
title: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) # Custom headline
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Pricing
|
||||
asking_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
min_offer: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="USD")
|
||||
price_type: Mapped[str] = mapped_column(String(20), default="fixed") # fixed, negotiable, make_offer
|
||||
|
||||
# Pounce valuation (calculated)
|
||||
pounce_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # 0-100
|
||||
estimated_value: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Verification (from analysis_3.md - Säule 2: Asset Verification)
|
||||
verification_status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default=VerificationStatus.NOT_STARTED.value
|
||||
)
|
||||
verification_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
verified_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Status
|
||||
status: Mapped[str] = mapped_column(String(30), default=ListingStatus.DRAFT.value, index=True)
|
||||
|
||||
# Features
|
||||
show_valuation: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
allow_offers: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
featured: Mapped[bool] = mapped_column(Boolean, default=False) # Premium placement
|
||||
|
||||
# Analytics
|
||||
view_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
inquiry_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Expiry
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
published_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship("User", back_populates="listings")
|
||||
inquiries: Mapped[List["ListingInquiry"]] = relationship(
|
||||
"ListingInquiry", back_populates="listing", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DomainListing {self.domain} ({self.status})>"
|
||||
|
||||
@property
|
||||
def is_verified(self) -> bool:
|
||||
return self.verification_status == VerificationStatus.VERIFIED.value
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.status == ListingStatus.ACTIVE.value
|
||||
|
||||
@property
|
||||
def public_url(self) -> str:
|
||||
return f"/buy/{self.slug}"
|
||||
|
||||
|
||||
class ListingInquiry(Base):
|
||||
"""
|
||||
Contact request from a potential buyer.
|
||||
|
||||
From analysis_3.md:
|
||||
"Ein einfaches Kontaktformular, das die Anfrage direkt an den User leitet."
|
||||
|
||||
Security (from analysis_3.md - Säule 3):
|
||||
- Keyword blocking for phishing prevention
|
||||
- Rate limiting per IP/user
|
||||
"""
|
||||
|
||||
__tablename__ = "listing_inquiries"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
listing_id: Mapped[int] = mapped_column(ForeignKey("domain_listings.id"), index=True, nullable=False)
|
||||
|
||||
# Inquirer info
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
company: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||
|
||||
# Message
|
||||
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
offer_amount: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Status
|
||||
status: Mapped[str] = mapped_column(String(20), default="new") # new, read, replied, spam
|
||||
|
||||
# Tracking
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
read_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
replied_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
listing: Mapped["DomainListing"] = relationship("DomainListing", back_populates="inquiries")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ListingInquiry from {self.email} for listing #{self.listing_id}>"
|
||||
|
||||
|
||||
class ListingView(Base):
|
||||
"""
|
||||
Track listing page views for analytics.
|
||||
"""
|
||||
|
||||
__tablename__ = "listing_views"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
listing_id: Mapped[int] = mapped_column(ForeignKey("domain_listings.id"), index=True, nullable=False)
|
||||
|
||||
# Visitor info
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
referrer: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# User (if logged in)
|
||||
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# Timestamp
|
||||
viewed_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ListingView #{self.listing_id} at {self.viewed_at}>"
|
||||
|
||||
@ -48,7 +48,7 @@ class PriceAlert(Base):
|
||||
)
|
||||
|
||||
# Relationship to user
|
||||
user: Mapped["User"] = relationship("User", backref="price_alerts")
|
||||
user: Mapped["User"] = relationship("User", back_populates="price_alerts")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = "active" if self.is_active else "paused"
|
||||
|
||||
116
backend/app/models/seo_data.py
Normal file
116
backend/app/models/seo_data.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""
|
||||
SEO Data models for the "SEO Juice Detector" feature.
|
||||
|
||||
This implements "Strategie 3: SEO-Daten & Backlinks" from analysis_3.md:
|
||||
"SEO-Agenturen suchen Domains nicht wegen dem Namen, sondern wegen der Power (Backlinks).
|
||||
Wenn eine Domain droppt, prüfst du nicht nur den Namen, sondern ob Backlinks existieren."
|
||||
|
||||
This is a TYCOON-ONLY feature ($29/month).
|
||||
|
||||
DATABASE TABLE TO CREATE:
|
||||
- domain_seo_data - Cached SEO metrics for domains
|
||||
|
||||
Run migrations: alembic upgrade head
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import String, DateTime, Float, Integer, Text, ForeignKey, Boolean, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class DomainSEOData(Base):
|
||||
"""
|
||||
Cached SEO data for domains.
|
||||
|
||||
Stores backlink data, domain authority, and other SEO metrics
|
||||
from Moz API or alternative sources.
|
||||
|
||||
From analysis_3.md:
|
||||
"Domain `alte-bäckerei-münchen.de` ist frei.
|
||||
Hat Links von `sueddeutsche.de` und `wikipedia.org`."
|
||||
"""
|
||||
|
||||
__tablename__ = "domain_seo_data"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
domain: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
|
||||
# Moz metrics
|
||||
domain_authority: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # 0-100
|
||||
page_authority: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # 0-100
|
||||
spam_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # 0-100
|
||||
|
||||
# Backlink data
|
||||
total_backlinks: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
referring_domains: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Top backlinks (JSON array of {domain, authority, type})
|
||||
top_backlinks: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# Notable backlinks (high-authority sites)
|
||||
notable_backlinks: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # Comma-separated
|
||||
has_wikipedia_link: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
has_gov_link: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
has_edu_link: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
has_news_link: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Estimated value based on SEO
|
||||
seo_value_estimate: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Data source
|
||||
data_source: Mapped[str] = mapped_column(String(50), default="moz") # moz, ahrefs, majestic, estimated
|
||||
|
||||
# Cache management
|
||||
last_updated: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Request tracking
|
||||
fetch_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DomainSEOData {self.domain} DA:{self.domain_authority}>"
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
if not self.expires_at:
|
||||
return True
|
||||
return datetime.utcnow() > self.expires_at
|
||||
|
||||
@property
|
||||
def seo_score(self) -> int:
|
||||
"""Calculate overall SEO score (0-100)."""
|
||||
if not self.domain_authority:
|
||||
return 0
|
||||
|
||||
score = self.domain_authority
|
||||
|
||||
# Boost for notable links
|
||||
if self.has_wikipedia_link:
|
||||
score = min(100, score + 10)
|
||||
if self.has_gov_link:
|
||||
score = min(100, score + 5)
|
||||
if self.has_edu_link:
|
||||
score = min(100, score + 5)
|
||||
if self.has_news_link:
|
||||
score = min(100, score + 3)
|
||||
|
||||
# Penalty for spam
|
||||
if self.spam_score and self.spam_score > 30:
|
||||
score = max(0, score - (self.spam_score // 5))
|
||||
|
||||
return score
|
||||
|
||||
@property
|
||||
def value_category(self) -> str:
|
||||
"""Categorize SEO value for display."""
|
||||
score = self.seo_score
|
||||
if score >= 60:
|
||||
return "High Value"
|
||||
elif score >= 40:
|
||||
return "Medium Value"
|
||||
elif score >= 20:
|
||||
return "Low Value"
|
||||
return "Minimal"
|
||||
|
||||
183
backend/app/models/sniper_alert.py
Normal file
183
backend/app/models/sniper_alert.py
Normal file
@ -0,0 +1,183 @@
|
||||
"""
|
||||
Sniper Alert models for hyper-personalized auction alerts.
|
||||
|
||||
This implements "Strategie 4: Alerts nach Maß" from analysis_3.md:
|
||||
"Der User kann extrem spezifische Filter speichern:
|
||||
- Informiere mich NUR, wenn eine 4-Letter .com Domain droppt, die kein 'q' oder 'x' enthält.
|
||||
- Informiere mich, wenn eine .ch Domain droppt, die das Wort 'Immo' enthält."
|
||||
|
||||
DATABASE TABLES TO CREATE:
|
||||
1. sniper_alerts - Saved filter configurations
|
||||
2. sniper_alert_matches - Matched auctions for each alert
|
||||
3. sniper_alert_notifications - Sent notifications
|
||||
|
||||
Run migrations: alembic upgrade head
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import String, DateTime, Float, Integer, Text, ForeignKey, Boolean, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class SniperAlert(Base):
|
||||
"""
|
||||
Saved filter for hyper-personalized auction alerts.
|
||||
|
||||
Users can define very specific criteria and get notified
|
||||
when matching domains appear in auctions.
|
||||
|
||||
Example filters:
|
||||
- "4-letter .com without q or x"
|
||||
- ".ch domains containing 'immo'"
|
||||
- "Auctions under $100 ending in 1 hour"
|
||||
|
||||
From analysis_3.md:
|
||||
"Wenn die SMS/Mail kommt, weiß der User: Das ist relevant."
|
||||
"""
|
||||
|
||||
__tablename__ = "sniper_alerts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
|
||||
|
||||
# Alert name
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Filter criteria (stored as JSON for flexibility)
|
||||
# Example: {"tlds": ["com", "io"], "max_length": 4, "exclude_chars": ["q", "x"]}
|
||||
filter_criteria: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
|
||||
# Individual filter fields (for database queries)
|
||||
tlds: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Comma-separated: "com,io,ai"
|
||||
keywords: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Must contain
|
||||
exclude_keywords: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Must not contain
|
||||
max_length: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
min_length: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
max_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
min_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
max_bids: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Low competition
|
||||
ending_within_hours: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Urgency
|
||||
platforms: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) # Comma-separated
|
||||
|
||||
# Advanced filters
|
||||
no_numbers: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
no_hyphens: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
exclude_chars: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) # "q,x,z"
|
||||
|
||||
# Notification settings
|
||||
notify_email: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
notify_sms: Mapped[bool] = mapped_column(Boolean, default=False) # Tycoon feature
|
||||
notify_push: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Frequency limits
|
||||
max_notifications_per_day: Mapped[int] = mapped_column(Integer, default=10)
|
||||
cooldown_minutes: Mapped[int] = mapped_column(Integer, default=30) # Min time between alerts
|
||||
|
||||
# Status
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Stats
|
||||
matches_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
notifications_sent: Mapped[int] = mapped_column(Integer, default=0)
|
||||
last_matched_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
last_notified_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship("User", back_populates="sniper_alerts")
|
||||
matches: Mapped[List["SniperAlertMatch"]] = relationship(
|
||||
"SniperAlertMatch", back_populates="alert", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<SniperAlert '{self.name}' (user={self.user_id})>"
|
||||
|
||||
def matches_domain(self, domain: str, tld: str, price: float, num_bids: int) -> bool:
|
||||
"""Check if a domain matches this alert's criteria."""
|
||||
name = domain.split('.')[0] if '.' in domain else domain
|
||||
|
||||
# TLD filter
|
||||
if self.tlds:
|
||||
allowed_tlds = [t.strip().lower() for t in self.tlds.split(',')]
|
||||
if tld.lower() not in allowed_tlds:
|
||||
return False
|
||||
|
||||
# Length filters
|
||||
if self.max_length and len(name) > self.max_length:
|
||||
return False
|
||||
if self.min_length and len(name) < self.min_length:
|
||||
return False
|
||||
|
||||
# Price filters
|
||||
if self.max_price and price > self.max_price:
|
||||
return False
|
||||
if self.min_price and price < self.min_price:
|
||||
return False
|
||||
|
||||
# Competition filter
|
||||
if self.max_bids and num_bids > self.max_bids:
|
||||
return False
|
||||
|
||||
# Keyword filters
|
||||
if self.keywords:
|
||||
required = [k.strip().lower() for k in self.keywords.split(',')]
|
||||
if not any(kw in name.lower() for kw in required):
|
||||
return False
|
||||
|
||||
if self.exclude_keywords:
|
||||
excluded = [k.strip().lower() for k in self.exclude_keywords.split(',')]
|
||||
if any(kw in name.lower() for kw in excluded):
|
||||
return False
|
||||
|
||||
# Character filters
|
||||
if self.no_numbers and any(c.isdigit() for c in name):
|
||||
return False
|
||||
|
||||
if self.no_hyphens and '-' in name:
|
||||
return False
|
||||
|
||||
if self.exclude_chars:
|
||||
excluded_chars = [c.strip().lower() for c in self.exclude_chars.split(',')]
|
||||
if any(c in name.lower() for c in excluded_chars):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class SniperAlertMatch(Base):
|
||||
"""
|
||||
Record of a domain that matched a sniper alert.
|
||||
"""
|
||||
|
||||
__tablename__ = "sniper_alert_matches"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
alert_id: Mapped[int] = mapped_column(ForeignKey("sniper_alerts.id"), index=True, nullable=False)
|
||||
|
||||
# Matched auction info
|
||||
domain: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
platform: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
current_bid: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
end_time: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
auction_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Status
|
||||
notified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
clicked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Timestamps
|
||||
matched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
notified_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
alert: Mapped["SniperAlert"] = relationship("SniperAlert", back_populates="matches")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<SniperAlertMatch {self.domain} for alert #{self.alert_id}>"
|
||||
|
||||
@ -57,6 +57,17 @@ class User(Base):
|
||||
portfolio_domains: Mapped[List["PortfolioDomain"]] = relationship(
|
||||
"PortfolioDomain", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
price_alerts: Mapped[List["PriceAlert"]] = relationship(
|
||||
"PriceAlert", cascade="all, delete-orphan", passive_deletes=True
|
||||
)
|
||||
# For Sale Marketplace
|
||||
listings: Mapped[List["DomainListing"]] = relationship(
|
||||
"DomainListing", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
# Sniper Alerts
|
||||
sniper_alerts: Mapped[List["SniperAlert"]] = relationship(
|
||||
"SniperAlert", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.email}>"
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
"""Background scheduler for domain checks, TLD price scraping, and notifications."""
|
||||
"""Background scheduler for domain checks, TLD price scraping, auctions, and notifications."""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import AsyncSessionLocal
|
||||
@ -16,6 +17,10 @@ from app.services.domain_checker import domain_checker
|
||||
from app.services.email_service import email_service
|
||||
from app.services.price_tracker import price_tracker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.sniper_alert import SniperAlert
|
||||
from app.models.auction import DomainAuction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
@ -315,7 +320,164 @@ async def scrape_auctions():
|
||||
|
||||
if result.get('errors'):
|
||||
logger.warning(f"Scrape errors: {result['errors']}")
|
||||
|
||||
# Match new auctions against Sniper Alerts
|
||||
if result['total_new'] > 0:
|
||||
await match_sniper_alerts()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Auction scrape failed: {e}")
|
||||
|
||||
|
||||
async def match_sniper_alerts():
|
||||
"""Match active sniper alerts against current auctions and notify users."""
|
||||
from app.models.sniper_alert import SniperAlert, SniperAlertMatch
|
||||
from app.models.auction import DomainAuction
|
||||
|
||||
logger.info("Matching sniper alerts against new auctions...")
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Get all active sniper alerts
|
||||
alerts_result = await db.execute(
|
||||
select(SniperAlert).where(SniperAlert.is_active == True)
|
||||
)
|
||||
alerts = alerts_result.scalars().all()
|
||||
|
||||
if not alerts:
|
||||
logger.info("No active sniper alerts to match")
|
||||
return
|
||||
|
||||
# Get recent auctions (added in last 2 hours)
|
||||
cutoff = datetime.utcnow() - timedelta(hours=2)
|
||||
auctions_result = await db.execute(
|
||||
select(DomainAuction).where(
|
||||
and_(
|
||||
DomainAuction.is_active == True,
|
||||
DomainAuction.scraped_at >= cutoff,
|
||||
)
|
||||
)
|
||||
)
|
||||
auctions = auctions_result.scalars().all()
|
||||
|
||||
if not auctions:
|
||||
logger.info("No recent auctions to match against")
|
||||
return
|
||||
|
||||
matches_created = 0
|
||||
notifications_sent = 0
|
||||
|
||||
for alert in alerts:
|
||||
matching_auctions = []
|
||||
|
||||
for auction in auctions:
|
||||
if _auction_matches_alert(auction, alert):
|
||||
matching_auctions.append(auction)
|
||||
|
||||
if matching_auctions:
|
||||
for auction in matching_auctions:
|
||||
# Check if this match already exists
|
||||
existing = await db.execute(
|
||||
select(SniperAlertMatch).where(
|
||||
and_(
|
||||
SniperAlertMatch.alert_id == alert.id,
|
||||
SniperAlertMatch.domain == auction.domain,
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
continue
|
||||
|
||||
# Create new match
|
||||
match = SniperAlertMatch(
|
||||
alert_id=alert.id,
|
||||
domain=auction.domain,
|
||||
platform=auction.platform,
|
||||
current_bid=auction.current_bid,
|
||||
end_time=auction.end_time,
|
||||
auction_url=auction.auction_url,
|
||||
matched_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(match)
|
||||
matches_created += 1
|
||||
|
||||
# Update alert last_triggered
|
||||
alert.last_triggered = datetime.utcnow()
|
||||
|
||||
# Send notification if enabled
|
||||
if alert.notify_email:
|
||||
try:
|
||||
user_result = await db.execute(
|
||||
select(User).where(User.id == alert.user_id)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if user and email_service.is_enabled:
|
||||
# Send email with matching domains
|
||||
domains_list = ", ".join([a.domain for a in matching_auctions[:5]])
|
||||
await email_service.send_email(
|
||||
to_email=user.email,
|
||||
subject=f"🎯 Sniper Alert: {len(matching_auctions)} matching domains found!",
|
||||
html_content=f"""
|
||||
<h2>Your Sniper Alert "{alert.name}" matched!</h2>
|
||||
<p>We found {len(matching_auctions)} domains matching your criteria:</p>
|
||||
<ul>
|
||||
{"".join(f"<li><strong>{a.domain}</strong> - ${a.current_bid:.0f} on {a.platform}</li>" for a in matching_auctions[:10])}
|
||||
</ul>
|
||||
<p><a href="https://pounce.ch/command/alerts">View all matches in your Command Center</a></p>
|
||||
"""
|
||||
)
|
||||
notifications_sent += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send sniper alert notification: {e}")
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Sniper alert matching complete: {matches_created} matches created, {notifications_sent} notifications sent")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Sniper alert matching failed: {e}")
|
||||
|
||||
|
||||
def _auction_matches_alert(auction: "DomainAuction", alert: "SniperAlert") -> bool:
|
||||
"""Check if an auction matches the criteria of a sniper alert."""
|
||||
domain_name = auction.domain.rsplit('.', 1)[0] if '.' in auction.domain else auction.domain
|
||||
|
||||
# Check keyword filter
|
||||
if alert.keyword:
|
||||
if alert.keyword.lower() not in domain_name.lower():
|
||||
return False
|
||||
|
||||
# Check TLD filter
|
||||
if alert.tlds:
|
||||
allowed_tlds = [t.strip().lower() for t in alert.tlds.split(',')]
|
||||
if auction.tld.lower() not in allowed_tlds:
|
||||
return False
|
||||
|
||||
# Check length filters
|
||||
if alert.min_length and len(domain_name) < alert.min_length:
|
||||
return False
|
||||
if alert.max_length and len(domain_name) > alert.max_length:
|
||||
return False
|
||||
|
||||
# Check price filters
|
||||
if alert.min_price and auction.current_bid < alert.min_price:
|
||||
return False
|
||||
if alert.max_price and auction.current_bid > alert.max_price:
|
||||
return False
|
||||
|
||||
# Check exclusion filters
|
||||
if alert.exclude_numbers:
|
||||
if any(c.isdigit() for c in domain_name):
|
||||
return False
|
||||
|
||||
if alert.exclude_hyphens:
|
||||
if '-' in domain_name:
|
||||
return False
|
||||
|
||||
if alert.exclude_chars:
|
||||
excluded = set(alert.exclude_chars.lower())
|
||||
if any(c in excluded for c in domain_name.lower()):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@ -58,8 +58,11 @@ class AuthService:
|
||||
|
||||
@staticmethod
|
||||
async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]:
|
||||
"""Get user by email."""
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
"""Get user by email (case-insensitive)."""
|
||||
from sqlalchemy import func
|
||||
result = await db.execute(
|
||||
select(User).where(func.lower(User.email) == email.lower())
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
@ -89,9 +92,9 @@ class AuthService:
|
||||
name: Optional[str] = None
|
||||
) -> User:
|
||||
"""Create a new user with default subscription."""
|
||||
# Create user
|
||||
# Create user (normalize email to lowercase)
|
||||
user = User(
|
||||
email=email,
|
||||
email=email.lower().strip(),
|
||||
hashed_password=AuthService.hash_password(password),
|
||||
name=name,
|
||||
)
|
||||
|
||||
521
backend/app/services/domain_health.py
Normal file
521
backend/app/services/domain_health.py
Normal file
@ -0,0 +1,521 @@
|
||||
"""
|
||||
🏥 POUNCE DOMAIN HEALTH ENGINE
|
||||
|
||||
Advanced domain health analysis for premium intelligence.
|
||||
|
||||
Implements 4-layer analysis from analysis_2.md:
|
||||
1. DNS Layer - Infrastructure check (nameservers, MX, A records)
|
||||
2. HTTP Layer - Website availability (status codes, content, parking detection)
|
||||
3. SSL Layer - Certificate validity
|
||||
4. WHOIS/RDAP Layer - Registration status
|
||||
|
||||
Output: Health Score (HEALTHY, WEAKENING, PARKED, CRITICAL)
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import ssl
|
||||
import socket
|
||||
import re
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
import httpx
|
||||
import dns.resolver
|
||||
import dns.exception
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HealthStatus(str, Enum):
|
||||
"""Domain health status levels."""
|
||||
HEALTHY = "healthy" # 🟢 All systems go
|
||||
WEAKENING = "weakening" # 🟡 Warning signs detected
|
||||
PARKED = "parked" # 🟠 Domain for sale/parked
|
||||
CRITICAL = "critical" # 🔴 Drop imminent
|
||||
UNKNOWN = "unknown" # ❓ Could not determine
|
||||
|
||||
|
||||
@dataclass
|
||||
class DNSCheckResult:
|
||||
"""Results from DNS layer check."""
|
||||
has_nameservers: bool = False
|
||||
nameservers: List[str] = field(default_factory=list)
|
||||
has_mx_records: bool = False
|
||||
mx_records: List[str] = field(default_factory=list)
|
||||
has_a_record: bool = False
|
||||
a_records: List[str] = field(default_factory=list)
|
||||
is_parking_ns: bool = False # Nameservers point to parking service
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class HTTPCheckResult:
|
||||
"""Results from HTTP layer check."""
|
||||
status_code: Optional[int] = None
|
||||
is_reachable: bool = False
|
||||
content_length: int = 0
|
||||
is_parked: bool = False
|
||||
parking_signals: List[str] = field(default_factory=list)
|
||||
redirect_url: Optional[str] = None
|
||||
response_time_ms: Optional[float] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSLCheckResult:
|
||||
"""Results from SSL layer check."""
|
||||
has_ssl: bool = False
|
||||
is_valid: bool = False
|
||||
expires_at: Optional[datetime] = None
|
||||
days_until_expiry: Optional[int] = None
|
||||
issuer: Optional[str] = None
|
||||
is_expired: bool = False
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DomainHealthReport:
|
||||
"""Complete health report for a domain."""
|
||||
domain: str
|
||||
status: HealthStatus
|
||||
score: int # 0-100
|
||||
|
||||
# Layer results
|
||||
dns: Optional[DNSCheckResult] = None
|
||||
http: Optional[HTTPCheckResult] = None
|
||||
ssl: Optional[SSLCheckResult] = None
|
||||
|
||||
# Summary
|
||||
signals: List[str] = field(default_factory=list)
|
||||
recommendations: List[str] = field(default_factory=list)
|
||||
|
||||
# Metadata
|
||||
checked_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for API response."""
|
||||
return {
|
||||
"domain": self.domain,
|
||||
"status": self.status.value,
|
||||
"score": self.score,
|
||||
"signals": self.signals,
|
||||
"recommendations": self.recommendations,
|
||||
"checked_at": self.checked_at.isoformat(),
|
||||
"layers": {
|
||||
"dns": {
|
||||
"has_nameservers": self.dns.has_nameservers if self.dns else False,
|
||||
"nameservers": self.dns.nameservers if self.dns else [],
|
||||
"has_mx_records": self.dns.has_mx_records if self.dns else False,
|
||||
"is_parking_ns": self.dns.is_parking_ns if self.dns else False,
|
||||
} if self.dns else None,
|
||||
"http": {
|
||||
"status_code": self.http.status_code if self.http else None,
|
||||
"is_reachable": self.http.is_reachable if self.http else False,
|
||||
"is_parked": self.http.is_parked if self.http else False,
|
||||
"response_time_ms": self.http.response_time_ms if self.http else None,
|
||||
} if self.http else None,
|
||||
"ssl": {
|
||||
"has_ssl": self.ssl.has_ssl if self.ssl else False,
|
||||
"is_valid": self.ssl.is_valid if self.ssl else False,
|
||||
"days_until_expiry": self.ssl.days_until_expiry if self.ssl else None,
|
||||
"is_expired": self.ssl.is_expired if self.ssl else False,
|
||||
} if self.ssl else None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class DomainHealthChecker:
|
||||
"""
|
||||
Premium domain health analysis engine.
|
||||
|
||||
Checks 4 layers to determine domain health:
|
||||
1. DNS: Is the infrastructure alive?
|
||||
2. HTTP: Is the website running?
|
||||
3. SSL: Is the certificate valid?
|
||||
4. (WHOIS handled by existing DomainChecker)
|
||||
"""
|
||||
|
||||
# Known parking/for-sale service nameservers
|
||||
PARKING_NAMESERVERS = {
|
||||
'sedoparking.com', 'afternic.com', 'domaincontrol.com',
|
||||
'parkingcrew.net', 'bodis.com', 'dsredirection.com',
|
||||
'above.com', 'domainsponsor.com', 'fastpark.net',
|
||||
'parkdomain.com', 'domainmarket.com', 'hugedomains.com',
|
||||
}
|
||||
|
||||
# Keywords indicating parked/for-sale pages
|
||||
PARKING_KEYWORDS = [
|
||||
'domain is for sale', 'buy this domain', 'inquire now',
|
||||
'make an offer', 'domain zum verkauf', 'domain for sale',
|
||||
'this domain is parked', 'parked by', 'related links',
|
||||
'sponsored listings', 'domain parking', 'this website is for sale',
|
||||
'purchase this domain', 'acquire this domain',
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
self._dns_resolver = dns.resolver.Resolver()
|
||||
self._dns_resolver.timeout = 3
|
||||
self._dns_resolver.lifetime = 5
|
||||
|
||||
async def check_domain(self, domain: str) -> DomainHealthReport:
|
||||
"""
|
||||
Perform comprehensive health check on a domain.
|
||||
|
||||
Args:
|
||||
domain: Domain name to check (e.g., "example.com")
|
||||
|
||||
Returns:
|
||||
DomainHealthReport with status, score, and detailed results
|
||||
"""
|
||||
domain = self._normalize_domain(domain)
|
||||
logger.info(f"🏥 Starting health check for: {domain}")
|
||||
|
||||
# Run all checks concurrently
|
||||
dns_task = asyncio.create_task(self._check_dns(domain))
|
||||
http_task = asyncio.create_task(self._check_http(domain))
|
||||
ssl_task = asyncio.create_task(self._check_ssl(domain))
|
||||
|
||||
dns_result, http_result, ssl_result = await asyncio.gather(
|
||||
dns_task, http_task, ssl_task,
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
# Handle exceptions
|
||||
if isinstance(dns_result, Exception):
|
||||
logger.warning(f"DNS check failed: {dns_result}")
|
||||
dns_result = DNSCheckResult(error=str(dns_result))
|
||||
if isinstance(http_result, Exception):
|
||||
logger.warning(f"HTTP check failed: {http_result}")
|
||||
http_result = HTTPCheckResult(error=str(http_result))
|
||||
if isinstance(ssl_result, Exception):
|
||||
logger.warning(f"SSL check failed: {ssl_result}")
|
||||
ssl_result = SSLCheckResult(error=str(ssl_result))
|
||||
|
||||
# Calculate health score and status
|
||||
report = self._calculate_health(domain, dns_result, http_result, ssl_result)
|
||||
|
||||
logger.info(f"✅ Health check complete: {domain} = {report.status.value} ({report.score}/100)")
|
||||
return report
|
||||
|
||||
def _normalize_domain(self, domain: str) -> str:
|
||||
"""Normalize domain name."""
|
||||
domain = domain.lower().strip()
|
||||
if domain.startswith('http://'):
|
||||
domain = domain[7:]
|
||||
elif domain.startswith('https://'):
|
||||
domain = domain[8:]
|
||||
if domain.startswith('www.'):
|
||||
domain = domain[4:]
|
||||
domain = domain.split('/')[0]
|
||||
return domain
|
||||
|
||||
async def _check_dns(self, domain: str) -> DNSCheckResult:
|
||||
"""
|
||||
Layer 1: DNS Infrastructure Check
|
||||
|
||||
Checks:
|
||||
- NS records (nameservers)
|
||||
- MX records (mail)
|
||||
- A records (IP address)
|
||||
"""
|
||||
result = DNSCheckResult()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Check NS records
|
||||
try:
|
||||
ns_answers = await loop.run_in_executor(
|
||||
None, lambda: self._dns_resolver.resolve(domain, 'NS')
|
||||
)
|
||||
result.nameservers = [str(rdata.target).rstrip('.').lower() for rdata in ns_answers]
|
||||
result.has_nameservers = len(result.nameservers) > 0
|
||||
|
||||
# Check if nameservers point to parking service
|
||||
for ns in result.nameservers:
|
||||
for parking_ns in self.PARKING_NAMESERVERS:
|
||||
if parking_ns in ns:
|
||||
result.is_parking_ns = True
|
||||
break
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.exception.Timeout):
|
||||
result.has_nameservers = False
|
||||
except Exception as e:
|
||||
result.error = str(e)
|
||||
|
||||
# Check MX records
|
||||
try:
|
||||
mx_answers = await loop.run_in_executor(
|
||||
None, lambda: self._dns_resolver.resolve(domain, 'MX')
|
||||
)
|
||||
result.mx_records = [str(rdata.exchange).rstrip('.').lower() for rdata in mx_answers]
|
||||
result.has_mx_records = len(result.mx_records) > 0
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.exception.Timeout):
|
||||
result.has_mx_records = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check A records
|
||||
try:
|
||||
a_answers = await loop.run_in_executor(
|
||||
None, lambda: self._dns_resolver.resolve(domain, 'A')
|
||||
)
|
||||
result.a_records = [str(rdata.address) for rdata in a_answers]
|
||||
result.has_a_record = len(result.a_records) > 0
|
||||
|
||||
# Check for dead IPs (0.0.0.0 or 127.0.0.1)
|
||||
dead_ips = {'0.0.0.0', '127.0.0.1'}
|
||||
if all(ip in dead_ips for ip in result.a_records):
|
||||
result.has_a_record = False
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.exception.Timeout):
|
||||
result.has_a_record = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
async def _check_http(self, domain: str) -> HTTPCheckResult:
|
||||
"""
|
||||
Layer 2: HTTP Website Check
|
||||
|
||||
Checks:
|
||||
- HTTP status code
|
||||
- Response content
|
||||
- Parking/for-sale detection
|
||||
"""
|
||||
result = HTTPCheckResult()
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=10.0,
|
||||
follow_redirects=True,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
}
|
||||
) as client:
|
||||
for scheme in ['https', 'http']:
|
||||
url = f"{scheme}://{domain}"
|
||||
try:
|
||||
start = asyncio.get_event_loop().time()
|
||||
response = await client.get(url)
|
||||
end = asyncio.get_event_loop().time()
|
||||
|
||||
result.status_code = response.status_code
|
||||
result.is_reachable = response.status_code < 500
|
||||
result.content_length = len(response.content)
|
||||
result.response_time_ms = (end - start) * 1000
|
||||
|
||||
# Check for redirects
|
||||
if response.history:
|
||||
result.redirect_url = str(response.url)
|
||||
|
||||
# Check for parking keywords in content
|
||||
content = response.text.lower()
|
||||
for keyword in self.PARKING_KEYWORDS:
|
||||
if keyword in content:
|
||||
result.is_parked = True
|
||||
result.parking_signals.append(keyword)
|
||||
|
||||
break # Success, no need to try other scheme
|
||||
|
||||
except httpx.TimeoutException:
|
||||
result.error = "timeout"
|
||||
except httpx.ConnectError:
|
||||
result.error = "connection_refused"
|
||||
except Exception as e:
|
||||
result.error = str(e)
|
||||
|
||||
return result
|
||||
|
||||
async def _check_ssl(self, domain: str) -> SSLCheckResult:
|
||||
"""
|
||||
Layer 3: SSL Certificate Check
|
||||
|
||||
Checks:
|
||||
- Certificate exists
|
||||
- Certificate validity
|
||||
- Expiration date
|
||||
"""
|
||||
result = SSLCheckResult()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
try:
|
||||
def get_ssl_info():
|
||||
context = ssl.create_default_context()
|
||||
with socket.create_connection((domain, 443), timeout=5) as sock:
|
||||
with context.wrap_socket(sock, server_hostname=domain) as ssock:
|
||||
cert = ssock.getpeercert()
|
||||
return cert
|
||||
|
||||
cert = await loop.run_in_executor(None, get_ssl_info)
|
||||
|
||||
result.has_ssl = True
|
||||
|
||||
# Parse expiration date
|
||||
not_after = cert.get('notAfter')
|
||||
if not_after:
|
||||
# Format: 'Dec 31 23:59:59 2024 GMT'
|
||||
try:
|
||||
expires = datetime.strptime(not_after, '%b %d %H:%M:%S %Y %Z')
|
||||
result.expires_at = expires.replace(tzinfo=timezone.utc)
|
||||
result.days_until_expiry = (result.expires_at - datetime.now(timezone.utc)).days
|
||||
result.is_expired = result.days_until_expiry < 0
|
||||
result.is_valid = result.days_until_expiry >= 0
|
||||
except Exception:
|
||||
result.is_valid = True # Assume valid if we can't parse
|
||||
|
||||
# Get issuer
|
||||
issuer = cert.get('issuer')
|
||||
if issuer:
|
||||
for item in issuer:
|
||||
if item[0][0] == 'organizationName':
|
||||
result.issuer = item[0][1]
|
||||
break
|
||||
|
||||
except ssl.SSLCertVerificationError as e:
|
||||
result.has_ssl = True
|
||||
result.is_valid = False
|
||||
result.is_expired = 'expired' in str(e).lower()
|
||||
result.error = str(e)
|
||||
except (socket.timeout, socket.error, ConnectionRefusedError):
|
||||
result.has_ssl = False
|
||||
result.error = "no_ssl"
|
||||
except Exception as e:
|
||||
result.error = str(e)
|
||||
|
||||
return result
|
||||
|
||||
def _calculate_health(
|
||||
self,
|
||||
domain: str,
|
||||
dns_result: DNSCheckResult,
|
||||
http_result: HTTPCheckResult,
|
||||
ssl_result: SSLCheckResult
|
||||
) -> DomainHealthReport:
|
||||
"""
|
||||
Calculate overall health status and score.
|
||||
|
||||
Scoring:
|
||||
- DNS layer: 30 points
|
||||
- HTTP layer: 40 points
|
||||
- SSL layer: 30 points
|
||||
"""
|
||||
score = 100
|
||||
signals = []
|
||||
recommendations = []
|
||||
|
||||
# =========================
|
||||
# DNS Scoring (30 points)
|
||||
# =========================
|
||||
|
||||
if not dns_result.has_nameservers:
|
||||
score -= 30
|
||||
signals.append("🔴 No nameservers found (domain may not exist)")
|
||||
elif dns_result.is_parking_ns:
|
||||
score -= 15
|
||||
signals.append("🟠 Nameservers point to parking service")
|
||||
recommendations.append("Domain is parked - owner may be selling")
|
||||
else:
|
||||
if not dns_result.has_a_record:
|
||||
score -= 10
|
||||
signals.append("⚠️ No A record (no website configured)")
|
||||
if not dns_result.has_mx_records:
|
||||
score -= 5
|
||||
signals.append("⚠️ No MX records (no email configured)")
|
||||
|
||||
# =========================
|
||||
# HTTP Scoring (40 points)
|
||||
# =========================
|
||||
|
||||
if not http_result.is_reachable:
|
||||
score -= 40
|
||||
signals.append("🔴 Website not reachable")
|
||||
if http_result.error == "timeout":
|
||||
signals.append("⚠️ Connection timeout")
|
||||
elif http_result.error == "connection_refused":
|
||||
signals.append("⚠️ Connection refused")
|
||||
elif http_result.status_code:
|
||||
if http_result.status_code >= 500:
|
||||
score -= 30
|
||||
signals.append(f"🔴 Server error ({http_result.status_code})")
|
||||
recommendations.append("Server is having issues - monitor closely")
|
||||
elif http_result.status_code >= 400:
|
||||
score -= 15
|
||||
signals.append(f"⚠️ Client error ({http_result.status_code})")
|
||||
|
||||
if http_result.is_parked:
|
||||
score -= 10
|
||||
signals.append("🟠 Page contains for-sale indicators")
|
||||
recommendations.append(f"Detected: {', '.join(http_result.parking_signals[:3])}")
|
||||
|
||||
if http_result.content_length < 500:
|
||||
score -= 5
|
||||
signals.append("⚠️ Very small page content")
|
||||
|
||||
# =========================
|
||||
# SSL Scoring (30 points)
|
||||
# =========================
|
||||
|
||||
if not ssl_result.has_ssl:
|
||||
score -= 10
|
||||
signals.append("⚠️ No SSL certificate")
|
||||
elif ssl_result.is_expired:
|
||||
score -= 30
|
||||
signals.append("🔴 SSL certificate expired!")
|
||||
recommendations.append("Certificate expired - owner neglecting domain")
|
||||
elif ssl_result.days_until_expiry is not None:
|
||||
if ssl_result.days_until_expiry < 7:
|
||||
score -= 15
|
||||
signals.append(f"⚠️ SSL expires in {ssl_result.days_until_expiry} days")
|
||||
recommendations.append("Certificate expiring soon - watch for neglect")
|
||||
elif ssl_result.days_until_expiry < 30:
|
||||
score -= 5
|
||||
signals.append(f"ℹ️ SSL expires in {ssl_result.days_until_expiry} days")
|
||||
|
||||
# Ensure score is in valid range
|
||||
score = max(0, min(100, score))
|
||||
|
||||
# Determine status
|
||||
if score >= 80:
|
||||
status = HealthStatus.HEALTHY
|
||||
elif score >= 50:
|
||||
if dns_result.is_parking_ns or http_result.is_parked:
|
||||
status = HealthStatus.PARKED
|
||||
else:
|
||||
status = HealthStatus.WEAKENING
|
||||
elif score >= 20:
|
||||
if dns_result.is_parking_ns or http_result.is_parked:
|
||||
status = HealthStatus.PARKED
|
||||
else:
|
||||
status = HealthStatus.WEAKENING
|
||||
else:
|
||||
status = HealthStatus.CRITICAL
|
||||
|
||||
# Override to PARKED if clear signals
|
||||
if dns_result.is_parking_ns or http_result.is_parked:
|
||||
if status != HealthStatus.CRITICAL:
|
||||
status = HealthStatus.PARKED
|
||||
|
||||
return DomainHealthReport(
|
||||
domain=domain,
|
||||
status=status,
|
||||
score=score,
|
||||
dns=dns_result,
|
||||
http=http_result,
|
||||
ssl=ssl_result,
|
||||
signals=signals,
|
||||
recommendations=recommendations,
|
||||
)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_health_checker: Optional[DomainHealthChecker] = None
|
||||
|
||||
|
||||
def get_health_checker() -> DomainHealthChecker:
|
||||
"""Get or create health checker instance."""
|
||||
global _health_checker
|
||||
if _health_checker is None:
|
||||
_health_checker = DomainHealthChecker()
|
||||
return _health_checker
|
||||
|
||||
@ -48,114 +48,33 @@ SMTP_CONFIG = {
|
||||
CONTACT_EMAIL = os.getenv("CONTACT_EMAIL", "hello@pounce.ch")
|
||||
|
||||
|
||||
# Base email wrapper template
|
||||
# Minimalistic Professional Email Template
|
||||
BASE_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
padding: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
}
|
||||
.logo {
|
||||
color: #00d4aa;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
h1 { color: #fff; margin: 0 0 16px 0; }
|
||||
h2 { color: #fff; margin: 24px 0 16px 0; }
|
||||
p { color: #e5e5e5; line-height: 1.6; }
|
||||
.highlight {
|
||||
font-family: monospace;
|
||||
font-size: 24px;
|
||||
color: #00d4aa;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.cta {
|
||||
display: inline-block;
|
||||
background: #00d4aa;
|
||||
color: #0a0a0a;
|
||||
padding: 14px 28px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.cta:hover { background: #00c49a; }
|
||||
.secondary-cta {
|
||||
display: inline-block;
|
||||
background: transparent;
|
||||
color: #00d4aa;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #00d4aa;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin-top: 16px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.info-box {
|
||||
background: #252525;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.stat {
|
||||
background: #252525;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin: 8px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.stat-value { color: #00d4aa; font-size: 20px; font-weight: bold; }
|
||||
.warning { color: #f59e0b; }
|
||||
.success { color: #00d4aa; }
|
||||
.decrease { color: #00d4aa; }
|
||||
.increase { color: #ef4444; }
|
||||
.footer {
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #333;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
}
|
||||
.footer a { color: #00d4aa; text-decoration: none; }
|
||||
ul { padding-left: 20px; }
|
||||
li { margin: 8px 0; }
|
||||
code {
|
||||
background: #252525;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
color: #00d4aa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">🐆 pounce</div>
|
||||
{{ content }}
|
||||
<div class="footer">
|
||||
<p>© {{ year }} pounce. All rights reserved.</p>
|
||||
<p>
|
||||
<a href="https://pounce.ch">pounce.ch</a> ·
|
||||
<a href="https://pounce.ch/privacy">Privacy</a> ·
|
||||
<a href="https://pounce.ch/terms">Terms</a>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<div style="max-width: 580px; margin: 40px auto; background: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
|
||||
<!-- Header -->
|
||||
<div style="padding: 32px 40px; border-bottom: 1px solid #e5e5e5;">
|
||||
<h1 style="margin: 0; font-size: 24px; font-weight: 600; color: #000000; letter-spacing: -0.5px;">
|
||||
pounce
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div style="padding: 40px;">
|
||||
{{ content }}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="padding: 24px 40px; background: #fafafa; border-top: 1px solid #e5e5e5;">
|
||||
<p style="margin: 0; font-size: 13px; color: #666666; line-height: 1.6;">
|
||||
pounce — Domain Intelligence Platform<br>
|
||||
<a href="https://pounce.ch" style="color: #000000; text-decoration: none;">pounce.ch</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -167,34 +86,52 @@ BASE_TEMPLATE = """
|
||||
# Email Templates (content only, wrapped in BASE_TEMPLATE)
|
||||
TEMPLATES = {
|
||||
"domain_available": """
|
||||
<h1>Time to pounce.</h1>
|
||||
<p>A domain you're tracking just dropped:</p>
|
||||
<div class="highlight">{{ domain }}</div>
|
||||
<p>It's available right now. Move fast—others are watching too.</p>
|
||||
<a href="{{ register_url }}" class="cta">Grab It Now →</a>
|
||||
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
||||
You're tracking this domain on POUNCE.
|
||||
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
|
||||
Domain available
|
||||
</h2>
|
||||
<p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;\">
|
||||
A domain you're monitoring is now available:
|
||||
</p>
|
||||
<div style="margin: 24px 0; padding: 20px; background: #fafafa; border-radius: 6px; border-left: 3px solid #000000;\">
|
||||
<p style="margin: 0; font-size: 18px; font-weight: 600; color: #000000; font-family: monospace;\">
|
||||
{{ domain }}
|
||||
</p>
|
||||
</div>
|
||||
<div style="margin: 32px 0 0 0;\">
|
||||
<a href="{{ register_url }}" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;\">
|
||||
Register Domain
|
||||
</a>
|
||||
</div>
|
||||
""",
|
||||
|
||||
"price_alert": """
|
||||
<h1>.{{ tld }} just moved.</h1>
|
||||
<p style="font-size: 20px;">
|
||||
{% if change_percent < 0 %}
|
||||
<span class="decrease">↓ Down {{ change_percent|abs }}%</span>
|
||||
{% else %}
|
||||
<span class="increase">↑ Up {{ change_percent }}%</span>
|
||||
{% endif %}
|
||||
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
|
||||
Price alert: .{{ tld }}
|
||||
</h2>
|
||||
<p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;\">
|
||||
The price for .{{ tld }} has changed:
|
||||
</p>
|
||||
<div class="info-box">
|
||||
<p><strong>Was:</strong> ${{ old_price }}</p>
|
||||
<p><strong>Now:</strong> ${{ new_price }}</p>
|
||||
<p><strong>Cheapest at:</strong> {{ registrar }}</p>
|
||||
<div style="margin: 24px 0; padding: 24px; background: #fafafa; border-radius: 6px;\">
|
||||
<div style="margin-bottom: 16px;\">
|
||||
<p style="margin: 0 0 4px 0; font-size: 13px; color: #666666;\">Previous Price</p>
|
||||
<p style="margin: 0; font-size: 18px; color: #999999; text-decoration: line-through;\">\${{ old_price }}</p>
|
||||
</div>
|
||||
<div style="margin-bottom: 16px;\">
|
||||
<p style="margin: 0 0 4px 0; font-size: 13px; color: #666666;\">New Price</p>
|
||||
<p style="margin: 0; font-size: 24px; font-weight: 600; color: #000000;\">\${{ new_price }}</p>
|
||||
</div>
|
||||
<p style="margin: 16px 0 0 0; font-size: 14px; {% if change_percent < 0 %}color: #10b981;{% else %}color: #ef4444;{% endif %}\">
|
||||
{% if change_percent < 0 %}↓{% else %}↑{% endif %} {{ change_percent|abs }}%
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ tld_url }}" class="cta">See Details →</a>
|
||||
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
||||
You set an alert for .{{ tld }} on POUNCE.
|
||||
<p style="margin: 24px 0; font-size: 14px; color: #666666;\">
|
||||
Cheapest at: <strong style="color: #000000;\">{{ registrar }}</strong>
|
||||
</p>
|
||||
<div style="margin: 32px 0 0 0;\">
|
||||
<a href="{{ tld_url }}" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;\">
|
||||
View Details
|
||||
</a>
|
||||
</div>
|
||||
""",
|
||||
|
||||
"subscription_confirmed": """
|
||||
@ -243,81 +180,99 @@ TEMPLATES = {
|
||||
""",
|
||||
|
||||
"password_reset": """
|
||||
<h1>Reset your password.</h1>
|
||||
<p>Hey {{ user_name }},</p>
|
||||
<p>Someone requested a password reset. If that was you, click below:</p>
|
||||
<a href="{{ reset_url }}" class="cta">Reset Password →</a>
|
||||
<p style="margin-top: 24px;">Or copy this link:</p>
|
||||
<code style="word-break: break-all;">{{ reset_url }}</code>
|
||||
<div class="info-box" style="margin-top: 24px;">
|
||||
<p class="warning" style="margin: 0;">Link expires in 1 hour.</p>
|
||||
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
|
||||
Reset your password
|
||||
</h2>
|
||||
<p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;">
|
||||
Hi {{ user_name }},
|
||||
</p>
|
||||
<p style="margin: 0 0 32px 0; font-size: 15px; color: #333333; line-height: 1.6;">
|
||||
We received a request to reset your password. Click the button below to create a new password.
|
||||
</p>
|
||||
<div style="margin: 0 0 32px 0;">
|
||||
<a href="{{ reset_url }}" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;">
|
||||
Reset Password
|
||||
</a>
|
||||
</div>
|
||||
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
||||
Didn't request this? Ignore it. Nothing changes.
|
||||
<p style="margin: 0 0 16px 0; font-size: 14px; color: #666666; line-height: 1.6;">
|
||||
This link expires in 1 hour.
|
||||
</p>
|
||||
<p style="margin: 32px 0 0 0; padding-top: 24px; border-top: 1px solid #e5e5e5; font-size: 13px; color: #999999; line-height: 1.6;">
|
||||
If you didn't request this, you can safely ignore this email.
|
||||
</p>
|
||||
""",
|
||||
|
||||
"email_verification": """
|
||||
<h1>One click to start hunting.</h1>
|
||||
<p>Hey {{ user_name }},</p>
|
||||
<p>Welcome to POUNCE. Verify your email to activate your account:</p>
|
||||
<a href="{{ verification_url }}" class="cta">Verify & Start →</a>
|
||||
<p style="margin-top: 24px;">Or copy this link:</p>
|
||||
<code style="word-break: break-all;">{{ verification_url }}</code>
|
||||
<div class="info-box" style="margin-top: 24px;">
|
||||
<p style="margin: 0;">Link expires in 24 hours.</p>
|
||||
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
|
||||
Verify your email
|
||||
</h2>
|
||||
<p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;">
|
||||
Hi {{ user_name }},
|
||||
</p>
|
||||
<p style="margin: 0 0 32px 0; font-size: 15px; color: #333333; line-height: 1.6;">
|
||||
Thanks for signing up. Click the button below to verify your email and activate your account.
|
||||
</p>
|
||||
<div style="margin: 0 0 32px 0;">
|
||||
<a href="{{ verification_url }}" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;">
|
||||
Verify Email
|
||||
</a>
|
||||
</div>
|
||||
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
||||
Didn't sign up? Just ignore this.
|
||||
<p style="margin: 0 0 16px 0; font-size: 14px; color: #666666; line-height: 1.6;">
|
||||
This link expires in 24 hours.
|
||||
</p>
|
||||
<p style="margin: 32px 0 0 0; padding-top: 24px; border-top: 1px solid #e5e5e5; font-size: 13px; color: #999999; line-height: 1.6;">
|
||||
If you didn't sign up, you can safely ignore this email.
|
||||
</p>
|
||||
""",
|
||||
|
||||
"contact_form": """
|
||||
<h1>New message from the wild.</h1>
|
||||
<div class="info-box">
|
||||
<p><strong>From:</strong> {{ name }} <{{ email }}></p>
|
||||
<p><strong>Subject:</strong> {{ subject }}</p>
|
||||
<p><strong>Date:</strong> {{ timestamp }}</p>
|
||||
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
|
||||
New Contact Form Submission
|
||||
</h2>
|
||||
<div style="margin: 24px 0; padding: 20px; background: #fafafa; border-radius: 6px;\">
|
||||
<p style="margin: 0 0 12px 0; font-size: 14px; color: #666666;\">From</p>
|
||||
<p style="margin: 0 0 16px 0; font-size: 15px; color: #000000;\">{{ name }} <{{ email }}></p>
|
||||
<p style="margin: 16px 0 12px 0; font-size: 14px; color: #666666;\">Subject</p>
|
||||
<p style="margin: 0; font-size: 15px; color: #000000;\">{{ subject }}</p>
|
||||
</div>
|
||||
<h2>Message</h2>
|
||||
<div class="info-box">
|
||||
<p style="white-space: pre-wrap;">{{ message }}</p>
|
||||
<p style="margin: 24px 0 12px 0; font-size: 14px; color: #666666;\">Message</p>
|
||||
<p style="margin: 0; font-size: 15px; color: #333333; line-height: 1.6; white-space: pre-wrap;\">{{ message }}</p>
|
||||
<div style="margin: 32px 0 0 0;\">
|
||||
<a href="mailto:{{ email }}" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;\">
|
||||
Reply
|
||||
</a>
|
||||
</div>
|
||||
<p style="margin-top: 24px;">
|
||||
<a href="mailto:{{ email }}" class="cta">Reply →</a>
|
||||
</p>
|
||||
<p style="margin: 24px 0 0 0; font-size: 13px; color: #999999;\">Sent: {{ timestamp }}</p>
|
||||
""",
|
||||
|
||||
"contact_confirmation": """
|
||||
<h1>Got it.</h1>
|
||||
<p>Hey {{ name }},</p>
|
||||
<p>Your message landed. We'll get back to you soon.</p>
|
||||
<div class="info-box">
|
||||
<p><strong>Subject:</strong> {{ subject }}</p>
|
||||
<p><strong>Your message:</strong></p>
|
||||
<p style="white-space: pre-wrap; color: #888;">{{ message }}</p>
|
||||
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
|
||||
Message received
|
||||
</h2>
|
||||
<p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;">
|
||||
Hi {{ name }},
|
||||
</p>
|
||||
<p style="margin: 0 0 32px 0; font-size: 15px; color: #333333; line-height: 1.6;">
|
||||
Thanks for reaching out. We've received your message and will get back to you within 24–48 hours.
|
||||
</p>
|
||||
<div style="margin: 24px 0; padding: 20px; background: #fafafa; border-radius: 6px;">
|
||||
<p style="margin: 0 0 8px 0; font-size: 14px; color: #666666;">Your message</p>
|
||||
<p style="margin: 0; font-size: 14px; color: #999999; white-space: pre-wrap;">{{ message }}</p>
|
||||
</div>
|
||||
<p>Expect a reply within 24-48 hours.</p>
|
||||
<a href="https://pounce.ch" class="secondary-cta">Back to POUNCE →</a>
|
||||
""",
|
||||
|
||||
"newsletter_welcome": """
|
||||
<h1>You're on the list.</h1>
|
||||
<p>Welcome to POUNCE Insights.</p>
|
||||
<p>Here's what you'll get:</p>
|
||||
<div class="info-box">
|
||||
<ul>
|
||||
<li>TLD market moves & analysis</li>
|
||||
<li>Domain investing strategies</li>
|
||||
<li>New feature drops</li>
|
||||
<li>Exclusive deals</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>1-2 emails per month. No spam. Ever.</p>
|
||||
<a href="https://pounce.ch" class="cta">Start Exploring →</a>
|
||||
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
||||
Unsubscribe anytime with one click.
|
||||
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
|
||||
Welcome to pounce insights
|
||||
</h2>
|
||||
<p style="margin: 0 0 32px 0; font-size: 15px; color: #333333; line-height: 1.6;">
|
||||
You'll receive updates about TLD market trends, domain investment strategies, and new features. 1–2 emails per month. No spam.
|
||||
</p>
|
||||
<div style="margin: 32px 0 0 0;">
|
||||
<a href="https://pounce.ch" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;">
|
||||
Visit pounce.ch
|
||||
</a>
|
||||
</div>
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
446
backend/app/services/seo_analyzer.py
Normal file
446
backend/app/services/seo_analyzer.py
Normal file
@ -0,0 +1,446 @@
|
||||
"""
|
||||
SEO Analyzer Service - "SEO Juice Detector"
|
||||
|
||||
This implements Strategie 3 from analysis_3.md:
|
||||
"SEO-Agenturen suchen Domains wegen der Power (Backlinks).
|
||||
Solche Domains sind für SEOs 100€ - 500€ wert, auch wenn der Name hässlich ist."
|
||||
|
||||
Data Sources (in priority order):
|
||||
1. Moz API (if MOZ_ACCESS_ID and MOZ_SECRET_KEY are set)
|
||||
2. CommonCrawl Index (free, but limited)
|
||||
3. Estimation based on domain characteristics
|
||||
|
||||
This is a TYCOON-ONLY feature.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
import httpx
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any, List
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.seo_data import DomainSEOData
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SEOAnalyzerService:
|
||||
"""
|
||||
Analyzes domains for SEO value (backlinks, authority, etc.)
|
||||
|
||||
From analysis_3.md:
|
||||
"Domain `alte-bäckerei-münchen.de` ist frei.
|
||||
Hat Links von `sueddeutsche.de` und `wikipedia.org`."
|
||||
"""
|
||||
|
||||
# Moz API configuration
|
||||
MOZ_API_URL = "https://lsapi.seomoz.com/v2/url_metrics"
|
||||
MOZ_LINKS_URL = "https://lsapi.seomoz.com/v2/links"
|
||||
|
||||
# Cache duration (7 days for SEO data)
|
||||
CACHE_DURATION_DAYS = 7
|
||||
|
||||
# Known high-authority domains for notable link detection
|
||||
NOTABLE_DOMAINS = {
|
||||
'wikipedia': ['wikipedia.org', 'wikimedia.org'],
|
||||
'gov': ['.gov', '.gov.uk', '.admin.ch', '.bund.de'],
|
||||
'edu': ['.edu', '.ac.uk', '.ethz.ch', '.uzh.ch'],
|
||||
'news': [
|
||||
'nytimes.com', 'theguardian.com', 'bbc.com', 'cnn.com',
|
||||
'forbes.com', 'bloomberg.com', 'reuters.com', 'techcrunch.com',
|
||||
'spiegel.de', 'faz.net', 'nzz.ch', 'tagesanzeiger.ch'
|
||||
]
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.moz_access_id = os.getenv('MOZ_ACCESS_ID')
|
||||
self.moz_secret_key = os.getenv('MOZ_SECRET_KEY')
|
||||
self.has_moz = bool(self.moz_access_id and self.moz_secret_key)
|
||||
|
||||
if self.has_moz:
|
||||
logger.info("SEO Analyzer: Moz API configured")
|
||||
else:
|
||||
logger.warning("SEO Analyzer: No Moz API keys - using estimation mode")
|
||||
|
||||
async def analyze_domain(
|
||||
self,
|
||||
domain: str,
|
||||
db: AsyncSession,
|
||||
force_refresh: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze a domain for SEO value.
|
||||
|
||||
Returns:
|
||||
Dict with SEO metrics, backlinks, and value estimate
|
||||
"""
|
||||
domain = domain.lower().strip()
|
||||
|
||||
try:
|
||||
# Check cache first
|
||||
if not force_refresh:
|
||||
try:
|
||||
cached = await self._get_cached(domain, db)
|
||||
if cached and not cached.is_expired:
|
||||
return self._format_response(cached)
|
||||
except Exception as e:
|
||||
# Table might not exist yet
|
||||
logger.warning(f"Cache check failed (table may not exist): {e}")
|
||||
|
||||
# Fetch fresh data
|
||||
if self.has_moz:
|
||||
seo_data = await self._fetch_moz_data(domain)
|
||||
else:
|
||||
seo_data = await self._estimate_seo_data(domain)
|
||||
|
||||
# Try to save to cache (may fail if table doesn't exist)
|
||||
try:
|
||||
cached = await self._save_to_cache(domain, seo_data, db)
|
||||
return self._format_response(cached)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache save failed (table may not exist): {e}")
|
||||
# Return data directly without caching
|
||||
return self._format_dict_response(domain, seo_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SEO analysis failed for {domain}: {e}")
|
||||
# Return estimated data on any error
|
||||
seo_data = await self._estimate_seo_data(domain)
|
||||
return self._format_dict_response(domain, seo_data)
|
||||
|
||||
async def _get_cached(self, domain: str, db: AsyncSession) -> Optional[DomainSEOData]:
|
||||
"""Get cached SEO data for a domain."""
|
||||
result = await db.execute(
|
||||
select(DomainSEOData).where(DomainSEOData.domain == domain)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _save_to_cache(
|
||||
self,
|
||||
domain: str,
|
||||
data: Dict[str, Any],
|
||||
db: AsyncSession
|
||||
) -> DomainSEOData:
|
||||
"""Save SEO data to cache."""
|
||||
# Check if exists
|
||||
result = await db.execute(
|
||||
select(DomainSEOData).where(DomainSEOData.domain == domain)
|
||||
)
|
||||
cached = result.scalar_one_or_none()
|
||||
|
||||
if cached:
|
||||
# Update existing
|
||||
for key, value in data.items():
|
||||
if hasattr(cached, key):
|
||||
setattr(cached, key, value)
|
||||
cached.last_updated = datetime.utcnow()
|
||||
cached.expires_at = datetime.utcnow() + timedelta(days=self.CACHE_DURATION_DAYS)
|
||||
cached.fetch_count += 1
|
||||
else:
|
||||
# Create new
|
||||
cached = DomainSEOData(
|
||||
domain=domain,
|
||||
expires_at=datetime.utcnow() + timedelta(days=self.CACHE_DURATION_DAYS),
|
||||
**data
|
||||
)
|
||||
db.add(cached)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(cached)
|
||||
return cached
|
||||
|
||||
async def _fetch_moz_data(self, domain: str) -> Dict[str, Any]:
|
||||
"""Fetch SEO data from Moz API."""
|
||||
try:
|
||||
# Generate authentication
|
||||
expires = int(time.time()) + 300
|
||||
string_to_sign = f"{self.moz_access_id}\n{expires}"
|
||||
signature = base64.b64encode(
|
||||
hmac.new(
|
||||
self.moz_secret_key.encode('utf-8'),
|
||||
string_to_sign.encode('utf-8'),
|
||||
hashlib.sha1
|
||||
).digest()
|
||||
).decode('utf-8')
|
||||
|
||||
auth_params = {
|
||||
'AccessID': self.moz_access_id,
|
||||
'Expires': expires,
|
||||
'Signature': signature
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
# Get URL metrics
|
||||
response = await client.post(
|
||||
self.MOZ_API_URL,
|
||||
params=auth_params,
|
||||
json={
|
||||
'targets': [f'http://{domain}/'],
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
metrics = response.json()
|
||||
if metrics and 'results' in metrics and metrics['results']:
|
||||
result = metrics['results'][0]
|
||||
|
||||
# Extract notable backlinks
|
||||
top_backlinks = await self._fetch_top_backlinks(
|
||||
domain, auth_params, client
|
||||
)
|
||||
|
||||
return {
|
||||
'domain_authority': result.get('domain_authority', 0),
|
||||
'page_authority': result.get('page_authority', 0),
|
||||
'spam_score': result.get('spam_score', 0),
|
||||
'total_backlinks': result.get('external_links_to_root_domain', 0),
|
||||
'referring_domains': result.get('root_domains_to_root_domain', 0),
|
||||
'top_backlinks': top_backlinks,
|
||||
'notable_backlinks': self._extract_notable(top_backlinks),
|
||||
'has_wikipedia_link': self._has_notable_link(top_backlinks, 'wikipedia'),
|
||||
'has_gov_link': self._has_notable_link(top_backlinks, 'gov'),
|
||||
'has_edu_link': self._has_notable_link(top_backlinks, 'edu'),
|
||||
'has_news_link': self._has_notable_link(top_backlinks, 'news'),
|
||||
'seo_value_estimate': self._calculate_seo_value(result),
|
||||
'data_source': 'moz',
|
||||
}
|
||||
|
||||
logger.warning(f"Moz API returned {response.status_code} for {domain}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Moz API error for {domain}: {e}")
|
||||
|
||||
# Fallback to estimation
|
||||
return await self._estimate_seo_data(domain)
|
||||
|
||||
async def _fetch_top_backlinks(
|
||||
self,
|
||||
domain: str,
|
||||
auth_params: dict,
|
||||
client: httpx.AsyncClient
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fetch top backlinks from Moz."""
|
||||
try:
|
||||
response = await client.post(
|
||||
self.MOZ_LINKS_URL,
|
||||
params=auth_params,
|
||||
json={
|
||||
'target': f'http://{domain}/',
|
||||
'target_scope': 'root_domain',
|
||||
'filter': 'external+nofollow',
|
||||
'sort': 'domain_authority',
|
||||
'limit': 20
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if 'results' in data:
|
||||
return [
|
||||
{
|
||||
'domain': link.get('source', {}).get('root_domain', ''),
|
||||
'authority': link.get('source', {}).get('domain_authority', 0),
|
||||
'page': link.get('source', {}).get('page', ''),
|
||||
}
|
||||
for link in data['results'][:10]
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching backlinks: {e}")
|
||||
|
||||
return []
|
||||
|
||||
async def _estimate_seo_data(self, domain: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Estimate SEO data when no API is available.
|
||||
|
||||
Uses heuristics based on domain characteristics.
|
||||
"""
|
||||
# Extract domain parts
|
||||
parts = domain.split('.')
|
||||
name = parts[0] if parts else domain
|
||||
tld = parts[-1] if len(parts) > 1 else ''
|
||||
|
||||
# Estimate domain authority based on characteristics
|
||||
estimated_da = 10 # Base
|
||||
|
||||
# Short domains tend to have more backlinks
|
||||
if len(name) <= 4:
|
||||
estimated_da += 15
|
||||
elif len(name) <= 6:
|
||||
estimated_da += 10
|
||||
elif len(name) <= 8:
|
||||
estimated_da += 5
|
||||
|
||||
# Premium TLDs
|
||||
premium_tlds = {'com': 10, 'org': 8, 'net': 5, 'io': 7, 'ai': 8, 'co': 6}
|
||||
estimated_da += premium_tlds.get(tld, 0)
|
||||
|
||||
# Dictionary words get a boost
|
||||
common_words = ['tech', 'app', 'data', 'cloud', 'web', 'net', 'hub', 'lab', 'dev']
|
||||
if any(word in name.lower() for word in common_words):
|
||||
estimated_da += 5
|
||||
|
||||
# Cap at reasonable estimate
|
||||
estimated_da = min(40, estimated_da)
|
||||
|
||||
# Estimate backlinks based on DA
|
||||
estimated_backlinks = estimated_da * 50
|
||||
estimated_referring = estimated_da * 5
|
||||
|
||||
return {
|
||||
'domain_authority': estimated_da,
|
||||
'page_authority': max(0, estimated_da - 5),
|
||||
'spam_score': 5, # Assume low spam for estimates
|
||||
'total_backlinks': estimated_backlinks,
|
||||
'referring_domains': estimated_referring,
|
||||
'top_backlinks': [],
|
||||
'notable_backlinks': None,
|
||||
'has_wikipedia_link': False,
|
||||
'has_gov_link': False,
|
||||
'has_edu_link': False,
|
||||
'has_news_link': False,
|
||||
'seo_value_estimate': self._estimate_value(estimated_da),
|
||||
'data_source': 'estimated',
|
||||
}
|
||||
|
||||
def _has_notable_link(self, backlinks: List[Dict], category: str) -> bool:
|
||||
"""Check if backlinks contain notable sources."""
|
||||
domains_to_check = self.NOTABLE_DOMAINS.get(category, [])
|
||||
|
||||
for link in backlinks:
|
||||
link_domain = link.get('domain', '').lower()
|
||||
for notable in domains_to_check:
|
||||
if notable in link_domain:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _extract_notable(self, backlinks: List[Dict]) -> Optional[str]:
|
||||
"""Extract notable backlink domains as comma-separated string."""
|
||||
notable = []
|
||||
|
||||
for link in backlinks:
|
||||
domain = link.get('domain', '')
|
||||
authority = link.get('authority', 0)
|
||||
|
||||
# Include high-authority links
|
||||
if authority >= 50:
|
||||
notable.append(domain)
|
||||
|
||||
return ','.join(notable[:10]) if notable else None
|
||||
|
||||
def _calculate_seo_value(self, metrics: Dict) -> float:
|
||||
"""Calculate estimated SEO value in USD."""
|
||||
da = metrics.get('domain_authority', 0)
|
||||
backlinks = metrics.get('external_links_to_root_domain', 0)
|
||||
|
||||
# Base value from DA
|
||||
if da >= 60:
|
||||
base_value = 500
|
||||
elif da >= 40:
|
||||
base_value = 200
|
||||
elif da >= 20:
|
||||
base_value = 50
|
||||
else:
|
||||
base_value = 10
|
||||
|
||||
# Boost for backlinks
|
||||
link_boost = min(backlinks / 100, 10) * 20
|
||||
|
||||
return round(base_value + link_boost, 2)
|
||||
|
||||
def _estimate_value(self, da: int) -> float:
|
||||
"""Estimate value based on estimated DA."""
|
||||
if da >= 40:
|
||||
return 200
|
||||
elif da >= 30:
|
||||
return 100
|
||||
elif da >= 20:
|
||||
return 50
|
||||
return 20
|
||||
|
||||
def _format_response(self, data: DomainSEOData) -> Dict[str, Any]:
|
||||
"""Format SEO data for API response."""
|
||||
return {
|
||||
'domain': data.domain,
|
||||
'seo_score': data.seo_score,
|
||||
'value_category': data.value_category,
|
||||
'metrics': {
|
||||
'domain_authority': data.domain_authority,
|
||||
'page_authority': data.page_authority,
|
||||
'spam_score': data.spam_score,
|
||||
'total_backlinks': data.total_backlinks,
|
||||
'referring_domains': data.referring_domains,
|
||||
},
|
||||
'notable_links': {
|
||||
'has_wikipedia': data.has_wikipedia_link,
|
||||
'has_gov': data.has_gov_link,
|
||||
'has_edu': data.has_edu_link,
|
||||
'has_news': data.has_news_link,
|
||||
'notable_domains': data.notable_backlinks.split(',') if data.notable_backlinks else [],
|
||||
},
|
||||
'top_backlinks': data.top_backlinks or [],
|
||||
'estimated_value': data.seo_value_estimate,
|
||||
'data_source': data.data_source,
|
||||
'last_updated': data.last_updated.isoformat() if data.last_updated else None,
|
||||
'is_estimated': data.data_source == 'estimated',
|
||||
}
|
||||
|
||||
def _format_dict_response(self, domain: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Format SEO data from dict (when DB is not available)."""
|
||||
da = data.get('domain_authority', 0) or 0
|
||||
|
||||
# Calculate SEO score
|
||||
seo_score = da
|
||||
if data.get('has_wikipedia_link'):
|
||||
seo_score = min(100, seo_score + 10)
|
||||
if data.get('has_gov_link'):
|
||||
seo_score = min(100, seo_score + 5)
|
||||
if data.get('has_edu_link'):
|
||||
seo_score = min(100, seo_score + 5)
|
||||
if data.get('has_news_link'):
|
||||
seo_score = min(100, seo_score + 3)
|
||||
|
||||
# Determine value category
|
||||
if seo_score >= 60:
|
||||
value_category = "High Value"
|
||||
elif seo_score >= 40:
|
||||
value_category = "Medium Value"
|
||||
elif seo_score >= 20:
|
||||
value_category = "Low Value"
|
||||
else:
|
||||
value_category = "Minimal"
|
||||
|
||||
return {
|
||||
'domain': domain,
|
||||
'seo_score': seo_score,
|
||||
'value_category': value_category,
|
||||
'metrics': {
|
||||
'domain_authority': data.get('domain_authority'),
|
||||
'page_authority': data.get('page_authority'),
|
||||
'spam_score': data.get('spam_score'),
|
||||
'total_backlinks': data.get('total_backlinks'),
|
||||
'referring_domains': data.get('referring_domains'),
|
||||
},
|
||||
'notable_links': {
|
||||
'has_wikipedia': data.get('has_wikipedia_link', False),
|
||||
'has_gov': data.get('has_gov_link', False),
|
||||
'has_edu': data.get('has_edu_link', False),
|
||||
'has_news': data.get('has_news_link', False),
|
||||
'notable_domains': data.get('notable_backlinks', '').split(',') if data.get('notable_backlinks') else [],
|
||||
},
|
||||
'top_backlinks': data.get('top_backlinks', []),
|
||||
'estimated_value': data.get('seo_value_estimate'),
|
||||
'data_source': data.get('data_source', 'estimated'),
|
||||
'last_updated': datetime.utcnow().isoformat(),
|
||||
'is_estimated': data.get('data_source') == 'estimated',
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
seo_analyzer = SEOAnalyzerService()
|
||||
|
||||
@ -1 +1 @@
|
||||
4645
|
||||
7503
|
||||
|
||||
477
backend/scripts/premium_data_collector.py
Normal file
477
backend/scripts/premium_data_collector.py
Normal file
@ -0,0 +1,477 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🚀 POUNCE PREMIUM DATA COLLECTOR
|
||||
================================
|
||||
|
||||
Professionelles, automatisiertes Script zur Sammlung und Auswertung aller Daten.
|
||||
|
||||
Features:
|
||||
- Multi-Source TLD-Preis-Aggregation
|
||||
- Robustes Auction-Scraping mit Fallback
|
||||
- Zone File Integration (vorbereitet)
|
||||
- Datenqualitäts-Scoring
|
||||
- Automatische Reports
|
||||
|
||||
Verwendung:
|
||||
python scripts/premium_data_collector.py --full # Vollständige Sammlung
|
||||
python scripts/premium_data_collector.py --tld # Nur TLD-Preise
|
||||
python scripts/premium_data_collector.py --auctions # Nur Auktionen
|
||||
python scripts/premium_data_collector.py --report # Nur Report generieren
|
||||
python scripts/premium_data_collector.py --schedule # Als Cronjob starten
|
||||
|
||||
Autor: Pounce Team
|
||||
Version: 1.0.0
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional
|
||||
from dataclasses import dataclass, field, asdict
|
||||
import hashlib
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy import select, func, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import AsyncSessionLocal, engine
|
||||
from app.models.tld_price import TLDPrice, TLDInfo
|
||||
from app.models.auction import DomainAuction, AuctionScrapeLog
|
||||
from app.services.tld_scraper.aggregator import TLDPriceAggregator
|
||||
from app.services.auction_scraper import AuctionScraperService
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s | %(levelname)-8s | %(name)s | %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
logger = logging.getLogger("PounceCollector")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DATA QUALITY METRICS
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class DataQualityReport:
|
||||
"""Tracks data quality metrics for premium service standards."""
|
||||
|
||||
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
|
||||
# TLD Price Metrics
|
||||
tld_total_count: int = 0
|
||||
tld_with_prices: int = 0
|
||||
tld_price_coverage: float = 0.0 # Percentage
|
||||
tld_sources_count: int = 0
|
||||
tld_freshness_hours: float = 0.0 # Average age of data
|
||||
tld_confidence_score: float = 0.0 # 0-100
|
||||
|
||||
# Auction Metrics
|
||||
auction_total_count: int = 0
|
||||
auction_active_count: int = 0
|
||||
auction_platforms_count: int = 0
|
||||
auction_with_real_prices: int = 0 # Has actual bid, not estimated
|
||||
auction_data_quality: float = 0.0 # 0-100
|
||||
auction_scrape_success_rate: float = 0.0
|
||||
|
||||
# Overall Metrics
|
||||
overall_score: float = 0.0 # 0-100, Premium threshold: 80+
|
||||
is_premium_ready: bool = False
|
||||
|
||||
issues: List[str] = field(default_factory=list)
|
||||
recommendations: List[str] = field(default_factory=list)
|
||||
|
||||
def calculate_overall_score(self):
|
||||
"""Calculate overall data quality score."""
|
||||
scores = []
|
||||
|
||||
# TLD Score (40% weight)
|
||||
tld_score = min(100, (
|
||||
(self.tld_price_coverage * 0.4) +
|
||||
(min(100, self.tld_sources_count * 25) * 0.2) +
|
||||
(max(0, 100 - self.tld_freshness_hours) * 0.2) +
|
||||
(self.tld_confidence_score * 0.2)
|
||||
))
|
||||
scores.append(('TLD Data', tld_score, 0.4))
|
||||
|
||||
# Auction Score (40% weight)
|
||||
if self.auction_total_count > 0:
|
||||
real_price_ratio = (self.auction_with_real_prices / self.auction_total_count) * 100
|
||||
else:
|
||||
real_price_ratio = 0
|
||||
|
||||
auction_score = min(100, (
|
||||
(min(100, self.auction_active_count) * 0.3) +
|
||||
(min(100, self.auction_platforms_count * 20) * 0.2) +
|
||||
(real_price_ratio * 0.3) +
|
||||
(self.auction_scrape_success_rate * 0.2)
|
||||
))
|
||||
scores.append(('Auction Data', auction_score, 0.4))
|
||||
|
||||
# Freshness Score (20% weight)
|
||||
freshness_score = max(0, 100 - (self.tld_freshness_hours * 2))
|
||||
scores.append(('Freshness', freshness_score, 0.2))
|
||||
|
||||
# Calculate weighted average
|
||||
self.overall_score = sum(score * weight for _, score, weight in scores)
|
||||
self.is_premium_ready = self.overall_score >= 80
|
||||
|
||||
# Add issues based on scores
|
||||
if self.tld_price_coverage < 50:
|
||||
self.issues.append(f"Low TLD coverage: {self.tld_price_coverage:.1f}%")
|
||||
self.recommendations.append("Add more TLD price sources (Namecheap, Cloudflare)")
|
||||
|
||||
if self.auction_with_real_prices < self.auction_total_count * 0.5:
|
||||
self.issues.append("Many auctions have estimated prices (not real bids)")
|
||||
self.recommendations.append("Improve auction scraping accuracy or get API access")
|
||||
|
||||
if self.tld_freshness_hours > 24:
|
||||
self.issues.append(f"TLD data is {self.tld_freshness_hours:.0f}h old")
|
||||
self.recommendations.append("Run TLD price scrape more frequently")
|
||||
|
||||
if self.auction_platforms_count < 3:
|
||||
self.issues.append(f"Only {self.auction_platforms_count} auction platforms active")
|
||||
self.recommendations.append("Enable more auction platform scrapers")
|
||||
|
||||
return scores
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
def print_report(self):
|
||||
"""Print a formatted report to console."""
|
||||
print("\n" + "="*70)
|
||||
print("🚀 POUNCE DATA QUALITY REPORT")
|
||||
print("="*70)
|
||||
print(f"Generated: {self.timestamp}")
|
||||
print()
|
||||
|
||||
# Overall Score
|
||||
status_emoji = "✅" if self.is_premium_ready else "⚠️"
|
||||
print(f"OVERALL SCORE: {self.overall_score:.1f}/100 {status_emoji}")
|
||||
print(f"Premium Ready: {'YES' if self.is_premium_ready else 'NO (requires 80+)'}")
|
||||
print()
|
||||
|
||||
# TLD Section
|
||||
print("-"*40)
|
||||
print("📊 TLD PRICE DATA")
|
||||
print("-"*40)
|
||||
print(f" Total TLDs: {self.tld_total_count:,}")
|
||||
print(f" With Prices: {self.tld_with_prices:,}")
|
||||
print(f" Coverage: {self.tld_price_coverage:.1f}%")
|
||||
print(f" Sources: {self.tld_sources_count}")
|
||||
print(f" Data Age: {self.tld_freshness_hours:.1f}h")
|
||||
print(f" Confidence: {self.tld_confidence_score:.1f}/100")
|
||||
print()
|
||||
|
||||
# Auction Section
|
||||
print("-"*40)
|
||||
print("🎯 AUCTION DATA")
|
||||
print("-"*40)
|
||||
print(f" Total Auctions: {self.auction_total_count:,}")
|
||||
print(f" Active: {self.auction_active_count:,}")
|
||||
print(f" Platforms: {self.auction_platforms_count}")
|
||||
print(f" Real Prices: {self.auction_with_real_prices:,}")
|
||||
print(f" Scrape Success: {self.auction_scrape_success_rate:.1f}%")
|
||||
print()
|
||||
|
||||
# Issues
|
||||
if self.issues:
|
||||
print("-"*40)
|
||||
print("⚠️ ISSUES")
|
||||
print("-"*40)
|
||||
for issue in self.issues:
|
||||
print(f" • {issue}")
|
||||
print()
|
||||
|
||||
# Recommendations
|
||||
if self.recommendations:
|
||||
print("-"*40)
|
||||
print("💡 RECOMMENDATIONS")
|
||||
print("-"*40)
|
||||
for rec in self.recommendations:
|
||||
print(f" → {rec}")
|
||||
print()
|
||||
|
||||
print("="*70)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DATA COLLECTOR
|
||||
# =============================================================================
|
||||
|
||||
class PremiumDataCollector:
|
||||
"""
|
||||
Premium-grade data collection service.
|
||||
|
||||
Collects, validates, and scores all data sources for pounce.ch.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.tld_aggregator = TLDPriceAggregator()
|
||||
self.auction_scraper = AuctionScraperService()
|
||||
self.report = DataQualityReport()
|
||||
|
||||
async def collect_tld_prices(self, db: AsyncSession) -> Dict[str, Any]:
|
||||
"""
|
||||
Collect TLD prices from all available sources.
|
||||
|
||||
Returns:
|
||||
Dictionary with collection results and metrics
|
||||
"""
|
||||
logger.info("🔄 Starting TLD price collection...")
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
try:
|
||||
result = await self.tld_aggregator.run_scrape(db)
|
||||
|
||||
duration = (datetime.utcnow() - start_time).total_seconds()
|
||||
|
||||
logger.info(f"✅ TLD prices collected in {duration:.1f}s")
|
||||
logger.info(f" → {result.new_prices} new, {result.updated_prices} updated")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"new_prices": result.new_prices,
|
||||
"updated_prices": result.updated_prices,
|
||||
"duration_seconds": duration,
|
||||
"sources": result.sources_scraped,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ TLD price collection failed: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
async def collect_auctions(self, db: AsyncSession) -> Dict[str, Any]:
|
||||
"""
|
||||
Collect auction data from all platforms.
|
||||
|
||||
Prioritizes real data over sample/estimated data.
|
||||
"""
|
||||
logger.info("🔄 Starting auction collection...")
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
try:
|
||||
# Try real scraping first
|
||||
result = await self.auction_scraper.scrape_all_platforms(db)
|
||||
|
||||
total_found = result.get("total_found", 0)
|
||||
|
||||
# If scraping failed or found too few, supplement with seed data
|
||||
if total_found < 10:
|
||||
logger.warning(f"⚠️ Only {total_found} auctions scraped, adding seed data...")
|
||||
seed_result = await self.auction_scraper.seed_sample_auctions(db)
|
||||
result["seed_data_added"] = seed_result
|
||||
|
||||
duration = (datetime.utcnow() - start_time).total_seconds()
|
||||
|
||||
logger.info(f"✅ Auctions collected in {duration:.1f}s")
|
||||
logger.info(f" → {result.get('total_new', 0)} new, {result.get('total_updated', 0)} updated")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
**result,
|
||||
"duration_seconds": duration,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Auction collection failed: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
async def analyze_data_quality(self, db: AsyncSession) -> DataQualityReport:
|
||||
"""
|
||||
Analyze current data quality and generate report.
|
||||
"""
|
||||
logger.info("📊 Analyzing data quality...")
|
||||
|
||||
report = DataQualityReport()
|
||||
|
||||
# =========================
|
||||
# TLD Price Analysis
|
||||
# =========================
|
||||
|
||||
# Count TLDs with prices
|
||||
tld_count = await db.execute(
|
||||
select(func.count(func.distinct(TLDPrice.tld)))
|
||||
)
|
||||
report.tld_with_prices = tld_count.scalar() or 0
|
||||
|
||||
# Count total TLD info records
|
||||
tld_info_count = await db.execute(
|
||||
select(func.count(TLDInfo.tld))
|
||||
)
|
||||
report.tld_total_count = max(tld_info_count.scalar() or 0, report.tld_with_prices)
|
||||
|
||||
# Calculate coverage
|
||||
if report.tld_total_count > 0:
|
||||
report.tld_price_coverage = (report.tld_with_prices / report.tld_total_count) * 100
|
||||
|
||||
# Count unique sources
|
||||
sources = await db.execute(
|
||||
select(func.count(func.distinct(TLDPrice.registrar)))
|
||||
)
|
||||
report.tld_sources_count = sources.scalar() or 0
|
||||
|
||||
# Calculate freshness (average age of prices)
|
||||
latest_price = await db.execute(
|
||||
select(func.max(TLDPrice.recorded_at))
|
||||
)
|
||||
latest = latest_price.scalar()
|
||||
if latest:
|
||||
report.tld_freshness_hours = (datetime.utcnow() - latest).total_seconds() / 3600
|
||||
|
||||
# Confidence score based on source reliability
|
||||
# Porkbun API = 100% confidence, scraped = 80%
|
||||
report.tld_confidence_score = 95.0 if report.tld_sources_count > 0 else 0.0
|
||||
|
||||
# =========================
|
||||
# Auction Analysis
|
||||
# =========================
|
||||
|
||||
# Count total auctions
|
||||
auction_count = await db.execute(
|
||||
select(func.count(DomainAuction.id))
|
||||
)
|
||||
report.auction_total_count = auction_count.scalar() or 0
|
||||
|
||||
# Count active auctions
|
||||
active_count = await db.execute(
|
||||
select(func.count(DomainAuction.id)).where(DomainAuction.is_active == True)
|
||||
)
|
||||
report.auction_active_count = active_count.scalar() or 0
|
||||
|
||||
# Count platforms
|
||||
platforms = await db.execute(
|
||||
select(func.count(func.distinct(DomainAuction.platform))).where(DomainAuction.is_active == True)
|
||||
)
|
||||
report.auction_platforms_count = platforms.scalar() or 0
|
||||
|
||||
# Count auctions with real prices (not from seed data)
|
||||
real_prices = await db.execute(
|
||||
select(func.count(DomainAuction.id)).where(
|
||||
DomainAuction.scrape_source != "seed_data"
|
||||
)
|
||||
)
|
||||
report.auction_with_real_prices = real_prices.scalar() or 0
|
||||
|
||||
# Calculate scrape success rate from logs
|
||||
logs = await db.execute(
|
||||
select(AuctionScrapeLog).order_by(AuctionScrapeLog.started_at.desc()).limit(20)
|
||||
)
|
||||
recent_logs = logs.scalars().all()
|
||||
if recent_logs:
|
||||
success_count = sum(1 for log in recent_logs if log.status == "success")
|
||||
report.auction_scrape_success_rate = (success_count / len(recent_logs)) * 100
|
||||
|
||||
# Calculate overall scores
|
||||
report.calculate_overall_score()
|
||||
|
||||
self.report = report
|
||||
return report
|
||||
|
||||
async def run_full_collection(self) -> DataQualityReport:
|
||||
"""
|
||||
Run complete data collection pipeline.
|
||||
|
||||
1. Collect TLD prices
|
||||
2. Collect auction data
|
||||
3. Analyze data quality
|
||||
4. Generate report
|
||||
"""
|
||||
logger.info("="*60)
|
||||
logger.info("🚀 POUNCE PREMIUM DATA COLLECTION - FULL RUN")
|
||||
logger.info("="*60)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Step 1: TLD Prices
|
||||
tld_result = await self.collect_tld_prices(db)
|
||||
|
||||
# Step 2: Auctions
|
||||
auction_result = await self.collect_auctions(db)
|
||||
|
||||
# Step 3: Analyze
|
||||
report = await self.analyze_data_quality(db)
|
||||
|
||||
# Step 4: Save report to file
|
||||
report_path = Path(__file__).parent.parent / "data" / "quality_reports"
|
||||
report_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
report_file = report_path / f"report_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
with open(report_file, "w") as f:
|
||||
json.dump(report.to_dict(), f, indent=2, default=str)
|
||||
|
||||
logger.info(f"📄 Report saved to: {report_file}")
|
||||
|
||||
return report
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAIN ENTRY POINT
|
||||
# =============================================================================
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="🚀 Pounce Premium Data Collector",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python premium_data_collector.py --full Run complete collection
|
||||
python premium_data_collector.py --tld Collect TLD prices only
|
||||
python premium_data_collector.py --auctions Collect auctions only
|
||||
python premium_data_collector.py --report Generate quality report only
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument("--full", action="store_true", help="Run full data collection")
|
||||
parser.add_argument("--tld", action="store_true", help="Collect TLD prices only")
|
||||
parser.add_argument("--auctions", action="store_true", help="Collect auctions only")
|
||||
parser.add_argument("--report", action="store_true", help="Generate quality report only")
|
||||
parser.add_argument("--quiet", action="store_true", help="Suppress console output")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Default to full if no args
|
||||
if not any([args.full, args.tld, args.auctions, args.report]):
|
||||
args.full = True
|
||||
|
||||
collector = PremiumDataCollector()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
if args.full:
|
||||
report = await collector.run_full_collection()
|
||||
if not args.quiet:
|
||||
report.print_report()
|
||||
|
||||
elif args.tld:
|
||||
result = await collector.collect_tld_prices(db)
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
|
||||
elif args.auctions:
|
||||
result = await collector.collect_auctions(db)
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
|
||||
elif args.report:
|
||||
report = await collector.analyze_data_quality(db)
|
||||
if not args.quiet:
|
||||
report.print_report()
|
||||
else:
|
||||
print(json.dumps(report.to_dict(), indent=2, default=str))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
57
backend/scripts/reset_admin_password.py
Normal file
57
backend/scripts/reset_admin_password.py
Normal file
@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to reset admin password.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
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 with AsyncSessionLocal() as db:
|
||||
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
|
||||
|
||||
# Hash new password
|
||||
hashed = pwd_context.hash(NEW_PASSWORD)
|
||||
user.hashed_password = hashed
|
||||
user.is_verified = True
|
||||
user.is_active = True
|
||||
user.is_admin = True
|
||||
|
||||
await db.commit()
|
||||
|
||||
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!")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(reset_password())
|
||||
|
||||
132
backend/scripts/setup_cron.sh
Executable file
132
backend/scripts/setup_cron.sh
Executable file
@ -0,0 +1,132 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# 🚀 POUNCE AUTOMATED DATA COLLECTION - CRON SETUP
|
||||
# =============================================================================
|
||||
#
|
||||
# This script sets up automated data collection for premium service.
|
||||
#
|
||||
# Schedule:
|
||||
# - TLD Prices: Every 6 hours (0:00, 6:00, 12:00, 18:00)
|
||||
# - Auctions: Every 2 hours
|
||||
# - Quality Report: Daily at 1:00 AM
|
||||
#
|
||||
# Usage:
|
||||
# ./setup_cron.sh # Install cron jobs
|
||||
# ./setup_cron.sh --remove # Remove cron jobs
|
||||
# ./setup_cron.sh --status # Show current cron jobs
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
PYTHON_PATH="${PROJECT_DIR}/.venv/bin/python"
|
||||
COLLECTOR_SCRIPT="${SCRIPT_DIR}/premium_data_collector.py"
|
||||
LOG_DIR="${PROJECT_DIR}/logs"
|
||||
|
||||
# Ensure log directory exists
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
print_status() {
|
||||
echo -e "${GREEN}[✓]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[!]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[✗]${NC} $1"
|
||||
}
|
||||
|
||||
# Cron job definitions
|
||||
CRON_MARKER="# POUNCE_DATA_COLLECTOR"
|
||||
|
||||
TLD_CRON="0 */6 * * * cd ${PROJECT_DIR} && ${PYTHON_PATH} ${COLLECTOR_SCRIPT} --tld --quiet >> ${LOG_DIR}/tld_collection.log 2>&1 ${CRON_MARKER}"
|
||||
AUCTION_CRON="0 */2 * * * cd ${PROJECT_DIR} && ${PYTHON_PATH} ${COLLECTOR_SCRIPT} --auctions --quiet >> ${LOG_DIR}/auction_collection.log 2>&1 ${CRON_MARKER}"
|
||||
REPORT_CRON="0 1 * * * cd ${PROJECT_DIR} && ${PYTHON_PATH} ${COLLECTOR_SCRIPT} --report --quiet >> ${LOG_DIR}/quality_report.log 2>&1 ${CRON_MARKER}"
|
||||
|
||||
install_cron() {
|
||||
echo "🚀 Installing Pounce Data Collection Cron Jobs..."
|
||||
echo ""
|
||||
|
||||
# Check if Python environment exists
|
||||
if [ ! -f "$PYTHON_PATH" ]; then
|
||||
print_error "Python virtual environment not found at: $PYTHON_PATH"
|
||||
echo "Please create it first: python -m venv .venv && .venv/bin/pip install -r requirements.txt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if collector script exists
|
||||
if [ ! -f "$COLLECTOR_SCRIPT" ]; then
|
||||
print_error "Collector script not found at: $COLLECTOR_SCRIPT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Remove existing Pounce cron jobs first
|
||||
(crontab -l 2>/dev/null | grep -v "$CRON_MARKER") | crontab -
|
||||
|
||||
# Add new cron jobs
|
||||
(crontab -l 2>/dev/null; echo "$TLD_CRON") | crontab -
|
||||
(crontab -l 2>/dev/null; echo "$AUCTION_CRON") | crontab -
|
||||
(crontab -l 2>/dev/null; echo "$REPORT_CRON") | crontab -
|
||||
|
||||
print_status "TLD Price Collection: Every 6 hours"
|
||||
print_status "Auction Collection: Every 2 hours"
|
||||
print_status "Quality Report: Daily at 1:00 AM"
|
||||
echo ""
|
||||
print_status "All cron jobs installed successfully!"
|
||||
echo ""
|
||||
echo "Log files will be written to: ${LOG_DIR}/"
|
||||
echo ""
|
||||
echo "To view current jobs: crontab -l"
|
||||
echo "To remove jobs: $0 --remove"
|
||||
}
|
||||
|
||||
remove_cron() {
|
||||
echo "🗑️ Removing Pounce Data Collection Cron Jobs..."
|
||||
|
||||
(crontab -l 2>/dev/null | grep -v "$CRON_MARKER") | crontab -
|
||||
|
||||
print_status "All Pounce cron jobs removed."
|
||||
}
|
||||
|
||||
show_status() {
|
||||
echo "📋 Current Pounce Cron Jobs:"
|
||||
echo ""
|
||||
|
||||
JOBS=$(crontab -l 2>/dev/null | grep "$CRON_MARKER" || true)
|
||||
|
||||
if [ -z "$JOBS" ]; then
|
||||
print_warning "No Pounce cron jobs found."
|
||||
echo ""
|
||||
echo "Run '$0' to install them."
|
||||
else
|
||||
echo "$JOBS" | while read -r line; do
|
||||
echo " $line"
|
||||
done
|
||||
echo ""
|
||||
print_status "Jobs are active."
|
||||
fi
|
||||
}
|
||||
|
||||
# Main
|
||||
case "${1:-}" in
|
||||
--remove)
|
||||
remove_cron
|
||||
;;
|
||||
--status)
|
||||
show_status
|
||||
;;
|
||||
*)
|
||||
install_cron
|
||||
;;
|
||||
esac
|
||||
|
||||
104
backend/scripts/verify_admin.py
Normal file
104
backend/scripts/verify_admin.py
Normal file
@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to ensure admin user is properly configured.
|
||||
Run this after database initialization.
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.user import User
|
||||
from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG
|
||||
|
||||
|
||||
ADMIN_EMAIL = "guggeryves@hotmail.com"
|
||||
|
||||
|
||||
async def verify_admin():
|
||||
"""Ensure admin user exists and is properly configured."""
|
||||
print(f"🔍 Looking for user: {ADMIN_EMAIL}")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Find user
|
||||
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}")
|
||||
print(" Please register first, then run this script again.")
|
||||
return False
|
||||
|
||||
print(f"✅ User found: ID={user.id}, Name={user.name}")
|
||||
|
||||
# Update user flags
|
||||
changes = []
|
||||
|
||||
if not user.is_admin:
|
||||
user.is_admin = True
|
||||
changes.append("is_admin = True")
|
||||
|
||||
if not user.is_verified:
|
||||
user.is_verified = True
|
||||
changes.append("is_verified = True")
|
||||
|
||||
if not user.is_active:
|
||||
user.is_active = True
|
||||
changes.append("is_active = True")
|
||||
|
||||
if changes:
|
||||
await db.commit()
|
||||
print(f"✅ Updated user: {', '.join(changes)}")
|
||||
else:
|
||||
print("✅ User already has correct flags")
|
||||
|
||||
# Check subscription
|
||||
sub_result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == user.id)
|
||||
)
|
||||
subscription = sub_result.scalar_one_or_none()
|
||||
|
||||
if not subscription:
|
||||
# Create Tycoon subscription
|
||||
tycoon_config = TIER_CONFIG.get(SubscriptionTier.TYCOON, {})
|
||||
subscription = Subscription(
|
||||
user_id=user.id,
|
||||
tier=SubscriptionTier.TYCOON,
|
||||
status=SubscriptionStatus.ACTIVE,
|
||||
max_domains=tycoon_config.get("domain_limit", 500),
|
||||
)
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
print("✅ Created Tycoon subscription")
|
||||
elif subscription.tier != SubscriptionTier.TYCOON:
|
||||
subscription.tier = SubscriptionTier.TYCOON
|
||||
subscription.status = SubscriptionStatus.ACTIVE
|
||||
subscription.max_domains = TIER_CONFIG.get(SubscriptionTier.TYCOON, {}).get("domain_limit", 500)
|
||||
await db.commit()
|
||||
print("✅ Upgraded to Tycoon subscription")
|
||||
else:
|
||||
print(f"✅ Subscription: {subscription.tier.value} (Active)")
|
||||
|
||||
# Final status
|
||||
print("\n" + "="*50)
|
||||
print("📋 FINAL STATUS:")
|
||||
print(f" Email: {user.email}")
|
||||
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(f" Tier: Tycoon")
|
||||
print("="*50)
|
||||
print("\n✅ Admin user is ready! You can now login.")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(verify_admin())
|
||||
|
||||
402
concept.md
Normal file
402
concept.md
Normal file
@ -0,0 +1,402 @@
|
||||
Hier ist das ganzheitliche Konzept für **pounce.ch**.
|
||||
|
||||
Es vereint deine drei Bausteine (Monitoring, TLD-Daten, Auktionen) zu einem logischen Produkt, das sowohl Einsteiger ("Dreamers") als auch Profis ("Hunters") abholt.
|
||||
|
||||
---
|
||||
|
||||
### 1. Tone of Voice & Markenidentität
|
||||
|
||||
Wir müssen weg vom reinen "aggressiven Jäger" hin zu **"Smart Intelligence"**. Pounce ist nicht der Schläger, der die Tür eintritt, sondern der Stratege, der genau weiß, wann die Tür unverschlossen ist.
|
||||
|
||||
* **Der Vibe:** "Bloomberg Terminal meets Apple". Minimalistisch, datengetrieben, aber extrem einfach zu bedienen. Dunkles Design (Dark Mode), Neon-Akzente (Signalgrün für Chancen).
|
||||
* **Die Sprache:** Präzise, vertraulich, treibend.
|
||||
* *Nicht:* "Wir überwachen Domains für dich."
|
||||
* *Sondern:* "Sie schlafen. Wir jagen. Dein Vorteil im Domain-Markt."
|
||||
* *Nicht:* "Hier sind Auktionsdaten."
|
||||
* *Sondern:* "Der Markt in Echtzeit. Gefiltert. Bewertet. Bereit."
|
||||
* **Das Versprechen:** "Don’t guess. Know."
|
||||
|
||||
---
|
||||
|
||||
### 2. Das Produkt-Konzept: "The Domain Command Center"
|
||||
|
||||
Das Produkt gliedert sich nicht in technische Kategorien, sondern in **User-Bedürfnisse**.
|
||||
|
||||
#### A. DISCOVER (Der Trichter für die Masse)
|
||||
*Das ersetzt die langweilige "Domain Search" bei GoDaddy.*
|
||||
|
||||
* **Funktion:** Ein super-schnelles Suchfeld.
|
||||
* **Der Clou:** Wenn eine Domain vergeben ist, zeigt Pounce nicht nur "Besetzt", sondern:
|
||||
* **Live-Status:** "Webseite ist offline" / "Steht zum Verkauf auf Sedo" / "Läuft bald aus".
|
||||
* **Smarte Alternativen:** KI-Vorschläge basierend auf deinen TLD-Daten (z.B. "`.io` ist teuer, nimm `.xyz` für $2").
|
||||
* **Ziel:** Sofortiger Nutzen für JEDEN Besucher.
|
||||
* **Monetarisierung:** Affiliate-Links zu Registraren (Free User) + Lead-Generierung (Account erstellen).
|
||||
|
||||
#### B. TRACK (Das Herzstück für Bindung)
|
||||
*Die "Watchlist" für Gründer und Jäger.*
|
||||
|
||||
* **Funktion:** User setzen besetzte Domains auf eine Liste.
|
||||
* **Simplicity (UX):** Keine Tabellen. Ein Dashboard mit **Status-Karten**.
|
||||
* 🟢 *Chance:* Domain ist gerade gedroppt oder in Auktion.
|
||||
* 🟡 *Warten:* Domain hat sich verändert (DNS Update, Site down).
|
||||
* 🔴 *Stabil:* Domain ist fest in Hand, aber wir beobachten weiter.
|
||||
* **Der "Pro"-Mehrwert ($19):**
|
||||
* **Deep Intel:** Wer ist der Besitzer? (Automatisierte Impressums/Whois-Suche).
|
||||
* **Pre-Drop Alerts:** Warnung bei DNS-Änderungen (bevor sie droppt).
|
||||
|
||||
#### C. ACQUIRE (Der Marktplatz für Action)
|
||||
*Deine Auktions-Aggregation + TLD Trends.*
|
||||
|
||||
* **Funktion:** Aggregation aller Auktionen (Sedo, GoDaddy, DropCatch) an einem Ort.
|
||||
* **Der "No-Bullshit"-Filter:** Das ist dein USP. Dein Algorithmus filtert automatisch Spam-Domains (`xy-kredit-24.info`) raus.
|
||||
* **Der "Pro"-Mehrwert ($19):**
|
||||
* **Deal-Score:** Zeige automatische Bewertungen (Estibot o.ä.) neben dem Auktionspreis. Wenn *Wert > Preis* → **"Undervalued 🔥"** Label.
|
||||
* **Arbitrage-Radar:** "Kaufe hier `.ai` für $60, verkaufe dort für $100".
|
||||
|
||||
---
|
||||
|
||||
### 3. Die User Journey (Wie sie bezahlen)
|
||||
|
||||
Wir nutzen das "Freemium mit Schranken"-Modell.
|
||||
|
||||
**Schritt 1: Der "Dreamer" (Kostenlos)**
|
||||
* *Situation:* Ein Startup-Gründer sucht einen Namen. Findet Pounce.
|
||||
* *Nutzen:* Er kann sofort sehen, wo seine Wunsch-Domain liegt (Auktion? Vergeben?). Er sieht deine coolen TLD-Statistiken ("Ah, `.io` ist beliebt!").
|
||||
* *Limit:* Er sieht Auktionen, aber mit 24h Verzögerung oder ohne "Deal-Score". Er kann nur 3 Domains überwachen.
|
||||
|
||||
**Schritt 2: Der "Opportunity-Check" (Der Upsell-Moment)**
|
||||
* Der User sieht in der Auktionsliste eine Domain `fintech-hero.com` für $50. Er will wissen: "Ist das ein guter Preis?"
|
||||
* Pounce blendet den "Valuation & Stats"-Button aus.
|
||||
* *Call to Action:* **"Unlock Market Intel. See valuations, owner history, and instant alerts. Start Trial."**
|
||||
|
||||
**Schritt 3: Der "Hunter" (Zahlender Kunde - 19 CHF)**
|
||||
* Er nutzt Pounce täglich, um den "Müll" der anderen Plattformen nicht sehen zu müssen. Er zahlt für **Zeitersparnis** (Filter) und **Informationsvorsprung** (Alerts).
|
||||
|
||||
---
|
||||
|
||||
### 4. Zusammenfassung der Features (Roadmap)
|
||||
|
||||
Um nicht überkompliziert zu werden, baue es in dieser Reihenfolge:
|
||||
|
||||
1. **MVP (Minimum Viable Product):**
|
||||
* **Universal Search:** Sucht in Whois + Auktionen gleichzeitig.
|
||||
* **Clean Auction Feed:** Deine Auktions-Liste, aber OHNE Spam (Filter: Keine Zahlen, max 2 Hyphens, keine .info Spam-Cluster).
|
||||
* **Basic Watchlist:** E-Mail wenn Status sich ändert.
|
||||
|
||||
2. **Phase 2 (Value Add):**
|
||||
* **TLD Intelligence:** Integriere deine Preis-Charts. Mach sie "actionable" ("Günstigster Registrar für .ai ist XYZ").
|
||||
* **Valuation:** Integriere eine API (z.B. GoDaddy Appraisal API), um Schätzwerte anzuzeigen.
|
||||
|
||||
---
|
||||
|
||||
### 5. Beispiel-Wording (Startseite)
|
||||
|
||||
**Headline:**
|
||||
> **Der Markt schläft nie. Du schon.**
|
||||
> *Domain Intelligence für Investoren und Gründer. Wir finden, überwachen und bewerten deine nächsten digitalen Assets.*
|
||||
|
||||
**Sub-Headline:**
|
||||
> *Scanne 800+ TLDs, filtere Millionen Auktionen und erhalte Alerts, bevor andere reagieren.*
|
||||
|
||||
**CTA:**
|
||||
> [Starte die Jagd – Kostenlos]
|
||||
|
||||
---
|
||||
|
||||
### Warum das funktioniert:
|
||||
1. **Viele Nutzer:** Weil die Suche und die TLD-Statistiken nützlichen "Free Content" bieten (gut für SEO und Viralität).
|
||||
2. **Zahlungsbereitschaft:** Weil Investoren Geld hassen, das auf der Straße liegt. Wenn du ihnen zeigst "Hier ist ein unterbewertetes Asset", zahlen sie gerne $19.
|
||||
3. **Nicht kompliziert:** Alles fließt in **eine** Zentrale. Suchen, Finden, Überwachen. Keine komplexen DNS-Tools, sondern klare Ampel-Systeme und Kauf-Buttons.
|
||||
|
||||
Hier ist die Informationsarchitektur (IA) für **pounce.ch**.
|
||||
|
||||
Das Ziel dieser Struktur ist **Klarheit**:
|
||||
|
||||
1. **Public Site (Marketing):** Zieht Besucher über SEO (TLD-Daten) und Neugier (Auktionen) an und konvertiert sie zur Registrierung.
|
||||
2. **Private App (Command Center):** Hält den Nutzer durch Übersichtlichkeit und wertvolle Daten ("Intelligence") im Abo.
|
||||
|
||||
-----
|
||||
|
||||
### Teil 1: Public Web (Marketing & SEO)
|
||||
|
||||
*Zugänglich für jeden, ohne Login. Ziel: Trust aufbauen & Sign-Up.*
|
||||
|
||||
**1. Home (Landing Page)**
|
||||
|
||||
* **Hero Section:** "Don't guess. Know." + Großes Suchfeld (Universal Search).
|
||||
* **Hook:** "Live Market Ticker" (Durchlaufende Leiste mit heißen Domains/Auktionen).
|
||||
* **Value Props:** Monitoring, Filtered Auctions, TLD Intel.
|
||||
* **CTA:** "Start Hunting Free".
|
||||
|
||||
**2. Market (Auctions Public Preview)**
|
||||
|
||||
* *Zweck: Teaser für die Datenqualität.*
|
||||
* Liste von aktuellen Auktionen (limitiert auf 20 Einträge oder verzögert).
|
||||
* **Teaser-Element:** Spalten für "Estibot Value" oder "Deal Score" sind ausgegraut/verschwommen → "Login to see valuation".
|
||||
|
||||
**3. TLD Intel (Data Hub)**
|
||||
|
||||
* *Zweck: SEO-Magnet & Expertenstatus.*
|
||||
* **Overview:** Top Movers (Gewinner/Verlierer der Woche z.B. `.ai` +5%).
|
||||
* **Detail-Seite pro TLD (z.B. /tld/ai):**
|
||||
* Preisentwicklung (Chart).
|
||||
* Durchschnittspreis vs. Günstigster Registrar.
|
||||
* Registrierungs-Trends (Wächst die TLD?).
|
||||
|
||||
**4. Pricing**
|
||||
|
||||
* Vergleichstabelle: Free (Scout) vs. Pro (Hunter).
|
||||
|
||||
**5. Resources / Footer**
|
||||
|
||||
* Blog (Domain-Investment Tipps).
|
||||
* Login / Sign-up.
|
||||
|
||||
-----
|
||||
|
||||
### Teil 2: The Command Center (App / Eingeloggt)
|
||||
|
||||
*Das Herzstück. Dunkles Design, datenintensiv aber aufgeräumt.*
|
||||
|
||||
**Navigation:**
|
||||
|
||||
* Empfehlung: **Linke Sidebar** (Collapsible). Das wirkt professioneller ("Werkzeug") als eine Top-Bar.
|
||||
|
||||
#### A. Dashboard (Home)
|
||||
|
||||
*Der Überblick beim ersten Kaffee.*
|
||||
|
||||
* **Activity Feed:** "3 Domains auf deiner Watchlist haben Status geändert."
|
||||
* **Market Pulse:** "Heute enden 5 Auktionen, die deinen Filtern entsprechen."
|
||||
* **Quick Search:** Eingabefeld, um sofort eine Domain zu prüfen oder zur Watchlist hinzuzufügen.
|
||||
|
||||
#### B. My Watchlist (Track)
|
||||
|
||||
*Die persönliche Jagdliste.*
|
||||
|
||||
* **Listenansicht:**
|
||||
* *Spalte 1:* Domain Name.
|
||||
* *Spalte 2:* Status (Ampel-System: Online / Inaktiv / Pending Drop).
|
||||
* *Spalte 3:* Eigner-Info (Pro Feature: Wer ist es?).
|
||||
* *Spalte 4:* Actions (Notiz hinzufügen, Whois ansehen, Löschen).
|
||||
* **Filter:** "Zeige nur Domains, die offline sind" (Chance\!).
|
||||
|
||||
#### C. Market Scanner (Auctions)
|
||||
|
||||
*Die aggregierten Auktionsdaten.*
|
||||
|
||||
* **Smart Filters (Das USP):**
|
||||
* Preset: "High Value / Low Price".
|
||||
* Preset: "Short Domains (4 Letters)".
|
||||
* Preset: "No Trash" (Filtert automatisch kryptische Namen).
|
||||
* **Die Tabelle:**
|
||||
* Domain | Aktuelles Gebot | **Pounce Value (KI-Schätzung)** | Endet in | Plattform (Sedo/GoDaddy).
|
||||
* *Action:* "Bid Now" (Externer Link) oder "Track" (auf Watchlist setzen).
|
||||
|
||||
#### D. TLD Intelligence (Analyze)
|
||||
|
||||
*Strategische Daten.*
|
||||
|
||||
* **Arbitrage Finder:** Tabelle aller TLDs.
|
||||
* Spalte: "Reg Fee" (Registrierungspreis).
|
||||
* Spalte: "Avg. Resale Price" (Verkaufspreis).
|
||||
* *Highlight:* Wo ist die Marge am größten?
|
||||
* **Registrar Comparison:** Wo bekomme ich `.io` heute am billigsten?
|
||||
|
||||
#### E. Settings
|
||||
|
||||
* **Alerts:** "Sende mir SMS bei Drops" (Pro Feature).
|
||||
* **Billing:** Abo verwalten.
|
||||
|
||||
-----
|
||||
|
||||
### Visuelle Map der Struktur
|
||||
|
||||
```text
|
||||
POUNCE.CH
|
||||
│
|
||||
├── PUBLIC (Visitor)
|
||||
│ ├── Home (Search + Value Prop)
|
||||
│ ├── Market Preview (Auctions List - limited)
|
||||
│ ├── TLD Data (Trends & Charts - SEO optimized)
|
||||
│ ├── Pricing
|
||||
│ └── Auth (Login/Register)
|
||||
│
|
||||
└── COMMAND CENTER (User)
|
||||
├── [Sidebar Nav]
|
||||
│ ├── Dashboard (Overview & Notifications)
|
||||
│ ├── Watchlist (My tracked domains)
|
||||
│ │ └── Detail View (Whois, History, Notes)
|
||||
│ ├── Market (Auctions & Drops)
|
||||
│ │ ├── Saved Filters (e.g. "My AI Search")
|
||||
│ │ └── Deal Analyzer (Pro only)
|
||||
│ ├── Intelligence (TLD Stats & Arbitrage)
|
||||
│ └── Settings (Profile, Billing, Alerts)
|
||||
```
|
||||
|
||||
### User Flow Beispiel: "Vom Besucher zum Jäger"
|
||||
|
||||
1. **Public:** User googelt "Preisentwicklung .ai Domain".
|
||||
2. **Public TLD Page:** Landet auf deiner TLD-Seite. Sieht, dass der Preis steigt.
|
||||
3. **Teaser:** Unten auf der Seite steht: *"Wir haben gerade 12 unterbewertete .ai Domains in Auktionen gefunden. [Show me]"*.
|
||||
4. **Sign-Up:** User erstellt Free Account.
|
||||
5. **Command Center (Market):** User sieht die Liste, gefiltert nach `.ai`.
|
||||
6. **Upgrade:** User will wissen, ob der Preis von $50 für `brain.ai` gut ist. Klickt auf "Analyze". Popup: *"Upgrade to Hunter to see valuation data."*
|
||||
|
||||
### Nächster Schritt für dich
|
||||
|
||||
Soll ich dir basierend auf dieser Struktur einen **Wireframe-Text** für das **Dashboard (Command Center)** erstellen, damit du siehst, welche Daten dort genau stehen sollten?
|
||||
|
||||
Das ist ein **extrem wertvolles Feature**. Genau das ist der Unterschied zwischen einem "Verkäufer" (wie GoDaddy, der dir alles andrehen will) und einem "Berater" (Pounce, der dir nur das Gute zeigt).
|
||||
|
||||
Das Problem bei den großen Hostern ist: Sie sortieren nach **Marge** (woran sie am meisten verdienen) oder alphabetisch. Du sortierst nach **Qualität und Trend**.
|
||||
|
||||
Hier ist das Konzept für die **"Smart Alternative Engine"**:
|
||||
|
||||
### Das Feature: "Signal over Noise" Suggestions
|
||||
|
||||
Wenn der User `meinstartup.ai` eingibt und die Domain vergeben ist, ballern wir ihn nicht mit `.kitchen` oder `.rocks` zu. Wir nutzen deine Datenbank (TLD Trends), um nur relevante Alternativen zu zeigen.
|
||||
|
||||
#### 1\. Die Logik: Kategorisierung statt Listen
|
||||
|
||||
Statt einer langen Liste "Andere Endungen", teilst du die Ergebnisse in **3 smarte Cluster**:
|
||||
|
||||
**A. Die "Tech & Trend" Alternativen (High Performance)**
|
||||
|
||||
* *Kriterium:* TLDs, die aktuell im Trend liegen (laut deiner Datenbank) und in der Tech/Startup-Welt akzeptiert sind.
|
||||
* *Vorschläge:* `.io`, `.co`, `.app`, `.xyz`.
|
||||
* *Das "Pounce"-Extra:* Zeige dazu den Trend an.
|
||||
* `meinstartup.io` – **Frei** (Trend: 🔥 Beliebt bei SaaS)
|
||||
|
||||
**B. Die "Thematisch passenden" Alternativen (Context Aware)**
|
||||
|
||||
* *Kriterium:* Eine einfache Keyword-Analyse des Namens.
|
||||
* *Beispiel:* User sucht `mein-coffee-shop.com`.
|
||||
* *Vorschlag:* `.shop`, `.store`, `.cafe` (statt `.net` oder `.org`, die hier keinen Sinn machen).
|
||||
* *Beispiel:* User sucht `finanz-guru.de`.
|
||||
* *Vorschlag:* `.money`, `.finance`.
|
||||
|
||||
**C. Die "Budget & Hidden Gem" Alternativen**
|
||||
|
||||
* *Kriterium:* Günstiger Preis, aber seriöse Endung (kein Spam).
|
||||
* *Vorschläge:* `.de` (wenn deutsch), `.eu`, `.net`.
|
||||
|
||||
-----
|
||||
|
||||
### 2\. UI-Konzept: Wie es aussieht
|
||||
|
||||
Stell dir vor, der User sucht: **`future-ai.com`** (Vergeben).
|
||||
|
||||
Das Resultat sollte so aussehen:
|
||||
|
||||
> **❌ https://www.google.com/url?sa=E\&source=gmail\&q=future-ai.com ist vergeben.**
|
||||
> [Button: Überwachen (Notify on Drop)] [Button: Owner kontaktieren]
|
||||
>
|
||||
> -----
|
||||
>
|
||||
> **💡 Wir haben 3 Top-Alternativen für dich gefunden:**
|
||||
> *(Basierend auf Marktwert und Beliebtheit)*
|
||||
>
|
||||
> | Domain | Warum diese Endung? | Preis | Action |
|
||||
> | :--- | :--- | :--- | :--- |
|
||||
> | **future-ai.io** | **\#1 Tech-Alternative.** Standard für AI-Startups. | $32.00 | [Kaufen] |
|
||||
> | **future-ai.co** | **Seriös & Kurz.** Wird oft als Alternative zu .com genutzt. | $11.00 | [Kaufen] |
|
||||
> | **future-ai.xyz** | **Budget-Tipp.** Beliebt bei Web3/GenZ. | $2.00 | [Kaufen] |
|
||||
>
|
||||
> **Mehr anzeigen:** [Semantic] [Regional] [Alle ansehen]
|
||||
|
||||
-----
|
||||
|
||||
### 3\. Warum das besser ist ("Pounce Intelligence")
|
||||
|
||||
Du löst das Problem "Anstrengend & Schrott" durch **Daten**:
|
||||
|
||||
1. **Der "Anti-Spam" Filter:**
|
||||
Du blendest Endungen wie `.info`, `.biz`, `.name`, `.party` standardmäßig aus, es sei denn, der User will sie sehen. Das wirkt sofort hochwertiger.
|
||||
2. **Der "Performance" Indikator:**
|
||||
Du kannst neben der Endung kleine Badges anzeigen:
|
||||
* 🏆 **SEO-Friendly:** (z.B. für .de, .com, .org)
|
||||
* 🚀 **Trending:** (z.B. für .ai, .io)
|
||||
* 🛡️ **Trust:** (z.B. für .ch, .eu)
|
||||
3. **Domain Hacks (für Profis):**
|
||||
Wenn der User `superdesign.com` sucht, prüft dein Algorithmus auch, ob die Endung Teil des Wortes sein kann:
|
||||
* Vorschlag: `superdes.ign` (Smart\!)
|
||||
|
||||
### Wie du das umsetzt (Technisch simpel halten)
|
||||
|
||||
Du brauchst keine komplexe KI. Du brauchst nur "Listen-Logik":
|
||||
|
||||
* **Tier 1 (Immer zeigen wenn frei):** .com, .net, .org, .io, .co, .ai, .de, .ch (je nach User-Standort).
|
||||
* **Tier 2 (Kontext):** Wenn Wort "Shop/Buy" enthält -\> zeige .shop. Wenn Wort "App/Tool" enthält -\> zeige .app.
|
||||
* **Tier 3 (Müll):** Alles andere -\> In ein "Show More" Menü verstecken.
|
||||
|
||||
**Der Business-Mehrwert:**
|
||||
Damit wirst du zur **ersten Anlaufstelle für Namensfindung**. User kommen zu dir, nicht um zu *kaufen* (das machen sie am Ende beim Registrar), sondern um zu *entscheiden*. Und während sie entscheiden, sehen sie deine Pro-Features ("Überwache die .com doch lieber\!").
|
||||
|
||||
Das ist ein **riesiger Sprung nach vorne**. Die Seite wirkt jetzt viel erwachsener, strategischer und weniger "hemadsärmelig".
|
||||
|
||||
Der Wechsel von "Pick your weapon" zu **"Command Center"** ist genau der richtige Schritt. Das positioniert dich als professionelles Tool (SaaS) und nicht nur als Skript für Bastler.
|
||||
|
||||
Hier ist mein Feedback im Detail – was super ist und wo du noch den letzten Schliff ansetzen kannst:
|
||||
|
||||
### 1. Der Hero-Bereich (Der erste Eindruck)
|
||||
> **"The market never sleeps. You should."**
|
||||
|
||||
Das ist eine **geniale Headline**. Sie ist frech, kurz und bringt den Nutzen (Automatisierung) auf den Punkt.
|
||||
* **Subline:** *"We scan. We watch. We alert. You pounce."* – Das Staccato funktioniert super. Es baut Spannung auf.
|
||||
* **Der Ticker:** Die durchlaufende Leiste mit den Domains (`blockvest.co`, `nexus.dev`) ist Gold wert. Das zeigt sofort: "Hier ist Action, hier sind Daten." Das ist dein "Bloomberg-Moment".
|
||||
|
||||
### 2. Die "Three Moves" (Die Struktur)
|
||||
Die Aufteilung in **Discover – Track – Acquire** ist jetzt glasklar.
|
||||
* **Discover:** *"Not just 'taken' — but why, when it expires, and smarter alternatives."* -> Das löst genau das Problem, das wir besprochen haben (GoDaddy-Frust).
|
||||
* **Acquire:** *"Filtered. Valued."* -> Das sind die Trigger-Wörter für Investoren. Sie wollen keine Müll-Listen, sie wollen gefilterte Werte.
|
||||
|
||||
### 3. TLD Intelligence
|
||||
Die Integration der Live-Daten (.ai +35%) auf der Landing Page ist perfekt für **Social Proof**. Es zeigt, dass du nicht nur Domains auflistest, sondern den *Markt verstehst*.
|
||||
|
||||
---
|
||||
|
||||
### Mein Feinschliff-Vorschlag (Optimierung)
|
||||
|
||||
Hier sind ein paar kleine Anpassungen, um die Conversion noch weiter zu steigern:
|
||||
|
||||
#### A. Das Suchfeld als "Hero"
|
||||
Du schreibst: *"Try dream.com, startup.io, or next.co"*.
|
||||
Stelle sicher, dass das **Suchfeld** das absolut dominierende Element in der Mitte ist. Es muss einladend wirken.
|
||||
* *Idee:* Wenn der User tippt, sollte das Feld vielleicht schon während des Tippens reagieren (Autosuggest) oder zumindest sehr prominent "Search Global Market" sagen.
|
||||
|
||||
#### B. Pricing Table - Klarere Trennung
|
||||
In deinem Text unten sind die Features etwas vermischt. Hier ist eine schärfere Formulierung für die Tabelle, um den **Schmerzpunkt** zu treffen, der zum Upgrade führt:
|
||||
|
||||
**Scout (Free)**
|
||||
* *Für:* "Casual Search & Inspiration"
|
||||
* ✅ Real-time Availability Check
|
||||
* ✅ AI-powered Alternatives
|
||||
* ✅ Watchlist: **5 Domains**
|
||||
* ❌ **No** Deal Scores & Valuations
|
||||
* ❌ **No** Spam Filter in Auctions
|
||||
|
||||
**Trader ($19/mo)**
|
||||
* *Für:* "Serious Investors & Founders"
|
||||
* ✅ **Unlimited** Market Intel
|
||||
* ✅ Watchlist: **100 Domains**
|
||||
* ✅ **Smart Spam Filter** (Clean Auction Feed)
|
||||
* ✅ **Expiry Intel** (See exact drop dates)
|
||||
* ✅ **Instant** SMS/Email Alerts
|
||||
|
||||
*Warum das besser ist:* Du musst klar machen, dass der Free-User den "Spam" sieht und der Pro-User die "saubere Liste". Das ist ein starker Kaufgrund.
|
||||
|
||||
#### C. Trust-Elemente
|
||||
Du hast "886+ TLDs" etc.
|
||||
Vielleicht kannst du noch ein kleines Element hinzufügen wie:
|
||||
> *"Data aggregated from GoDaddy, Sedo, NameJet & DropCatch."*
|
||||
Logos dieser Anbieter in Graustufen (klein) bauen sofort Vertrauen auf, weil die User diese Marken kennen.
|
||||
|
||||
### Fazit
|
||||
Der Tone of Voice ist jetzt **"Cool, Calm, Collected"**.
|
||||
Du bist nicht mehr der hektische Marktschreier, sondern der **Analyst im Hintergrund**.
|
||||
|
||||
Der Satz **"Don't guess. Know."** ist extrem stark. Er sollte vielleicht sogar als fester Slogan unter dem Logo stehen oder als Meta-Title der Seite genutzt werden.
|
||||
|
||||
**Nächster Schritt:**
|
||||
Hast du schon überlegt, was passiert, wenn man auf "Go to Dashboard" klickt? Ist das erste, was man sieht, die "Universal Search" oder eine Übersicht der "Top Movers"? (Ich würde die Search empfehlen).
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,106 +0,0 @@
|
||||
import { Metadata } from 'next'
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.ch'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Domain Auctions — Smart Pounce',
|
||||
description: 'Find undervalued domain auctions from GoDaddy, Sedo, NameJet, SnapNames & DropCatch. Our Smart Pounce algorithm identifies the best opportunities with transparent valuations.',
|
||||
keywords: [
|
||||
'domain auctions',
|
||||
'expired domains',
|
||||
'domain bidding',
|
||||
'GoDaddy auctions',
|
||||
'Sedo domains',
|
||||
'NameJet',
|
||||
'domain investment',
|
||||
'undervalued domains',
|
||||
'domain flipping',
|
||||
],
|
||||
openGraph: {
|
||||
title: 'Domain Auctions — Smart Pounce by pounce',
|
||||
description: 'Find undervalued domain auctions. Transparent valuations, multiple platforms, no payment handling.',
|
||||
url: `${siteUrl}/auctions`,
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: `${siteUrl}/og-auctions.png`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Smart Pounce - Domain Auction Aggregator',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Domain Auctions — Smart Pounce',
|
||||
description: 'Find undervalued domain auctions from GoDaddy, Sedo, NameJet & more.',
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/auctions`,
|
||||
},
|
||||
}
|
||||
|
||||
// JSON-LD for Auctions page
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: 'Domain Auctions — Smart Pounce',
|
||||
description: 'Aggregated domain auctions from multiple platforms with transparent algorithmic valuations.',
|
||||
url: `${siteUrl}/auctions`,
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: 'pounce',
|
||||
url: siteUrl,
|
||||
},
|
||||
about: {
|
||||
'@type': 'Service',
|
||||
name: 'Smart Pounce',
|
||||
description: 'Domain auction aggregation and opportunity analysis',
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: 'pounce',
|
||||
},
|
||||
},
|
||||
mainEntity: {
|
||||
'@type': 'ItemList',
|
||||
name: 'Domain Auctions',
|
||||
description: 'Live domain auctions from GoDaddy, Sedo, NameJet, SnapNames, and DropCatch',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'GoDaddy Auctions',
|
||||
url: 'https://auctions.godaddy.com',
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: 'Sedo',
|
||||
url: 'https://sedo.com',
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: 'NameJet',
|
||||
url: 'https://namejet.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default function AuctionsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,29 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { PlatformBadge } from '@/components/PremiumTable'
|
||||
import {
|
||||
Clock,
|
||||
TrendingUp,
|
||||
ExternalLink,
|
||||
Search,
|
||||
Flame,
|
||||
Timer,
|
||||
Users,
|
||||
ArrowUpRight,
|
||||
Lock,
|
||||
Gavel,
|
||||
DollarSign,
|
||||
X,
|
||||
Lock,
|
||||
TrendingUp,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
DollarSign,
|
||||
RefreshCw,
|
||||
Target,
|
||||
Info,
|
||||
X,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
@ -45,25 +42,9 @@ interface Auction {
|
||||
affiliate_url: string
|
||||
}
|
||||
|
||||
interface Opportunity {
|
||||
auction: Auction
|
||||
analysis: {
|
||||
opportunity_score: number
|
||||
urgency?: string
|
||||
competition?: string
|
||||
price_range?: string
|
||||
recommendation: string
|
||||
reasoning?: string
|
||||
// Legacy fields
|
||||
estimated_value?: number
|
||||
current_bid?: number
|
||||
value_ratio?: number
|
||||
potential_profit?: number
|
||||
}
|
||||
}
|
||||
|
||||
type TabType = 'all' | 'ending' | 'hot' | 'opportunities'
|
||||
type SortField = 'ending' | 'bid_asc' | 'bid_desc' | 'bids'
|
||||
type TabType = 'all' | 'ending' | 'hot'
|
||||
type SortField = 'domain' | 'ending' | 'bid' | 'bids'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
const PLATFORMS = [
|
||||
{ id: 'All', name: 'All Sources' },
|
||||
@ -71,29 +52,62 @@ const PLATFORMS = [
|
||||
{ id: 'Sedo', name: 'Sedo' },
|
||||
{ id: 'NameJet', name: 'NameJet' },
|
||||
{ id: 'DropCatch', name: 'DropCatch' },
|
||||
{ id: 'ExpiredDomains', name: 'Expired Domains' },
|
||||
]
|
||||
|
||||
const TAB_DESCRIPTIONS: Record<TabType, { title: string; description: string }> = {
|
||||
all: {
|
||||
title: 'All Auctions',
|
||||
description: 'All active auctions from all platforms, sorted by ending time by default.',
|
||||
},
|
||||
ending: {
|
||||
title: 'Ending Soon',
|
||||
description: 'Auctions ending within the next 24 hours. Best for last-minute sniping opportunities.',
|
||||
},
|
||||
hot: {
|
||||
title: 'Hot Auctions',
|
||||
description: 'Auctions with the most bidding activity (20+ bids). High competition but proven demand.',
|
||||
},
|
||||
opportunities: {
|
||||
title: 'Smart Opportunities',
|
||||
description: 'Our algorithm scores auctions based on: Time urgency (ending soon = higher score), Competition (fewer bids = higher score), and Price point (lower entry = higher score). Only auctions with a combined score ≥ 3 are shown.',
|
||||
},
|
||||
// Premium TLDs that look professional (from analysis_1.md)
|
||||
const PREMIUM_TLDS = ['com', 'io', 'ai', 'co', 'de', 'ch', 'net', 'org', 'app', 'dev', 'xyz']
|
||||
|
||||
// Vanity Filter: Only show "beautiful" domains to non-authenticated users (from analysis_1.md)
|
||||
// Rules: No numbers (except short domains), no hyphens, length < 12, only premium TLDs
|
||||
function isVanityDomain(auction: Auction): boolean {
|
||||
const domain = auction.domain
|
||||
const parts = domain.split('.')
|
||||
if (parts.length < 2) return false
|
||||
|
||||
const name = parts[0]
|
||||
const tld = parts.slice(1).join('.').toLowerCase()
|
||||
|
||||
// Check TLD is premium
|
||||
if (!PREMIUM_TLDS.includes(tld)) return false
|
||||
|
||||
// Check length (max 12 characters for the name)
|
||||
if (name.length > 12) return false
|
||||
|
||||
// No hyphens
|
||||
if (name.includes('-')) return false
|
||||
|
||||
// No numbers (unless domain is 4 chars or less - short domains are valuable)
|
||||
if (name.length > 4 && /\d/.test(name)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function SortIcon({ field, currentField, direction }: { field: SortField, currentField: SortField, direction: 'asc' | 'desc' }) {
|
||||
// Generate a mock "Deal Score" for display purposes
|
||||
// In production, this would come from a valuation API
|
||||
function getDealScore(auction: Auction): number | null {
|
||||
// Simple heuristic based on domain characteristics
|
||||
let score = 50
|
||||
|
||||
// Short domains are more valuable
|
||||
const name = auction.domain.split('.')[0]
|
||||
if (name.length <= 4) score += 20
|
||||
else if (name.length <= 6) score += 10
|
||||
|
||||
// Premium TLDs
|
||||
if (['com', 'io', 'ai'].includes(auction.tld)) score += 15
|
||||
|
||||
// Age bonus
|
||||
if (auction.age_years && auction.age_years > 5) score += 10
|
||||
|
||||
// High competition = good domain
|
||||
if (auction.num_bids >= 20) score += 15
|
||||
else if (auction.num_bids >= 10) score += 10
|
||||
|
||||
// Cap at 100
|
||||
return Math.min(score, 100)
|
||||
}
|
||||
|
||||
function SortIcon({ field, currentField, direction }: { field: SortField, currentField: SortField, direction: SortDirection }) {
|
||||
if (field !== currentField) {
|
||||
return <ChevronsUpDown className="w-4 h-4 text-foreground-subtle" />
|
||||
}
|
||||
@ -102,107 +116,65 @@ function SortIcon({ field, currentField, direction }: { field: SortField, curren
|
||||
: <ChevronDown className="w-4 h-4 text-accent" />
|
||||
}
|
||||
|
||||
function getPlatformBadgeClass(platform: string) {
|
||||
switch (platform) {
|
||||
case 'GoDaddy': return 'text-blue-400 bg-blue-400/10'
|
||||
case 'Sedo': return 'text-orange-400 bg-orange-400/10'
|
||||
case 'NameJet': return 'text-purple-400 bg-purple-400/10'
|
||||
case 'DropCatch': return 'text-teal-400 bg-teal-400/10'
|
||||
default: return 'text-foreground-muted bg-foreground/5'
|
||||
}
|
||||
}
|
||||
|
||||
export default function AuctionsPage() {
|
||||
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
|
||||
|
||||
const [allAuctions, setAllAuctions] = useState<Auction[]>([])
|
||||
const [endingSoon, setEndingSoon] = useState<Auction[]>([])
|
||||
const [hotAuctions, setHotAuctions] = useState<Auction[]>([])
|
||||
const [opportunities, setOpportunities] = useState<Opportunity[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all')
|
||||
const [sortBy, setSortBy] = useState<SortField>('ending')
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
|
||||
const [sortField, setSortField] = useState<SortField>('ending')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
||||
|
||||
// Filters
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedPlatform, setSelectedPlatform] = useState('All')
|
||||
const [maxBid, setMaxBid] = useState<string>('')
|
||||
const [maxBid, setMaxBid] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
loadData()
|
||||
loadAuctions()
|
||||
}, [checkAuth])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && opportunities.length === 0) {
|
||||
loadOpportunities()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
const loadOpportunities = async () => {
|
||||
try {
|
||||
const oppData = await api.getAuctionOpportunities()
|
||||
setOpportunities(oppData.opportunities || [])
|
||||
} catch (e) {
|
||||
console.error('Failed to load opportunities:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
const loadAuctions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [auctionsData, hotData, endingData] = await Promise.all([
|
||||
api.getAuctions(),
|
||||
const [all, ending, hot] = await Promise.all([
|
||||
api.getAuctions(undefined, undefined, undefined, undefined, undefined, false, 'ending', 100, 0),
|
||||
api.getEndingSoonAuctions(50),
|
||||
api.getHotAuctions(50),
|
||||
api.getEndingSoonAuctions(24, 50),
|
||||
])
|
||||
|
||||
setAllAuctions(auctionsData.auctions || [])
|
||||
setHotAuctions(hotData || [])
|
||||
setEndingSoon(endingData || [])
|
||||
|
||||
if (isAuthenticated) {
|
||||
await loadOpportunities()
|
||||
}
|
||||
setAllAuctions(all.auctions || [])
|
||||
setEndingSoon(ending || [])
|
||||
setHotAuctions(hot || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load auction data:', error)
|
||||
console.error('Failed to load auctions:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
await loadData()
|
||||
setRefreshing(false)
|
||||
}
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
const getCurrentAuctions = (): Auction[] => {
|
||||
switch (activeTab) {
|
||||
case 'ending': return endingSoon
|
||||
case 'hot': return hotAuctions
|
||||
case 'opportunities': return opportunities.map(o => o.auction)
|
||||
default: return allAuctions
|
||||
}
|
||||
}
|
||||
|
||||
const getOpportunityData = (domain: string) => {
|
||||
if (activeTab !== 'opportunities') return null
|
||||
return opportunities.find(o => o.auction.domain === domain)?.analysis
|
||||
}
|
||||
// Apply Vanity Filter for non-authenticated users (from analysis_1.md)
|
||||
// Shows only "beautiful" domains to visitors - no spam/trash
|
||||
const displayAuctions = useMemo(() => {
|
||||
const current = getCurrentAuctions()
|
||||
if (isAuthenticated) {
|
||||
// Authenticated users see all auctions
|
||||
return current
|
||||
}
|
||||
// Non-authenticated users only see "vanity" domains (clean, professional-looking)
|
||||
return current.filter(isVanityDomain)
|
||||
}, [activeTab, allAuctions, endingSoon, hotAuctions, isAuthenticated])
|
||||
|
||||
const filteredAuctions = getCurrentAuctions().filter(auction => {
|
||||
const filteredAuctions = displayAuctions.filter(auction => {
|
||||
if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
@ -215,45 +187,45 @@ export default function AuctionsPage() {
|
||||
return true
|
||||
})
|
||||
|
||||
const sortedAuctions = activeTab === 'opportunities'
|
||||
? filteredAuctions
|
||||
: [...filteredAuctions].sort((a, b) => {
|
||||
const mult = sortDirection === 'asc' ? 1 : -1
|
||||
switch (sortBy) {
|
||||
case 'ending':
|
||||
return mult * (new Date(a.end_time).getTime() - new Date(b.end_time).getTime())
|
||||
case 'bid_asc':
|
||||
case 'bid_desc':
|
||||
return mult * (a.current_bid - b.current_bid)
|
||||
case 'bids':
|
||||
return mult * (b.num_bids - a.num_bids)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
const getTimeColor = (timeRemaining: string) => {
|
||||
if (timeRemaining.includes('m') && !timeRemaining.includes('h') && !timeRemaining.includes('d')) {
|
||||
return 'text-danger'
|
||||
}
|
||||
if (timeRemaining.includes('h') && parseInt(timeRemaining) < 2) {
|
||||
return 'text-warning'
|
||||
}
|
||||
return 'text-foreground-muted'
|
||||
}
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortBy === field) {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortBy(field)
|
||||
setSortField(field)
|
||||
setSortDirection('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const sortedAuctions = [...filteredAuctions].sort((a, b) => {
|
||||
const modifier = sortDirection === 'asc' ? 1 : -1
|
||||
switch (sortField) {
|
||||
case 'domain':
|
||||
return a.domain.localeCompare(b.domain) * modifier
|
||||
case 'bid':
|
||||
return (a.current_bid - b.current_bid) * modifier
|
||||
case 'bids':
|
||||
return (a.num_bids - b.num_bids) * modifier
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
const formatCurrency = (amount: number, currency = 'USD') => {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
|
||||
}
|
||||
|
||||
const getTimeColor = (timeRemaining: string) => {
|
||||
if (timeRemaining.includes('m') && !timeRemaining.includes('h')) return 'text-red-400'
|
||||
if (timeRemaining.includes('h') && parseInt(timeRemaining) < 12) return 'text-amber-400'
|
||||
return 'text-foreground-muted'
|
||||
}
|
||||
|
||||
// Hot auctions preview for the hero section
|
||||
const hotPreview = hotAuctions.slice(0, 4)
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
@ -278,15 +250,26 @@ export default function AuctionsPage() {
|
||||
|
||||
<main className="relative pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6 flex-1">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
{/* Hero Header - centered like TLD pricing */}
|
||||
<div className="text-center mb-16 sm:mb-20 animate-fade-in">
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Auction Aggregator</span>
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Live Market</span>
|
||||
<h1 className="mt-4 font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] lg:text-[5rem] leading-[0.95] tracking-[-0.03em] text-foreground">
|
||||
{allAuctions.length}+ Live Auctions
|
||||
{/* Use "Live Feed" or "Curated Opportunities" if count is small (from report.md) */}
|
||||
{allAuctions.length >= 50
|
||||
? `${allAuctions.length}+ Live Auctions`
|
||||
: 'Live Auction Feed'}
|
||||
</h1>
|
||||
<p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
|
||||
Real-time from GoDaddy, Sedo, NameJet & DropCatch. Find opportunities.
|
||||
{isAuthenticated
|
||||
? 'All auctions from GoDaddy, Sedo, NameJet & DropCatch. Unfiltered.'
|
||||
: 'Curated opportunities from GoDaddy, Sedo, NameJet & DropCatch.'}
|
||||
</p>
|
||||
{!isAuthenticated && displayAuctions.length < allAuctions.length && (
|
||||
<p className="mt-2 text-sm text-accent flex items-center justify-center gap-1">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Showing {displayAuctions.length} premium domains • Sign in to see all {allAuctions.length}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Login Banner for non-authenticated users */}
|
||||
@ -294,12 +277,12 @@ export default function AuctionsPage() {
|
||||
<div className="mb-8 p-5 bg-accent-muted border border-accent/20 rounded-xl flex flex-col sm:flex-row items-center justify-between gap-4 animate-fade-in">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-accent/20 rounded-xl flex items-center justify-center">
|
||||
<Target className="w-5 h-5 text-accent" />
|
||||
<Lock className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-body-sm font-medium text-foreground">Unlock Smart Opportunities</p>
|
||||
<p className="text-ui-sm text-foreground-muted">
|
||||
Sign in for algorithmic deal-finding and alerts.
|
||||
Sign in for AI-powered analysis and personalized recommendations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -313,92 +296,47 @@ export default function AuctionsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs - flex-wrap to avoid horizontal scroll */}
|
||||
<div className="mb-6 animate-slide-up">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('all')}
|
||||
title="View all active auctions from all platforms"
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2.5 text-ui-sm font-medium rounded-lg transition-all",
|
||||
activeTab === 'all'
|
||||
? "bg-foreground text-background"
|
||||
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground hover:bg-background-secondary"
|
||||
)}
|
||||
{/* Hot Auctions Preview */}
|
||||
{hotPreview.length > 0 && (
|
||||
<div className="mb-12 sm:mb-16 animate-slide-up">
|
||||
<h2 className="text-body-lg sm:text-heading-sm font-medium text-foreground mb-4 sm:mb-6 flex items-center gap-2">
|
||||
<Flame className="w-5 h-5 text-accent" />
|
||||
Hot Right Now
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
{hotPreview.map((auction) => (
|
||||
<a
|
||||
key={`${auction.domain}-${auction.platform}`}
|
||||
href={auction.affiliate_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-4 sm:p-5 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover hover:bg-background-secondary transition-all duration-300 text-left group"
|
||||
>
|
||||
<Gavel className="w-4 h-4" />
|
||||
All
|
||||
<span className={clsx(
|
||||
"text-ui-xs px-1.5 py-0.5 rounded",
|
||||
activeTab === 'all' ? "bg-background/20" : "bg-foreground/10"
|
||||
)}>{allAuctions.length}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('ending')}
|
||||
title="Auctions ending in the next 24 hours - best for sniping"
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2.5 text-ui-sm font-medium rounded-lg transition-all",
|
||||
activeTab === 'ending'
|
||||
? "bg-warning text-background"
|
||||
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground hover:bg-background-secondary"
|
||||
)}
|
||||
>
|
||||
<Timer className="w-4 h-4" />
|
||||
Ending Soon
|
||||
<span className={clsx(
|
||||
"text-ui-xs px-1.5 py-0.5 rounded",
|
||||
activeTab === 'ending' ? "bg-background/20" : "bg-foreground/10"
|
||||
)}>{endingSoon.length}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('hot')}
|
||||
title="Auctions with 20+ bids - high demand, proven interest"
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2.5 text-ui-sm font-medium rounded-lg transition-all",
|
||||
activeTab === 'hot'
|
||||
? "bg-accent text-background"
|
||||
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground hover:bg-background-secondary"
|
||||
)}
|
||||
>
|
||||
<Flame className="w-4 h-4" />
|
||||
Hot
|
||||
<span className={clsx(
|
||||
"text-ui-xs px-1.5 py-0.5 rounded",
|
||||
activeTab === 'hot' ? "bg-background/20" : "bg-foreground/10"
|
||||
)}>{hotAuctions.length}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('opportunities')}
|
||||
title="Smart algorithm: Time urgency × Competition × Price = Score"
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2.5 text-ui-sm font-medium rounded-lg transition-all",
|
||||
activeTab === 'opportunities'
|
||||
? "bg-accent text-background"
|
||||
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground hover:bg-background-secondary"
|
||||
)}
|
||||
>
|
||||
<Target className="w-4 h-4" />
|
||||
Opportunities
|
||||
{!isAuthenticated && <Lock className="w-3 h-3 ml-1" />}
|
||||
{isAuthenticated && (
|
||||
<span className={clsx(
|
||||
"text-ui-xs px-1.5 py-0.5 rounded",
|
||||
activeTab === 'opportunities' ? "bg-background/20" : "bg-foreground/10"
|
||||
)}>{opportunities.length}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
title="Refresh auction data from all platforms"
|
||||
className="ml-auto flex items-center gap-2 px-4 py-2.5 text-ui-sm text-foreground-muted hover:text-foreground hover:bg-background-secondary/50 rounded-lg transition-all disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
||||
Refresh
|
||||
</button>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-mono text-body-lg sm:text-heading-sm text-foreground group-hover:text-accent transition-colors">
|
||||
{auction.domain}
|
||||
</span>
|
||||
<span className="text-ui-sm font-medium px-2 py-0.5 rounded-full text-accent bg-accent-muted flex items-center gap-1">
|
||||
<Flame className="w-3 h-3" />
|
||||
{auction.num_bids}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-body-sm text-foreground-muted">
|
||||
{formatCurrency(auction.current_bid)}
|
||||
</span>
|
||||
<span className={clsx("text-body-sm", getTimeColor(auction.time_remaining))}>
|
||||
{auction.time_remaining}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<PlatformBadge platform={auction.platform} />
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search & Filters */}
|
||||
<div className="mb-6 animate-slide-up">
|
||||
@ -427,55 +365,54 @@ export default function AuctionsPage() {
|
||||
<select
|
||||
value={selectedPlatform}
|
||||
onChange={(e) => setSelectedPlatform(e.target.value)}
|
||||
title="Filter by auction platform"
|
||||
className="px-4 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||
text-body text-foreground focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent cursor-pointer transition-all"
|
||||
text-body text-foreground cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
||||
>
|
||||
{PLATFORMS.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
|
||||
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Max bid"
|
||||
title="Filter auctions under this bid amount"
|
||||
value={maxBid}
|
||||
onChange={(e) => setMaxBid(e.target.value)}
|
||||
className="w-32 pl-11 pr-4 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||
className="w-32 pl-10 pr-4 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||
text-body text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all"
|
||||
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : activeTab === 'opportunities' && !isAuthenticated ? (
|
||||
<div className="text-center py-20 border border-dashed border-border rounded-2xl bg-background-secondary/20">
|
||||
<div className="w-14 h-14 bg-accent/10 rounded-2xl flex items-center justify-center mx-auto mb-5">
|
||||
<Target className="w-7 h-7 text-accent" />
|
||||
</div>
|
||||
<h3 className="text-body-lg font-medium text-foreground mb-2">Unlock Smart Opportunities</h3>
|
||||
<p className="text-body-sm text-foreground-muted max-w-md mx-auto mb-6">
|
||||
Our algorithm analyzes ending times, bid activity, and price points to find the best opportunities.
|
||||
</p>
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background text-ui font-medium rounded-xl
|
||||
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
|
||||
{/* Tabs */}
|
||||
<div className="flex flex-wrap gap-2 mb-6 animate-slide-up">
|
||||
{[
|
||||
{ id: 'all' as const, label: 'All Auctions', icon: Gavel, count: allAuctions.length },
|
||||
{ id: 'ending' as const, label: 'Ending Soon', icon: Timer, count: endingSoon.length },
|
||||
{ id: 'hot' as const, label: 'Hot', icon: Flame, count: hotAuctions.length },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2.5 text-ui-sm font-medium rounded-xl transition-all",
|
||||
activeTab === tab.id
|
||||
? "bg-accent text-background"
|
||||
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground hover:bg-background-secondary border border-border"
|
||||
)}
|
||||
>
|
||||
Get Started Free
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
</Link>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
<span className={clsx(
|
||||
"text-xs px-1.5 py-0.5 rounded",
|
||||
activeTab === tab.id ? "bg-background/20" : "bg-foreground/10"
|
||||
)}>{tab.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Table - using proper <table> like TLD Prices */
|
||||
|
||||
{/* Auctions Table */}
|
||||
<div className="bg-background-secondary/30 border border-border rounded-xl overflow-hidden animate-slide-up">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@ -483,12 +420,11 @@ export default function AuctionsPage() {
|
||||
<tr className="bg-background-secondary border-b border-border">
|
||||
<th className="text-left px-4 sm:px-6 py-4">
|
||||
<button
|
||||
onClick={() => handleSort('ending')}
|
||||
title="Sort by ending time"
|
||||
onClick={() => handleSort('domain')}
|
||||
className="flex items-center gap-2 text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
Domain
|
||||
<SortIcon field="ending" currentField={sortBy} direction={sortDirection} />
|
||||
<SortIcon field="domain" currentField={sortField} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left px-4 sm:px-6 py-4 hidden lg:table-cell">
|
||||
@ -496,192 +432,172 @@ export default function AuctionsPage() {
|
||||
</th>
|
||||
<th className="text-right px-4 sm:px-6 py-4">
|
||||
<button
|
||||
onClick={() => handleSort('bid_asc')}
|
||||
title="Current highest bid in USD"
|
||||
onClick={() => handleSort('bid')}
|
||||
className="flex items-center gap-2 ml-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
Bid
|
||||
<SortIcon field="bid_asc" currentField={sortBy} direction={sortDirection} />
|
||||
Current Bid
|
||||
<SortIcon field="bid" currentField={sortField} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-center px-4 sm:px-6 py-4 hidden md:table-cell">
|
||||
<span className="text-ui-sm text-foreground-subtle font-medium flex items-center justify-center gap-1">
|
||||
Deal Score
|
||||
{!isAuthenticated && <Lock className="w-3 h-3" />}
|
||||
</span>
|
||||
</th>
|
||||
<th className="text-right px-4 sm:px-6 py-4 hidden sm:table-cell">
|
||||
<button
|
||||
onClick={() => handleSort('bids')}
|
||||
title="Number of bids placed"
|
||||
className="flex items-center gap-2 ml-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
Bids
|
||||
<SortIcon field="bids" currentField={sortBy} direction={sortDirection} />
|
||||
<SortIcon field="bids" currentField={sortField} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-right px-4 sm:px-6 py-4 hidden md:table-cell">
|
||||
<span className="text-ui-sm text-foreground-subtle font-medium" title="Time remaining">Time Left</span>
|
||||
</th>
|
||||
{activeTab === 'opportunities' && (
|
||||
<th className="text-center px-4 sm:px-6 py-4">
|
||||
<span className="text-ui-sm text-foreground-subtle font-medium" title="Opportunity score">Score</span>
|
||||
<button
|
||||
onClick={() => handleSort('ending')}
|
||||
className="flex items-center gap-2 ml-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
Time Left
|
||||
<SortIcon field="ending" currentField={sortField} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
)}
|
||||
<th className="px-4 sm:px-6 py-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{sortedAuctions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={activeTab === 'opportunities' ? 7 : 6} className="px-6 py-12 text-center text-foreground-muted">
|
||||
{activeTab === 'opportunities'
|
||||
? 'No opportunities right now — check back later!'
|
||||
: searchQuery
|
||||
? `No auctions found matching "${searchQuery}"`
|
||||
: 'No auctions found'}
|
||||
{loading ? (
|
||||
// Loading skeleton
|
||||
Array.from({ length: 10 }).map((_, idx) => (
|
||||
<tr key={idx} className="animate-pulse">
|
||||
<td className="px-4 sm:px-6 py-4"><div className="h-4 w-32 bg-background-tertiary rounded" /></td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden lg:table-cell"><div className="h-4 w-20 bg-background-tertiary rounded" /></td>
|
||||
<td className="px-4 sm:px-6 py-4"><div className="h-4 w-16 bg-background-tertiary rounded ml-auto" /></td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden md:table-cell"><div className="h-8 w-8 bg-background-tertiary rounded mx-auto" /></td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden sm:table-cell"><div className="h-4 w-12 bg-background-tertiary rounded ml-auto" /></td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden md:table-cell"><div className="h-4 w-16 bg-background-tertiary rounded ml-auto" /></td>
|
||||
<td className="px-4 sm:px-6 py-4"><div className="h-8 w-16 bg-background-tertiary rounded ml-auto" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : sortedAuctions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center text-foreground-muted">
|
||||
{searchQuery ? `No auctions found matching "${searchQuery}"` : 'No auctions found'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedAuctions.map((auction, idx) => {
|
||||
const oppData = getOpportunityData(auction.domain)
|
||||
return (
|
||||
sortedAuctions.map((auction) => (
|
||||
<tr
|
||||
key={`${auction.domain}-${idx}`}
|
||||
key={`${auction.domain}-${auction.platform}`}
|
||||
className="hover:bg-background-secondary/50 transition-colors group"
|
||||
>
|
||||
{/* Domain */}
|
||||
<td className="px-4 sm:px-6 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div>
|
||||
<a
|
||||
href={auction.affiliate_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`Go to ${auction.platform} to bid on ${auction.domain}`}
|
||||
className="font-mono text-body-sm sm:text-body font-medium text-foreground hover:text-accent transition-colors"
|
||||
>
|
||||
{auction.domain}
|
||||
</a>
|
||||
<div className="flex items-center gap-2 text-body-xs text-foreground-subtle lg:hidden">
|
||||
<span className={clsx("text-ui-xs px-1.5 py-0.5 rounded", getPlatformBadgeClass(auction.platform))}>
|
||||
{auction.platform}
|
||||
</span>
|
||||
{auction.age_years && (
|
||||
<span title={`Domain age: ${auction.age_years} years`}>{auction.age_years}y</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1 lg:hidden">
|
||||
<PlatformBadge platform={auction.platform} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Platform */}
|
||||
<td className="px-4 sm:px-6 py-4 hidden lg:table-cell">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span
|
||||
className={clsx("text-ui-sm px-2 py-0.5 rounded-full w-fit", getPlatformBadgeClass(auction.platform))}
|
||||
title={`${auction.platform} - Click Bid to go to auction`}
|
||||
>
|
||||
{auction.platform}
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<PlatformBadge platform={auction.platform} />
|
||||
{auction.age_years && (
|
||||
<span className="text-body-xs text-foreground-subtle" title={`Domain age: ${auction.age_years} years`}>
|
||||
<Clock className="w-3 h-3 inline mr-1" />
|
||||
{auction.age_years}y
|
||||
<span className="text-ui-sm text-foreground-subtle flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" /> {auction.age_years}y
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Current Bid */}
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
<span
|
||||
className="text-body-sm font-medium text-foreground"
|
||||
title={`Current highest bid: ${formatCurrency(auction.current_bid)}`}
|
||||
>
|
||||
<div>
|
||||
<span className="text-body-sm font-medium text-foreground">
|
||||
{formatCurrency(auction.current_bid)}
|
||||
</span>
|
||||
{auction.buy_now_price && (
|
||||
<p className="text-ui-xs text-accent" title={`Buy Now for ${formatCurrency(auction.buy_now_price)}`}>
|
||||
Buy: {formatCurrency(auction.buy_now_price)}
|
||||
</p>
|
||||
<p className="text-ui-sm text-accent">Buy: {formatCurrency(auction.buy_now_price)}</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
{/* Deal Score Column - locked for non-authenticated users */}
|
||||
<td className="px-4 sm:px-6 py-4 text-center hidden md:table-cell">
|
||||
{isAuthenticated ? (
|
||||
<div className="inline-flex flex-col items-center">
|
||||
<span className={clsx(
|
||||
"inline-flex items-center justify-center w-9 h-9 rounded-lg font-bold text-sm",
|
||||
(getDealScore(auction) ?? 0) >= 75 ? "bg-accent/20 text-accent" :
|
||||
(getDealScore(auction) ?? 0) >= 50 ? "bg-amber-500/20 text-amber-400" :
|
||||
"bg-foreground/10 text-foreground-muted"
|
||||
)}>
|
||||
{getDealScore(auction)}
|
||||
</span>
|
||||
{(getDealScore(auction) ?? 0) >= 75 && (
|
||||
<span className="text-[10px] text-accent mt-0.5 font-medium">Undervalued</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href="/login?redirect=/auctions"
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded-lg bg-foreground/5 text-foreground-subtle
|
||||
hover:bg-accent/10 hover:text-accent transition-all group"
|
||||
title="Sign in to see Deal Score"
|
||||
>
|
||||
<Lock className="w-4 h-4 group-hover:scale-110 transition-transform" />
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Bids */}
|
||||
<td className="px-4 sm:px-6 py-4 text-right hidden sm:table-cell">
|
||||
<span
|
||||
className={clsx(
|
||||
"text-body-sm font-medium inline-flex items-center gap-1",
|
||||
auction.num_bids >= 20 ? "text-accent" :
|
||||
auction.num_bids >= 10 ? "text-warning" :
|
||||
"text-foreground-muted"
|
||||
)}
|
||||
title={`${auction.num_bids} bids - ${auction.num_bids >= 20 ? 'High competition!' : auction.num_bids >= 10 ? 'Moderate interest' : 'Low competition'}`}
|
||||
>
|
||||
<span className={clsx(
|
||||
"font-medium flex items-center justify-end gap-1",
|
||||
auction.num_bids >= 20 ? "text-accent" : auction.num_bids >= 10 ? "text-amber-400" : "text-foreground-muted"
|
||||
)}>
|
||||
{auction.num_bids}
|
||||
{auction.num_bids >= 20 && <Flame className="w-3 h-3" />}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Time Left */}
|
||||
<td className="px-4 sm:px-6 py-4 text-right hidden md:table-cell">
|
||||
<span
|
||||
className={clsx("text-body-sm font-medium", getTimeColor(auction.time_remaining))}
|
||||
title={`Auction ends: ${new Date(auction.end_time).toLocaleString()}`}
|
||||
>
|
||||
<span className={clsx("font-medium", getTimeColor(auction.time_remaining))}>
|
||||
{auction.time_remaining}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Score (opportunities only) */}
|
||||
{activeTab === 'opportunities' && oppData && (
|
||||
<td className="px-4 sm:px-6 py-4 text-center">
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-9 h-9 bg-accent/10 text-accent font-bold rounded-lg text-body-sm"
|
||||
title={`Score: ${oppData.opportunity_score}${oppData.reasoning ? ' - ' + oppData.reasoning : ''}`}
|
||||
>
|
||||
{oppData.opportunity_score}
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
|
||||
{/* Action */}
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
<td className="px-4 sm:px-6 py-4">
|
||||
<a
|
||||
href={auction.affiliate_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`Open ${auction.platform} to place your bid`}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-foreground text-background text-ui-sm font-medium rounded-lg
|
||||
hover:bg-foreground/90 transition-all opacity-70 group-hover:opacity-100"
|
||||
className="inline-flex items-center gap-1 text-ui-sm text-accent hover:text-accent-hover transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
Bid
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Footer */}
|
||||
<div className="mt-10 p-5 bg-background-secondary/30 border border-border rounded-xl animate-slide-up">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-foreground/5 rounded-xl flex items-center justify-center shrink-0">
|
||||
<Info className="w-5 h-5 text-foreground-muted" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-body font-medium text-foreground mb-1.5">
|
||||
{TAB_DESCRIPTIONS[activeTab].title}
|
||||
</h4>
|
||||
<p className="text-body-sm text-foreground-subtle leading-relaxed mb-3">
|
||||
{TAB_DESCRIPTIONS[activeTab].description}
|
||||
</p>
|
||||
<p className="text-body-sm text-foreground-subtle leading-relaxed">
|
||||
<span className="text-foreground-muted font-medium">Sources:</span> GoDaddy, Sedo, NameJet, DropCatch, ExpiredDomains.
|
||||
Click "Bid" to go to the auction — we don't handle transactions.
|
||||
</p>
|
||||
</div>
|
||||
{/* Stats */}
|
||||
{!loading && (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<p className="text-ui-sm text-foreground-subtle">
|
||||
{searchQuery
|
||||
? `Found ${sortedAuctions.length} auctions matching "${searchQuery}"`
|
||||
: `${allAuctions.length} auctions available across ${PLATFORMS.length - 1} platforms`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
@ -137,7 +137,7 @@ export default function BlogPostPage() {
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background rounded-xl font-medium hover:bg-accent-hover transition-all"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Blog
|
||||
Back to Briefings
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
@ -171,7 +171,7 @@ export default function BlogPostPage() {
|
||||
className="inline-flex items-center gap-2 text-foreground-muted hover:text-accent transition-colors mb-10 group"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
|
||||
<span className="text-sm font-medium">Back to Blog</span>
|
||||
<span className="text-sm font-medium">Back to Briefings</span>
|
||||
</Link>
|
||||
|
||||
{/* Hero Header */}
|
||||
@ -336,7 +336,7 @@ export default function BlogPostPage() {
|
||||
href="/register"
|
||||
className="inline-flex items-center justify-center gap-2 px-8 py-4 bg-accent text-background rounded-xl font-medium hover:bg-accent-hover transition-all"
|
||||
>
|
||||
Get Started Free
|
||||
Join the Hunt
|
||||
</Link>
|
||||
<Link
|
||||
href="/blog"
|
||||
|
||||
@ -109,7 +109,7 @@ export default function BlogPage() {
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Hero Header */}
|
||||
<div className="text-center mb-20 animate-fade-in">
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Domain Intelligence</span>
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Field Briefings</span>
|
||||
|
||||
<h1 className="mt-4 font-display text-[2.75rem] sm:text-[4rem] md:text-[5rem] leading-[0.95] tracking-[-0.03em] text-foreground mb-8">
|
||||
The Hunt<br />
|
||||
|
||||
460
frontend/src/app/buy/[slug]/page.tsx
Normal file
460
frontend/src/app/buy/[slug]/page.tsx
Normal file
@ -0,0 +1,460 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { api } from '@/lib/api'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import {
|
||||
Shield,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Mail,
|
||||
User,
|
||||
Building,
|
||||
Phone,
|
||||
MessageSquare,
|
||||
Send,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
Globe,
|
||||
Calendar,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface Listing {
|
||||
domain: string
|
||||
slug: string
|
||||
title: string | null
|
||||
description: string | null
|
||||
asking_price: number | null
|
||||
currency: string
|
||||
price_type: string
|
||||
pounce_score: number | null
|
||||
estimated_value: number | null
|
||||
is_verified: boolean
|
||||
allow_offers: boolean
|
||||
public_url: string
|
||||
seller_verified: boolean
|
||||
seller_member_since: string | null
|
||||
}
|
||||
|
||||
export default function BuyDomainPage() {
|
||||
const params = useParams()
|
||||
const slug = params.slug as string
|
||||
|
||||
const [listing, setListing] = useState<Listing | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Inquiry form state
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
company: '',
|
||||
message: '',
|
||||
offer_amount: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadListing()
|
||||
}, [slug])
|
||||
|
||||
const loadListing = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await api.request<Listing>(`/listings/${slug}`)
|
||||
setListing(data)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Listing not found')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
await api.request(`/listings/${slug}/inquire`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...formData,
|
||||
offer_amount: formData.offer_amount ? parseFloat(formData.offer_amount) : null,
|
||||
}),
|
||||
})
|
||||
setSubmitted(true)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to submit inquiry')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return 'text-accent'
|
||||
if (score >= 60) return 'text-amber-400'
|
||||
return 'text-foreground-muted'
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !listing) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="pt-32 pb-20 px-4">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<AlertCircle className="w-16 h-16 text-foreground-muted mx-auto mb-6" />
|
||||
<h1 className="text-2xl font-display text-foreground mb-4">Domain Not Available</h1>
|
||||
<p className="text-foreground-muted mb-8">
|
||||
This listing may have been sold, removed, or doesn't exist.
|
||||
</p>
|
||||
<Link
|
||||
href="/buy"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||
>
|
||||
Browse Listings
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="relative pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Domain Hero */}
|
||||
<div className="text-center mb-12 sm:mb-16 animate-fade-in">
|
||||
{listing.is_verified && (
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-accent/10 text-accent text-sm font-medium rounded-full mb-6">
|
||||
<Shield className="w-4 h-4" />
|
||||
Verified Owner
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="font-display text-[2.5rem] sm:text-[4rem] md:text-[5rem] lg:text-[6rem] leading-[0.95] tracking-[-0.03em] text-foreground mb-6">
|
||||
{listing.domain}
|
||||
</h1>
|
||||
|
||||
{listing.title && (
|
||||
<p className="text-xl sm:text-2xl text-foreground-muted max-w-2xl mx-auto mb-8">
|
||||
{listing.title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Price Badge */}
|
||||
<div className="inline-flex items-center gap-4 px-6 py-4 bg-background-secondary/50 border border-border rounded-2xl">
|
||||
{listing.asking_price ? (
|
||||
<>
|
||||
<span className="text-sm text-foreground-muted uppercase tracking-wider">
|
||||
{listing.price_type === 'fixed' ? 'Price' : 'Asking'}
|
||||
</span>
|
||||
<span className="text-3xl sm:text-4xl font-display text-foreground">
|
||||
{formatPrice(listing.asking_price, listing.currency)}
|
||||
</span>
|
||||
{listing.price_type === 'negotiable' && (
|
||||
<span className="text-sm text-accent bg-accent/10 px-2 py-1 rounded">
|
||||
Negotiable
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DollarSign className="w-6 h-6 text-accent" />
|
||||
<span className="text-2xl font-display text-foreground">Make an Offer</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
{/* Description */}
|
||||
{listing.description && (
|
||||
<div className="p-6 bg-background-secondary/30 border border-border rounded-2xl animate-slide-up">
|
||||
<h2 className="text-lg font-medium text-foreground mb-4 flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5 text-accent" />
|
||||
About This Domain
|
||||
</h2>
|
||||
<p className="text-foreground-muted whitespace-pre-line">
|
||||
{listing.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pounce Valuation */}
|
||||
{listing.pounce_score && listing.estimated_value && (
|
||||
<div className="p-6 bg-gradient-to-br from-accent/10 to-accent/5 border border-accent/20 rounded-2xl animate-slide-up">
|
||||
<h2 className="text-lg font-medium text-foreground mb-4 flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-accent" />
|
||||
Pounce Valuation
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-foreground-muted mb-1">Domain Score</p>
|
||||
<p className={clsx("text-4xl font-display", getScoreColor(listing.pounce_score))}>
|
||||
{listing.pounce_score}
|
||||
<span className="text-lg text-foreground-muted">/100</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-foreground-muted mb-1">Estimated Value</p>
|
||||
<p className="text-4xl font-display text-foreground">
|
||||
{formatPrice(listing.estimated_value, listing.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-foreground-subtle">
|
||||
Valuation based on domain length, TLD, keywords, and market data.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trust Indicators */}
|
||||
<div className="grid sm:grid-cols-3 gap-4 animate-slide-up">
|
||||
<div className="p-4 bg-background-secondary/30 border border-border rounded-xl flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-accent/10 rounded-lg flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{listing.is_verified ? 'Verified' : 'Pending'}
|
||||
</p>
|
||||
<p className="text-xs text-foreground-muted">Ownership</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-background-secondary/30 border border-border rounded-xl flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-foreground/5 rounded-lg flex items-center justify-center">
|
||||
<Globe className="w-5 h-5 text-foreground-muted" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
.{listing.domain.split('.').pop()}
|
||||
</p>
|
||||
<p className="text-xs text-foreground-muted">Extension</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{listing.seller_member_since && (
|
||||
<div className="p-4 bg-background-secondary/30 border border-border rounded-xl flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-foreground/5 rounded-lg flex items-center justify-center">
|
||||
<Calendar className="w-5 h-5 text-foreground-muted" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{new Date(listing.seller_member_since).getFullYear()}
|
||||
</p>
|
||||
<p className="text-xs text-foreground-muted">Member Since</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - Contact Form */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="sticky top-32 p-6 bg-background-secondary/30 border border-border rounded-2xl animate-slide-up">
|
||||
{submitted ? (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="w-16 h-16 text-accent mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">Inquiry Sent!</h3>
|
||||
<p className="text-sm text-foreground-muted">
|
||||
The seller will respond to your message directly.
|
||||
</p>
|
||||
</div>
|
||||
) : showForm ? (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">Contact Seller</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Name *</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Email *</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Phone</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
placeholder="+1 (555) 000-0000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Company</label>
|
||||
<div className="relative">
|
||||
<Building className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||
<input
|
||||
type="text"
|
||||
value={formData.company}
|
||||
onChange={(e) => setFormData({ ...formData, company: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
placeholder="Your company"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{listing.allow_offers && (
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Your Offer</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||
<input
|
||||
type="number"
|
||||
value={formData.offer_amount}
|
||||
onChange={(e) => setFormData({ ...formData, offer_amount: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
placeholder="Amount in USD"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Message *</label>
|
||||
<textarea
|
||||
required
|
||||
rows={4}
|
||||
value={formData.message}
|
||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent resize-none"
|
||||
placeholder="I'm interested in acquiring this domain..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all disabled:opacity-50"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-5 h-5" />
|
||||
Send Inquiry
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForm(false)}
|
||||
className="w-full text-sm text-foreground-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">Interested?</h3>
|
||||
<p className="text-sm text-foreground-muted mb-6">
|
||||
Contact the seller directly through Pounce.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||
>
|
||||
<Mail className="w-5 h-5" />
|
||||
Contact Seller
|
||||
</button>
|
||||
|
||||
{listing.allow_offers && listing.asking_price && (
|
||||
<p className="mt-4 text-xs text-foreground-subtle">
|
||||
Price is negotiable. Make an offer!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Powered by Pounce */}
|
||||
<div className="mt-16 text-center animate-fade-in">
|
||||
<p className="text-sm text-foreground-subtle flex items-center justify-center gap-2">
|
||||
<img src="/pounce_puma.png" alt="Pounce" className="w-5 h-5 opacity-50" />
|
||||
Marketplace powered by Pounce
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
304
frontend/src/app/buy/page.tsx
Normal file
304
frontend/src/app/buy/page.tsx
Normal file
@ -0,0 +1,304 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '@/lib/api'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import {
|
||||
Search,
|
||||
Shield,
|
||||
DollarSign,
|
||||
X,
|
||||
Lock,
|
||||
Sparkles,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface Listing {
|
||||
domain: string
|
||||
slug: string
|
||||
title: string | null
|
||||
description: string | null
|
||||
asking_price: number | null
|
||||
currency: string
|
||||
price_type: string
|
||||
pounce_score: number | null
|
||||
estimated_value: number | null
|
||||
is_verified: boolean
|
||||
allow_offers: boolean
|
||||
public_url: string
|
||||
seller_verified: boolean
|
||||
}
|
||||
|
||||
export default function BrowseListingsPage() {
|
||||
const [listings, setListings] = useState<Listing[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [minPrice, setMinPrice] = useState('')
|
||||
const [maxPrice, setMaxPrice] = useState('')
|
||||
const [verifiedOnly, setVerifiedOnly] = useState(false)
|
||||
const [sortBy, setSortBy] = useState<'newest' | 'price_asc' | 'price_desc' | 'popular'>('newest')
|
||||
|
||||
useEffect(() => {
|
||||
loadListings()
|
||||
}, [sortBy, verifiedOnly])
|
||||
|
||||
const loadListings = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('sort_by', sortBy)
|
||||
if (verifiedOnly) params.append('verified_only', 'true')
|
||||
params.append('limit', '50')
|
||||
|
||||
const data = await api.request<Listing[]>(`/listings?${params.toString()}`)
|
||||
setListings(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load listings:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredListings = listings.filter(listing => {
|
||||
if (searchQuery && !listing.domain.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
if (minPrice && listing.asking_price && listing.asking_price < parseFloat(minPrice)) {
|
||||
return false
|
||||
}
|
||||
if (maxPrice && listing.asking_price && listing.asking_price > parseFloat(maxPrice)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const formatPrice = (price: number | null, currency: string) => {
|
||||
if (!price) return 'Make Offer'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return 'text-accent bg-accent/10'
|
||||
if (score >= 60) return 'text-amber-400 bg-amber-500/10'
|
||||
return 'text-foreground-muted bg-foreground/5'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="relative pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Hero Header */}
|
||||
<div className="text-center mb-16 sm:mb-20 animate-fade-in">
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Marketplace</span>
|
||||
<h1 className="mt-4 font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] lg:text-[5rem] leading-[0.95] tracking-[-0.03em] text-foreground">
|
||||
Premium Domains. Direct.
|
||||
</h1>
|
||||
<p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
|
||||
Browse verified domains from trusted sellers. No middlemen, no hassle.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search & Filters */}
|
||||
<div className="mb-8 animate-slide-up">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search domains..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-12 pr-10 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||
text-body text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent
|
||||
transition-all duration-300"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 text-foreground-subtle hover:text-foreground"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Min"
|
||||
value={minPrice}
|
||||
onChange={(e) => setMinPrice(e.target.value)}
|
||||
className="w-24 pl-9 pr-2 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||
text-body text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Max"
|
||||
value={maxPrice}
|
||||
onChange={(e) => setMaxPrice(e.target.value)}
|
||||
className="w-24 pl-9 pr-2 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||
text-body text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setVerifiedOnly(!verifiedOnly)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-3 rounded-xl border transition-all",
|
||||
verifiedOnly
|
||||
? "bg-accent text-background border-accent"
|
||||
: "bg-background-secondary/50 text-foreground-muted border-border hover:border-accent"
|
||||
)}
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
Verified Only
|
||||
</button>
|
||||
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
|
||||
className="px-4 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||
text-body text-foreground cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
>
|
||||
<option value="newest">Newest First</option>
|
||||
<option value="price_asc">Price: Low to High</option>
|
||||
<option value="price_desc">Price: High to Low</option>
|
||||
<option value="popular">Most Viewed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Listings Grid */}
|
||||
{loading ? (
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, idx) => (
|
||||
<div key={idx} className="animate-pulse p-6 bg-background-secondary/30 border border-border rounded-2xl">
|
||||
<div className="h-8 w-40 bg-background-tertiary rounded mb-4" />
|
||||
<div className="h-4 w-24 bg-background-tertiary rounded mb-6" />
|
||||
<div className="h-10 w-full bg-background-tertiary rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredListings.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<Sparkles className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
|
||||
<h2 className="text-xl font-medium text-foreground mb-2">No Listings Found</h2>
|
||||
<p className="text-foreground-muted mb-8">
|
||||
{searchQuery
|
||||
? `No domains match "${searchQuery}"`
|
||||
: 'Be the first to list your domain!'}
|
||||
</p>
|
||||
<Link
|
||||
href="/command/listings"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||
>
|
||||
<Sparkles className="w-5 h-5" />
|
||||
List Your Domain
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 animate-slide-up">
|
||||
{filteredListings.map((listing) => (
|
||||
<Link
|
||||
key={listing.slug}
|
||||
href={`/buy/${listing.slug}`}
|
||||
className="group p-6 bg-background-secondary/30 border border-border rounded-2xl
|
||||
hover:border-accent/50 hover:bg-background-secondary/50 transition-all duration-300"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-mono text-lg font-medium text-foreground group-hover:text-accent transition-colors truncate">
|
||||
{listing.domain}
|
||||
</h3>
|
||||
{listing.title && (
|
||||
<p className="text-sm text-foreground-muted truncate mt-1">{listing.title}</p>
|
||||
)}
|
||||
</div>
|
||||
{listing.is_verified && (
|
||||
<div className="shrink-0 ml-2 w-8 h-8 bg-accent/10 rounded-lg flex items-center justify-center">
|
||||
<Shield className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score & Price */}
|
||||
<div className="flex items-end justify-between">
|
||||
{listing.pounce_score && (
|
||||
<div className={clsx("px-2 py-1 rounded text-sm font-medium", getScoreColor(listing.pounce_score))}>
|
||||
Score: {listing.pounce_score}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-right">
|
||||
<p className="text-xl font-display text-foreground">
|
||||
{formatPrice(listing.asking_price, listing.currency)}
|
||||
</p>
|
||||
{listing.price_type === 'negotiable' && (
|
||||
<p className="text-xs text-accent">Negotiable</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View CTA */}
|
||||
<div className="mt-4 pt-4 border-t border-border/50 flex items-center justify-between">
|
||||
<span className="text-sm text-foreground-muted flex items-center gap-1">
|
||||
<Eye className="w-3 h-3" />
|
||||
View Details
|
||||
</span>
|
||||
<ExternalLink className="w-4 h-4 text-foreground-subtle group-hover:text-accent transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA for Sellers */}
|
||||
<div className="mt-16 p-8 bg-gradient-to-r from-accent/10 to-accent/5 border border-accent/20 rounded-2xl text-center animate-slide-up">
|
||||
<Sparkles className="w-10 h-10 text-accent mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-display text-foreground mb-2">Got a domain to sell?</h2>
|
||||
<p className="text-foreground-muted mb-6 max-w-xl mx-auto">
|
||||
List your domain on Pounce and reach serious buyers.
|
||||
DNS verification ensures only real owners can list.
|
||||
</p>
|
||||
<Link
|
||||
href="/command/listings"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||
>
|
||||
List Your Domain
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
597
frontend/src/app/command/alerts/page.tsx
Normal file
597
frontend/src/app/command/alerts/page.tsx
Normal file
@ -0,0 +1,597 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import { PageContainer, StatCard, Badge, ActionButton } from '@/components/PremiumTable'
|
||||
import {
|
||||
Plus,
|
||||
Bell,
|
||||
Target,
|
||||
Zap,
|
||||
Loader2,
|
||||
Trash2,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
X,
|
||||
Play,
|
||||
Pause,
|
||||
Mail,
|
||||
Settings,
|
||||
TestTube,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface SniperAlert {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
tlds: string | null
|
||||
keywords: string | null
|
||||
exclude_keywords: string | null
|
||||
max_length: number | null
|
||||
min_length: number | null
|
||||
max_price: number | null
|
||||
min_price: number | null
|
||||
max_bids: number | null
|
||||
ending_within_hours: number | null
|
||||
platforms: string | null
|
||||
no_numbers: boolean
|
||||
no_hyphens: boolean
|
||||
exclude_chars: string | null
|
||||
notify_email: boolean
|
||||
notify_sms: boolean
|
||||
is_active: boolean
|
||||
matches_count: number
|
||||
notifications_sent: number
|
||||
last_matched_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
alert_name: string
|
||||
auctions_checked: number
|
||||
matches_found: number
|
||||
matches: Array<{
|
||||
domain: string
|
||||
platform: string
|
||||
current_bid: number
|
||||
num_bids: number
|
||||
end_time: string
|
||||
}>
|
||||
message: string
|
||||
}
|
||||
|
||||
export default function SniperAlertsPage() {
|
||||
const { subscription } = useStore()
|
||||
|
||||
const [alerts, setAlerts] = useState<SniperAlert[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [testing, setTesting] = useState<number | null>(null)
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null)
|
||||
const [expandedAlert, setExpandedAlert] = useState<number | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
// Create form
|
||||
const [newAlert, setNewAlert] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
tlds: '',
|
||||
keywords: '',
|
||||
exclude_keywords: '',
|
||||
max_length: '',
|
||||
min_length: '',
|
||||
max_price: '',
|
||||
min_price: '',
|
||||
max_bids: '',
|
||||
no_numbers: false,
|
||||
no_hyphens: false,
|
||||
exclude_chars: '',
|
||||
notify_email: true,
|
||||
})
|
||||
|
||||
const loadAlerts = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.request<SniperAlert[]>('/sniper-alerts')
|
||||
setAlerts(data)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadAlerts()
|
||||
}, [loadAlerts])
|
||||
|
||||
const handleCreate = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await api.request('/sniper-alerts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: newAlert.name,
|
||||
description: newAlert.description || null,
|
||||
tlds: newAlert.tlds || null,
|
||||
keywords: newAlert.keywords || null,
|
||||
exclude_keywords: newAlert.exclude_keywords || null,
|
||||
max_length: newAlert.max_length ? parseInt(newAlert.max_length) : null,
|
||||
min_length: newAlert.min_length ? parseInt(newAlert.min_length) : null,
|
||||
max_price: newAlert.max_price ? parseFloat(newAlert.max_price) : null,
|
||||
min_price: newAlert.min_price ? parseFloat(newAlert.min_price) : null,
|
||||
max_bids: newAlert.max_bids ? parseInt(newAlert.max_bids) : null,
|
||||
no_numbers: newAlert.no_numbers,
|
||||
no_hyphens: newAlert.no_hyphens,
|
||||
exclude_chars: newAlert.exclude_chars || null,
|
||||
notify_email: newAlert.notify_email,
|
||||
}),
|
||||
})
|
||||
setSuccess('Sniper Alert created!')
|
||||
setShowCreateModal(false)
|
||||
setNewAlert({
|
||||
name: '', description: '', tlds: '', keywords: '', exclude_keywords: '',
|
||||
max_length: '', min_length: '', max_price: '', min_price: '', max_bids: '',
|
||||
no_numbers: false, no_hyphens: false, exclude_chars: '', notify_email: true,
|
||||
})
|
||||
loadAlerts()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}, [newAlert, loadAlerts])
|
||||
|
||||
const handleToggle = useCallback(async (alert: SniperAlert) => {
|
||||
try {
|
||||
await api.request(`/sniper-alerts/${alert.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ is_active: !alert.is_active }),
|
||||
})
|
||||
loadAlerts()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
}
|
||||
}, [loadAlerts])
|
||||
|
||||
const handleDelete = useCallback(async (alert: SniperAlert) => {
|
||||
if (!confirm(`Delete alert "${alert.name}"?`)) return
|
||||
|
||||
try {
|
||||
await api.request(`/sniper-alerts/${alert.id}`, { method: 'DELETE' })
|
||||
setSuccess('Alert deleted')
|
||||
loadAlerts()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
}
|
||||
}, [loadAlerts])
|
||||
|
||||
const handleTest = useCallback(async (alert: SniperAlert) => {
|
||||
setTesting(alert.id)
|
||||
setTestResult(null)
|
||||
|
||||
try {
|
||||
const result = await api.request<TestResult>(`/sniper-alerts/${alert.id}/test`, {
|
||||
method: 'POST',
|
||||
})
|
||||
setTestResult(result)
|
||||
setExpandedAlert(alert.id)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setTesting(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Memoized stats
|
||||
const stats = useMemo(() => ({
|
||||
activeAlerts: alerts.filter(a => a.is_active).length,
|
||||
totalMatches: alerts.reduce((sum, a) => sum + a.matches_count, 0),
|
||||
notificationsSent: alerts.reduce((sum, a) => sum + a.notifications_sent, 0),
|
||||
}), [alerts])
|
||||
|
||||
const tier = subscription?.tier || 'scout'
|
||||
const limits = { scout: 2, trader: 10, tycoon: 50 }
|
||||
const maxAlerts = limits[tier as keyof typeof limits] || 2
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="Sniper Alerts"
|
||||
subtitle={`Hyper-personalized auction notifications (${alerts.length}/${maxAlerts})`}
|
||||
actions={
|
||||
<ActionButton onClick={() => setShowCreateModal(true)} disabled={alerts.length >= maxAlerts} icon={Plus}>
|
||||
New Alert
|
||||
</ActionButton>
|
||||
}
|
||||
>
|
||||
<PageContainer>
|
||||
{/* Messages */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400" />
|
||||
<p className="text-sm text-red-400 flex-1">{error}</p>
|
||||
<button onClick={() => setError(null)}><X className="w-4 h-4 text-red-400" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="p-4 bg-accent/10 border border-accent/20 rounded-xl flex items-center gap-3">
|
||||
<CheckCircle className="w-5 h-5 text-accent" />
|
||||
<p className="text-sm text-accent flex-1">{success}</p>
|
||||
<button onClick={() => setSuccess(null)}><X className="w-4 h-4 text-accent" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard title="Active Alerts" value={stats.activeAlerts} icon={Bell} />
|
||||
<StatCard title="Total Matches" value={stats.totalMatches} icon={Target} />
|
||||
<StatCard title="Notifications Sent" value={stats.notificationsSent} icon={Zap} />
|
||||
<StatCard title="Alert Slots" value={`${alerts.length}/${maxAlerts}`} icon={Settings} />
|
||||
</div>
|
||||
|
||||
{/* Alerts List */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||
</div>
|
||||
) : alerts.length === 0 ? (
|
||||
<div className="text-center py-16 bg-background-secondary/30 border border-border rounded-2xl">
|
||||
<Target className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
|
||||
<h2 className="text-xl font-medium text-foreground mb-2">No Sniper Alerts</h2>
|
||||
<p className="text-foreground-muted mb-6 max-w-md mx-auto">
|
||||
Create alerts to get notified when domains matching your criteria appear in auctions.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Create Alert
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="bg-background-secondary/30 border border-border rounded-2xl overflow-hidden transition-all hover:border-border-hover"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-5">
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-medium text-foreground">{alert.name}</h3>
|
||||
<Badge variant={alert.is_active ? 'success' : 'default'}>
|
||||
{alert.is_active ? 'Active' : 'Paused'}
|
||||
</Badge>
|
||||
</div>
|
||||
{alert.description && (
|
||||
<p className="text-sm text-foreground-muted">{alert.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold text-foreground">{alert.matches_count}</p>
|
||||
<p className="text-xs text-foreground-muted">Matches</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold text-foreground">{alert.notifications_sent}</p>
|
||||
<p className="text-xs text-foreground-muted">Notified</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleTest(alert)}
|
||||
disabled={testing === alert.id}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-foreground/5 text-foreground-muted text-sm font-medium rounded-lg hover:bg-foreground/10 transition-all"
|
||||
>
|
||||
{testing === alert.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<TestTube className="w-4 h-4" />
|
||||
)}
|
||||
Test
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleToggle(alert)}
|
||||
className={clsx(
|
||||
"flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-all",
|
||||
alert.is_active
|
||||
? "bg-amber-500/10 text-amber-400 hover:bg-amber-500/20"
|
||||
: "bg-accent/10 text-accent hover:bg-accent/20"
|
||||
)}
|
||||
>
|
||||
{alert.is_active ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
{alert.is_active ? 'Pause' : 'Activate'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(alert)}
|
||||
className="p-2 text-foreground-subtle hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setExpandedAlert(expandedAlert === alert.id ? null : alert.id)}
|
||||
className="p-2 text-foreground-subtle hover:text-foreground transition-colors"
|
||||
>
|
||||
{expandedAlert === alert.id ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Summary */}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{alert.tlds && (
|
||||
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
|
||||
TLDs: {alert.tlds}
|
||||
</span>
|
||||
)}
|
||||
{alert.max_length && (
|
||||
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
|
||||
Max {alert.max_length} chars
|
||||
</span>
|
||||
)}
|
||||
{alert.max_price && (
|
||||
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
|
||||
Max ${alert.max_price}
|
||||
</span>
|
||||
)}
|
||||
{alert.no_numbers && (
|
||||
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
|
||||
No numbers
|
||||
</span>
|
||||
)}
|
||||
{alert.no_hyphens && (
|
||||
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
|
||||
No hyphens
|
||||
</span>
|
||||
)}
|
||||
{alert.notify_email && (
|
||||
<span className="px-2 py-1 bg-accent/10 text-accent text-xs rounded flex items-center gap-1">
|
||||
<Mail className="w-3 h-3" /> Email
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Results */}
|
||||
{expandedAlert === alert.id && testResult && testResult.alert_name === alert.name && (
|
||||
<div className="px-5 pb-5">
|
||||
<div className="p-4 bg-background rounded-xl border border-border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm font-medium text-foreground">Test Results</p>
|
||||
<p className="text-xs text-foreground-muted">
|
||||
Checked {testResult.auctions_checked} auctions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{testResult.matches_found === 0 ? (
|
||||
<p className="text-sm text-foreground-muted">{testResult.message}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-accent">
|
||||
Found {testResult.matches_found} matching domains!
|
||||
</p>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1">
|
||||
{testResult.matches.map((match, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between text-sm py-1">
|
||||
<span className="font-mono text-foreground">{match.domain}</span>
|
||||
<span className="text-foreground-muted">${match.current_bid}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm overflow-y-auto">
|
||||
<div className="w-full max-w-lg bg-background-secondary border border-border rounded-2xl p-6 my-8">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-2">Create Sniper Alert</h2>
|
||||
<p className="text-sm text-foreground-muted mb-6">
|
||||
Get notified when domains matching your criteria appear in auctions.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleCreate} className="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Alert Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={newAlert.name}
|
||||
onChange={(e) => setNewAlert({ ...newAlert, name: e.target.value })}
|
||||
placeholder="4-letter .com without numbers"
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newAlert.description}
|
||||
onChange={(e) => setNewAlert({ ...newAlert, description: e.target.value })}
|
||||
placeholder="Optional description"
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">TLDs (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newAlert.tlds}
|
||||
onChange={(e) => setNewAlert({ ...newAlert, tlds: e.target.value })}
|
||||
placeholder="com,io,ai"
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Keywords (must contain)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newAlert.keywords}
|
||||
onChange={(e) => setNewAlert({ ...newAlert, keywords: e.target.value })}
|
||||
placeholder="ai,tech,crypto"
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Min Length</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="63"
|
||||
value={newAlert.min_length}
|
||||
onChange={(e) => setNewAlert({ ...newAlert, min_length: e.target.value })}
|
||||
placeholder="3"
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Max Length</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="63"
|
||||
value={newAlert.max_length}
|
||||
onChange={(e) => setNewAlert({ ...newAlert, max_length: e.target.value })}
|
||||
placeholder="6"
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Max Price ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={newAlert.max_price}
|
||||
onChange={(e) => setNewAlert({ ...newAlert, max_price: e.target.value })}
|
||||
placeholder="500"
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Max Bids (low competition)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={newAlert.max_bids}
|
||||
onChange={(e) => setNewAlert({ ...newAlert, max_bids: e.target.value })}
|
||||
placeholder="5"
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Exclude Characters</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newAlert.exclude_chars}
|
||||
onChange={(e) => setNewAlert({ ...newAlert, exclude_chars: e.target.value })}
|
||||
placeholder="q,x,z"
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newAlert.no_numbers}
|
||||
onChange={(e) => setNewAlert({ ...newAlert, no_numbers: e.target.checked })}
|
||||
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
|
||||
/>
|
||||
<span className="text-sm text-foreground">No numbers</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newAlert.no_hyphens}
|
||||
onChange={(e) => setNewAlert({ ...newAlert, no_hyphens: e.target.checked })}
|
||||
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
|
||||
/>
|
||||
<span className="text-sm text-foreground">No hyphens</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newAlert.notify_email}
|
||||
onChange={(e) => setNewAlert({ ...newAlert, notify_email: e.target.checked })}
|
||||
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
|
||||
/>
|
||||
<span className="text-sm text-foreground flex items-center gap-1">
|
||||
<Mail className="w-4 h-4" /> Email alerts
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="flex-1 px-4 py-3 border border-border text-foreground-muted rounded-xl hover:bg-foreground/5 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !newAlert.name}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all disabled:opacity-50"
|
||||
>
|
||||
{creating ? <Loader2 className="w-5 h-5 animate-spin" /> : <Target className="w-5 h-5" />}
|
||||
{creating ? 'Creating...' : 'Create Alert'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
578
frontend/src/app/command/auctions/page.tsx
Normal file
578
frontend/src/app/command/auctions/page.tsx
Normal file
@ -0,0 +1,578 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import {
|
||||
PremiumTable,
|
||||
Badge,
|
||||
PlatformBadge,
|
||||
StatCard,
|
||||
PageContainer,
|
||||
SearchInput,
|
||||
TabBar,
|
||||
FilterBar,
|
||||
SelectDropdown,
|
||||
ActionButton,
|
||||
} from '@/components/PremiumTable'
|
||||
import {
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Flame,
|
||||
Timer,
|
||||
Gavel,
|
||||
DollarSign,
|
||||
RefreshCw,
|
||||
Target,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Eye,
|
||||
Zap,
|
||||
Crown,
|
||||
Plus,
|
||||
Check,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface Auction {
|
||||
domain: string
|
||||
platform: string
|
||||
platform_url: string
|
||||
current_bid: number
|
||||
currency: string
|
||||
num_bids: number
|
||||
end_time: string
|
||||
time_remaining: string
|
||||
buy_now_price: number | null
|
||||
reserve_met: boolean | null
|
||||
traffic: number | null
|
||||
age_years: number | null
|
||||
tld: string
|
||||
affiliate_url: string
|
||||
}
|
||||
|
||||
interface Opportunity {
|
||||
auction: Auction
|
||||
analysis: {
|
||||
opportunity_score: number
|
||||
urgency?: string
|
||||
competition?: string
|
||||
price_range?: string
|
||||
recommendation: string
|
||||
reasoning?: string
|
||||
}
|
||||
}
|
||||
|
||||
type TabType = 'all' | 'ending' | 'hot' | 'opportunities'
|
||||
type SortField = 'ending' | 'bid_asc' | 'bid_desc' | 'bids' | 'score'
|
||||
type FilterPreset = 'all' | 'no-trash' | 'short' | 'high-value' | 'low-competition'
|
||||
|
||||
const PLATFORMS = [
|
||||
{ value: 'All', label: 'All Sources' },
|
||||
{ value: 'GoDaddy', label: 'GoDaddy' },
|
||||
{ value: 'Sedo', label: 'Sedo' },
|
||||
{ value: 'NameJet', label: 'NameJet' },
|
||||
{ value: 'DropCatch', label: 'DropCatch' },
|
||||
]
|
||||
|
||||
const FILTER_PRESETS: { id: FilterPreset, label: string, icon: typeof Gavel, description: string, proOnly?: boolean }[] = [
|
||||
{ id: 'all', label: 'All', icon: Gavel, description: 'Show all auctions' },
|
||||
{ id: 'no-trash', label: 'No Trash', icon: Sparkles, description: 'Clean domains only (no spam)', proOnly: true },
|
||||
{ id: 'short', label: 'Short', icon: Zap, description: '4-letter domains or less' },
|
||||
{ id: 'high-value', label: 'High Value', icon: Crown, description: 'Premium TLDs with high activity', proOnly: true },
|
||||
{ id: 'low-competition', label: 'Low Competition', icon: Target, description: 'Under 5 bids' },
|
||||
]
|
||||
|
||||
const PREMIUM_TLDS = ['com', 'io', 'ai', 'co', 'net', 'org', 'app', 'dev']
|
||||
|
||||
// Pure functions (no hooks needed)
|
||||
function isCleanDomain(auction: Auction): boolean {
|
||||
const name = auction.domain.split('.')[0]
|
||||
if (name.includes('-')) return false
|
||||
if (name.length > 4 && /\d/.test(name)) return false
|
||||
if (name.length > 12) return false
|
||||
if (!PREMIUM_TLDS.includes(auction.tld)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function calculateDealScore(auction: Auction): number {
|
||||
let score = 50
|
||||
const name = auction.domain.split('.')[0]
|
||||
if (name.length <= 4) score += 25
|
||||
else if (name.length <= 6) score += 15
|
||||
else if (name.length <= 8) score += 5
|
||||
if (['com', 'io', 'ai'].includes(auction.tld)) score += 15
|
||||
else if (['co', 'net', 'org'].includes(auction.tld)) score += 5
|
||||
if (auction.age_years && auction.age_years > 10) score += 15
|
||||
else if (auction.age_years && auction.age_years > 5) score += 10
|
||||
if (auction.num_bids >= 20) score += 10
|
||||
else if (auction.num_bids >= 10) score += 5
|
||||
if (isCleanDomain(auction)) score += 10
|
||||
return Math.min(score, 100)
|
||||
}
|
||||
|
||||
function getTimeColor(timeRemaining: string): string {
|
||||
if (timeRemaining.includes('m') && !timeRemaining.includes('h') && !timeRemaining.includes('d')) return 'text-red-400'
|
||||
if (timeRemaining.includes('h') && parseInt(timeRemaining) < 2) return 'text-amber-400'
|
||||
return 'text-foreground-muted'
|
||||
}
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
export default function AuctionsPage() {
|
||||
const { isAuthenticated, subscription } = useStore()
|
||||
|
||||
const [allAuctions, setAllAuctions] = useState<Auction[]>([])
|
||||
const [endingSoon, setEndingSoon] = useState<Auction[]>([])
|
||||
const [hotAuctions, setHotAuctions] = useState<Auction[]>([])
|
||||
const [opportunities, setOpportunities] = useState<Opportunity[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all')
|
||||
const [sortBy, setSortBy] = useState<SortField>('ending')
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
|
||||
|
||||
// Filters
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedPlatform, setSelectedPlatform] = useState('All')
|
||||
const [maxBid, setMaxBid] = useState('')
|
||||
const [filterPreset, setFilterPreset] = useState<FilterPreset>('all')
|
||||
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set())
|
||||
const [trackingInProgress, setTrackingInProgress] = useState<string | null>(null)
|
||||
|
||||
const isPaidUser = subscription?.tier === 'trader' || subscription?.tier === 'tycoon'
|
||||
|
||||
// Data loading
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [auctionsData, hotData, endingData] = await Promise.all([
|
||||
api.getAuctions(),
|
||||
api.getHotAuctions(50),
|
||||
api.getEndingSoonAuctions(24, 50),
|
||||
])
|
||||
|
||||
setAllAuctions(auctionsData.auctions || [])
|
||||
setHotAuctions(hotData || [])
|
||||
setEndingSoon(endingData || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load auction data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadOpportunities = useCallback(async () => {
|
||||
try {
|
||||
const oppData = await api.getAuctionOpportunities()
|
||||
setOpportunities(oppData.opportunities || [])
|
||||
} catch (e) {
|
||||
console.error('Failed to load opportunities:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && opportunities.length === 0) {
|
||||
loadOpportunities()
|
||||
}
|
||||
}, [isAuthenticated, opportunities.length, loadOpportunities])
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await loadData()
|
||||
if (isAuthenticated) await loadOpportunities()
|
||||
setRefreshing(false)
|
||||
}, [loadData, loadOpportunities, isAuthenticated])
|
||||
|
||||
const handleTrackDomain = useCallback(async (domain: string) => {
|
||||
if (trackedDomains.has(domain)) return
|
||||
|
||||
setTrackingInProgress(domain)
|
||||
try {
|
||||
await api.addDomainToWatchlist({ domain })
|
||||
setTrackedDomains(prev => new Set([...prev, domain]))
|
||||
} catch (error) {
|
||||
console.error('Failed to track domain:', error)
|
||||
} finally {
|
||||
setTrackingInProgress(null)
|
||||
}
|
||||
}, [trackedDomains])
|
||||
|
||||
const handleSort = useCallback((field: string) => {
|
||||
const f = field as SortField
|
||||
if (sortBy === f) {
|
||||
setSortDirection(d => d === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortBy(f)
|
||||
setSortDirection('asc')
|
||||
}
|
||||
}, [sortBy])
|
||||
|
||||
// Memoized tabs
|
||||
const tabs = useMemo(() => [
|
||||
{ id: 'all', label: 'All', icon: Gavel, count: allAuctions.length },
|
||||
{ id: 'ending', label: 'Ending Soon', icon: Timer, count: endingSoon.length, color: 'warning' as const },
|
||||
{ id: 'hot', label: 'Hot', icon: Flame, count: hotAuctions.length },
|
||||
{ id: 'opportunities', label: 'Opportunities', icon: Target, count: opportunities.length },
|
||||
], [allAuctions.length, endingSoon.length, hotAuctions.length, opportunities.length])
|
||||
|
||||
// Filter and sort auctions
|
||||
const sortedAuctions = useMemo(() => {
|
||||
// Get base auctions for current tab
|
||||
let auctions: Auction[] = []
|
||||
switch (activeTab) {
|
||||
case 'ending': auctions = [...endingSoon]; break
|
||||
case 'hot': auctions = [...hotAuctions]; break
|
||||
case 'opportunities': auctions = opportunities.map(o => o.auction); break
|
||||
default: auctions = [...allAuctions]
|
||||
}
|
||||
|
||||
// Apply preset filter
|
||||
const baseFilter = filterPreset === 'all' && isPaidUser ? 'no-trash' : filterPreset
|
||||
switch (baseFilter) {
|
||||
case 'no-trash': auctions = auctions.filter(isCleanDomain); break
|
||||
case 'short': auctions = auctions.filter(a => a.domain.split('.')[0].length <= 4); break
|
||||
case 'high-value': auctions = auctions.filter(a =>
|
||||
PREMIUM_TLDS.slice(0, 3).includes(a.tld) && a.num_bids >= 5 && calculateDealScore(a) >= 70
|
||||
); break
|
||||
case 'low-competition': auctions = auctions.filter(a => a.num_bids < 5); break
|
||||
}
|
||||
|
||||
// Apply search
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
auctions = auctions.filter(a => a.domain.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
// Apply platform filter
|
||||
if (selectedPlatform !== 'All') {
|
||||
auctions = auctions.filter(a => a.platform === selectedPlatform)
|
||||
}
|
||||
|
||||
// Apply max bid
|
||||
if (maxBid) {
|
||||
const max = parseFloat(maxBid)
|
||||
auctions = auctions.filter(a => a.current_bid <= max)
|
||||
}
|
||||
|
||||
// Sort (skip for opportunities - already sorted by score)
|
||||
if (activeTab !== 'opportunities') {
|
||||
const mult = sortDirection === 'asc' ? 1 : -1
|
||||
auctions.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'ending': return mult * (new Date(a.end_time).getTime() - new Date(b.end_time).getTime())
|
||||
case 'bid_asc':
|
||||
case 'bid_desc': return mult * (a.current_bid - b.current_bid)
|
||||
case 'bids': return mult * (b.num_bids - a.num_bids)
|
||||
case 'score': return mult * (calculateDealScore(b) - calculateDealScore(a))
|
||||
default: return 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return auctions
|
||||
}, [activeTab, allAuctions, endingSoon, hotAuctions, opportunities, filterPreset, isPaidUser, searchQuery, selectedPlatform, maxBid, sortBy, sortDirection])
|
||||
|
||||
// Subtitle
|
||||
const subtitle = useMemo(() => {
|
||||
if (loading) return 'Loading live auctions...'
|
||||
const total = allAuctions.length
|
||||
if (total === 0) return 'No active auctions found'
|
||||
return `${sortedAuctions.length.toLocaleString()} auctions ${sortedAuctions.length < total ? `(filtered from ${total.toLocaleString()})` : 'across 4 platforms'}`
|
||||
}, [loading, allAuctions.length, sortedAuctions.length])
|
||||
|
||||
// Get opportunity data helper
|
||||
const getOpportunityData = useCallback((domain: string) => {
|
||||
if (activeTab !== 'opportunities') return null
|
||||
return opportunities.find(o => o.auction.domain === domain)?.analysis
|
||||
}, [activeTab, opportunities])
|
||||
|
||||
// Table columns - memoized
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
key: 'domain',
|
||||
header: 'Domain',
|
||||
sortable: true,
|
||||
render: (a: Auction) => (
|
||||
<div>
|
||||
<a
|
||||
href={a.affiliate_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono font-medium text-foreground hover:text-accent transition-colors"
|
||||
>
|
||||
{a.domain}
|
||||
</a>
|
||||
<div className="flex items-center gap-2 mt-1 lg:hidden">
|
||||
<PlatformBadge platform={a.platform} />
|
||||
{a.age_years && <span className="text-xs text-foreground-subtle">{a.age_years}y</span>}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'platform',
|
||||
header: 'Platform',
|
||||
hideOnMobile: true,
|
||||
render: (a: Auction) => (
|
||||
<div className="space-y-1">
|
||||
<PlatformBadge platform={a.platform} />
|
||||
{a.age_years && (
|
||||
<span className="text-xs text-foreground-subtle flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" /> {a.age_years}y
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bid_asc',
|
||||
header: 'Bid',
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
render: (a: Auction) => (
|
||||
<div>
|
||||
<span className="font-medium text-foreground">{formatCurrency(a.current_bid)}</span>
|
||||
{a.buy_now_price && (
|
||||
<p className="text-xs text-accent">Buy: {formatCurrency(a.buy_now_price)}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'score',
|
||||
header: 'Deal Score',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
hideOnMobile: true,
|
||||
render: (a: Auction) => {
|
||||
if (activeTab === 'opportunities') {
|
||||
const oppData = getOpportunityData(a.domain)
|
||||
if (oppData) {
|
||||
return (
|
||||
<span className="inline-flex items-center justify-center w-9 h-9 bg-accent/10 text-accent font-bold rounded-lg">
|
||||
{oppData.opportunity_score}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPaidUser) {
|
||||
return (
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="inline-flex items-center justify-center w-9 h-9 bg-foreground/5 text-foreground-subtle rounded-lg hover:bg-accent/10 hover:text-accent transition-all"
|
||||
title="Upgrade to see Deal Score"
|
||||
>
|
||||
<Crown className="w-4 h-4" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const score = calculateDealScore(a)
|
||||
return (
|
||||
<div className="inline-flex flex-col items-center">
|
||||
<span className={clsx(
|
||||
"inline-flex items-center justify-center w-9 h-9 rounded-lg font-bold text-sm",
|
||||
score >= 75 ? "bg-accent/20 text-accent" :
|
||||
score >= 50 ? "bg-amber-500/20 text-amber-400" :
|
||||
"bg-foreground/10 text-foreground-muted"
|
||||
)}>
|
||||
{score}
|
||||
</span>
|
||||
{score >= 75 && <span className="text-[10px] text-accent mt-0.5 font-medium">Undervalued</span>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'bids',
|
||||
header: 'Bids',
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
hideOnMobile: true,
|
||||
render: (a: Auction) => (
|
||||
<span className={clsx(
|
||||
"font-medium flex items-center justify-end gap-1",
|
||||
a.num_bids >= 20 ? "text-accent" : a.num_bids >= 10 ? "text-amber-400" : "text-foreground-muted"
|
||||
)}>
|
||||
{a.num_bids}
|
||||
{a.num_bids >= 20 && <Flame className="w-3 h-3" />}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ending',
|
||||
header: 'Time Left',
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
hideOnMobile: true,
|
||||
render: (a: Auction) => (
|
||||
<span className={clsx("font-medium", getTimeColor(a.time_remaining))}>
|
||||
{a.time_remaining}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right' as const,
|
||||
render: (a: Auction) => (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); handleTrackDomain(a.domain) }}
|
||||
disabled={trackedDomains.has(a.domain) || trackingInProgress === a.domain}
|
||||
className={clsx(
|
||||
"inline-flex items-center justify-center w-8 h-8 rounded-lg transition-all",
|
||||
trackedDomains.has(a.domain)
|
||||
? "bg-accent/20 text-accent cursor-default"
|
||||
: "bg-foreground/5 text-foreground-subtle hover:bg-accent/10 hover:text-accent"
|
||||
)}
|
||||
title={trackedDomains.has(a.domain) ? 'Already tracked' : 'Add to Watchlist'}
|
||||
>
|
||||
{trackingInProgress === a.domain ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : trackedDomains.has(a.domain) ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href={a.affiliate_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-foreground text-background text-xs font-medium rounded-lg hover:bg-foreground/90 transition-all"
|
||||
>
|
||||
Bid <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], [activeTab, isPaidUser, trackedDomains, trackingInProgress, handleTrackDomain, getOpportunityData])
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="Auctions"
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
<ActionButton onClick={handleRefresh} disabled={refreshing} variant="ghost" icon={refreshing ? Loader2 : RefreshCw}>
|
||||
{refreshing ? '' : 'Refresh'}
|
||||
</ActionButton>
|
||||
}
|
||||
>
|
||||
<PageContainer>
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard title="All Auctions" value={allAuctions.length} icon={Gavel} />
|
||||
<StatCard title="Ending Soon" value={endingSoon.length} icon={Timer} />
|
||||
<StatCard title="Hot Auctions" value={hotAuctions.length} subtitle="20+ bids" icon={Flame} />
|
||||
<StatCard title="Opportunities" value={opportunities.length} icon={Target} />
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<TabBar tabs={tabs} activeTab={activeTab} onChange={(id) => setActiveTab(id as TabType)} />
|
||||
|
||||
{/* Smart Filter Presets */}
|
||||
<div className="flex flex-wrap gap-2 p-1.5 bg-background-secondary/30 border border-border/30 rounded-xl">
|
||||
{FILTER_PRESETS.map((preset) => {
|
||||
const isDisabled = preset.proOnly && !isPaidUser
|
||||
const isActive = filterPreset === preset.id
|
||||
const Icon = preset.icon
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => !isDisabled && setFilterPreset(preset.id)}
|
||||
disabled={isDisabled}
|
||||
title={isDisabled ? 'Upgrade to Trader to use this filter' : preset.description}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg transition-all",
|
||||
isActive
|
||||
? "bg-accent text-background shadow-md"
|
||||
: isDisabled
|
||||
? "text-foreground-subtle opacity-50 cursor-not-allowed"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{preset.label}</span>
|
||||
{preset.proOnly && !isPaidUser && <Crown className="w-3 h-3 text-amber-400" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tier notification for Scout users */}
|
||||
{!isPaidUser && (
|
||||
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-500/20 rounded-xl flex items-center justify-center shrink-0">
|
||||
<Eye className="w-5 h-5 text-amber-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-foreground">You're seeing the raw auction feed</p>
|
||||
<p className="text-xs text-foreground-muted">
|
||||
Upgrade to Trader for spam-free listings, Deal Scores, and Smart Filters.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="shrink-0 px-4 py-2 bg-accent text-background text-sm font-medium rounded-lg hover:bg-accent-hover transition-all"
|
||||
>
|
||||
Upgrade
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<FilterBar>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search domains..."
|
||||
className="flex-1 min-w-[200px] max-w-md"
|
||||
/>
|
||||
<SelectDropdown value={selectedPlatform} onChange={setSelectedPlatform} options={PLATFORMS} />
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted" />
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Max bid"
|
||||
value={maxBid}
|
||||
onChange={(e) => setMaxBid(e.target.value)}
|
||||
className="w-28 h-10 pl-9 pr-3 bg-background-secondary/50 border border-border/40 rounded-xl
|
||||
text-sm text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:border-accent/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{/* Table */}
|
||||
<PremiumTable
|
||||
data={sortedAuctions}
|
||||
keyExtractor={(a) => `${a.domain}-${a.platform}`}
|
||||
loading={loading}
|
||||
sortBy={sortBy}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
emptyIcon={<Gavel className="w-12 h-12 text-foreground-subtle" />}
|
||||
emptyTitle={searchQuery ? `No auctions matching "${searchQuery}"` : "No auctions found"}
|
||||
emptyDescription="Try adjusting your filters or check back later"
|
||||
columns={columns}
|
||||
/>
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
402
frontend/src/app/command/dashboard/page.tsx
Normal file
402
frontend/src/app/command/dashboard/page.tsx
Normal file
@ -0,0 +1,402 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import { PremiumTable, StatCard, PageContainer, Badge, SectionHeader, SearchInput, ActionButton } from '@/components/PremiumTable'
|
||||
import { Toast, useToast } from '@/components/Toast'
|
||||
import {
|
||||
Eye,
|
||||
Briefcase,
|
||||
TrendingUp,
|
||||
Gavel,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Sparkles,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Zap,
|
||||
Crown,
|
||||
Activity,
|
||||
Loader2,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface HotAuction {
|
||||
domain: string
|
||||
current_bid: number
|
||||
time_remaining: string
|
||||
platform: string
|
||||
affiliate_url?: string
|
||||
}
|
||||
|
||||
interface TrendingTld {
|
||||
tld: string
|
||||
current_price: number
|
||||
price_change: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
user,
|
||||
domains,
|
||||
subscription
|
||||
} = useStore()
|
||||
|
||||
const { toast, showToast, hideToast } = useToast()
|
||||
const [hotAuctions, setHotAuctions] = useState<HotAuction[]>([])
|
||||
const [trendingTlds, setTrendingTlds] = useState<TrendingTld[]>([])
|
||||
const [loadingAuctions, setLoadingAuctions] = useState(true)
|
||||
const [loadingTlds, setLoadingTlds] = useState(true)
|
||||
const [quickDomain, setQuickDomain] = useState('')
|
||||
const [addingDomain, setAddingDomain] = useState(false)
|
||||
|
||||
// Check for upgrade success
|
||||
useEffect(() => {
|
||||
if (searchParams.get('upgraded') === 'true') {
|
||||
showToast('Welcome to your upgraded plan! 🎉', 'success')
|
||||
window.history.replaceState({}, '', '/command/dashboard')
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const loadDashboardData = useCallback(async () => {
|
||||
try {
|
||||
const [auctions, trending] = await Promise.all([
|
||||
api.getEndingSoonAuctions(5).catch(() => []),
|
||||
api.getTrendingTlds().catch(() => ({ trending: [] }))
|
||||
])
|
||||
setHotAuctions(auctions.slice(0, 5))
|
||||
setTrendingTlds(trending.trending?.slice(0, 4) || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error)
|
||||
} finally {
|
||||
setLoadingAuctions(false)
|
||||
setLoadingTlds(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load dashboard data
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadDashboardData()
|
||||
}
|
||||
}, [isAuthenticated, loadDashboardData])
|
||||
|
||||
const handleQuickAdd = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!quickDomain.trim()) return
|
||||
|
||||
setAddingDomain(true)
|
||||
try {
|
||||
const store = useStore.getState()
|
||||
await store.addDomain(quickDomain.trim())
|
||||
setQuickDomain('')
|
||||
showToast(`Added ${quickDomain.trim()} to watchlist`, 'success')
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Failed to add domain', 'error')
|
||||
} finally {
|
||||
setAddingDomain(false)
|
||||
}
|
||||
}, [quickDomain, showToast])
|
||||
|
||||
// Memoized computed values
|
||||
const { availableDomains, totalDomains, tierName, TierIcon, greeting, subtitle } = useMemo(() => {
|
||||
const availableDomains = domains?.filter(d => d.is_available) || []
|
||||
const totalDomains = domains?.length || 0
|
||||
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||
const TierIcon = tierName === 'Tycoon' ? Crown : tierName === 'Trader' ? TrendingUp : Zap
|
||||
|
||||
const hour = new Date().getHours()
|
||||
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening'
|
||||
|
||||
let subtitle = ''
|
||||
if (availableDomains.length > 0) {
|
||||
subtitle = `${availableDomains.length} domain${availableDomains.length !== 1 ? 's' : ''} ready to pounce!`
|
||||
} else if (totalDomains > 0) {
|
||||
subtitle = `Monitoring ${totalDomains} domain${totalDomains !== 1 ? 's' : ''} for you`
|
||||
} else {
|
||||
subtitle = 'Start tracking domains to find opportunities'
|
||||
}
|
||||
|
||||
return { availableDomains, totalDomains, tierName, TierIcon, greeting, subtitle }
|
||||
}, [domains, subscription])
|
||||
|
||||
if (isLoading || !isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title={`${greeting}${user?.name ? `, ${user.name.split(' ')[0]}` : ''}`}
|
||||
subtitle={subtitle}
|
||||
>
|
||||
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
||||
|
||||
<PageContainer>
|
||||
{/* Quick Add */}
|
||||
<div className="relative p-5 sm:p-6 bg-gradient-to-r from-accent/10 via-accent/5 to-transparent border border-accent/20 rounded-2xl overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
||||
<div className="relative">
|
||||
<h2 className="text-base font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-accent/20 rounded-lg flex items-center justify-center">
|
||||
<Search className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
Quick Add to Watchlist
|
||||
</h2>
|
||||
<form onSubmit={handleQuickAdd} className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||
<input
|
||||
type="text"
|
||||
value={quickDomain}
|
||||
onChange={(e) => setQuickDomain(e.target.value)}
|
||||
placeholder="Enter domain to track (e.g., dream.com)"
|
||||
className="w-full h-11 pl-11 pr-4 bg-background/80 backdrop-blur-sm border border-border/50 rounded-xl
|
||||
text-sm text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addingDomain || !quickDomain.trim()}
|
||||
className="flex items-center justify-center gap-2 h-11 px-6 bg-gradient-to-r from-accent to-accent/80 text-background rounded-xl
|
||||
font-medium hover:shadow-[0_0_20px_-5px_rgba(16,185,129,0.4)] transition-all
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Link href="/command/watchlist" className="group">
|
||||
<StatCard
|
||||
title="Domains Watched"
|
||||
value={totalDomains}
|
||||
icon={Eye}
|
||||
/>
|
||||
</Link>
|
||||
<Link href="/command/watchlist?filter=available" className="group">
|
||||
<StatCard
|
||||
title="Available Now"
|
||||
value={availableDomains.length}
|
||||
icon={Sparkles}
|
||||
accent={availableDomains.length > 0}
|
||||
/>
|
||||
</Link>
|
||||
<Link href="/command/portfolio" className="group">
|
||||
<StatCard
|
||||
title="Portfolio"
|
||||
value={0}
|
||||
icon={Briefcase}
|
||||
/>
|
||||
</Link>
|
||||
<StatCard
|
||||
title="Plan"
|
||||
value={tierName}
|
||||
subtitle={`${subscription?.domains_used || 0}/${subscription?.domain_limit || 5} slots`}
|
||||
icon={TierIcon}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Activity Feed + Market Pulse */}
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
{/* Activity Feed */}
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm">
|
||||
<div className="p-5 border-b border-border/30">
|
||||
<SectionHeader
|
||||
title="Activity Feed"
|
||||
icon={Activity}
|
||||
compact
|
||||
action={
|
||||
<Link href="/command/watchlist" className="text-sm text-accent hover:text-accent/80 transition-colors">
|
||||
View all →
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{availableDomains.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{availableDomains.slice(0, 4).map((domain) => (
|
||||
<div
|
||||
key={domain.id}
|
||||
className="flex items-center gap-4 p-3 bg-accent/5 border border-accent/20 rounded-xl"
|
||||
>
|
||||
<div className="relative">
|
||||
<span className="w-3 h-3 bg-accent rounded-full block" />
|
||||
<span className="absolute inset-0 bg-accent rounded-full animate-ping opacity-50" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{domain.name}</p>
|
||||
<p className="text-xs text-accent">Available for registration!</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${domain.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-medium text-accent hover:underline flex items-center gap-1"
|
||||
>
|
||||
Register <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
{availableDomains.length > 4 && (
|
||||
<p className="text-center text-sm text-foreground-muted">
|
||||
+{availableDomains.length - 4} more available
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : totalDomains > 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Bell className="w-10 h-10 text-foreground-subtle mx-auto mb-3" />
|
||||
<p className="text-foreground-muted">All domains are still registered</p>
|
||||
<p className="text-sm text-foreground-subtle mt-1">
|
||||
We're monitoring {totalDomains} domains for you
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Plus className="w-10 h-10 text-foreground-subtle mx-auto mb-3" />
|
||||
<p className="text-foreground-muted">No domains tracked yet</p>
|
||||
<p className="text-sm text-foreground-subtle mt-1">
|
||||
Add a domain above to start monitoring
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Market Pulse */}
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm">
|
||||
<div className="p-5 border-b border-border/30">
|
||||
<SectionHeader
|
||||
title="Market Pulse"
|
||||
icon={Gavel}
|
||||
compact
|
||||
action={
|
||||
<Link href="/command/auctions" className="text-sm text-accent hover:text-accent/80 transition-colors">
|
||||
View all →
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{loadingAuctions ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-14 bg-foreground/5 rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : hotAuctions.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{hotAuctions.map((auction, idx) => (
|
||||
<a
|
||||
key={`${auction.domain}-${idx}`}
|
||||
href={auction.affiliate_url || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 p-3 bg-foreground/5 rounded-xl
|
||||
hover:bg-foreground/10 transition-colors group"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{auction.domain}</p>
|
||||
<p className="text-xs text-foreground-muted flex items-center gap-2">
|
||||
<Clock className="w-3 h-3" />
|
||||
{auction.time_remaining}
|
||||
<span className="text-foreground-subtle">• {auction.platform}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-foreground">${auction.current_bid}</p>
|
||||
<p className="text-xs text-foreground-subtle">current bid</p>
|
||||
</div>
|
||||
<ExternalLink className="w-4 h-4 text-foreground-subtle group-hover:text-foreground" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Gavel className="w-10 h-10 text-foreground-subtle mx-auto mb-3" />
|
||||
<p className="text-foreground-muted">No auctions ending soon</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trending TLDs */}
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm">
|
||||
<div className="p-5 border-b border-border/30">
|
||||
<SectionHeader
|
||||
title="Trending TLDs"
|
||||
icon={TrendingUp}
|
||||
compact
|
||||
action={
|
||||
<Link href="/command/pricing" className="text-sm text-accent hover:text-accent/80 transition-colors">
|
||||
View all →
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{loadingTlds ? (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-24 bg-foreground/5 rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : trendingTlds.length > 0 ? (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{trendingTlds.map((tld) => (
|
||||
<Link
|
||||
key={tld.tld}
|
||||
href={`/tld-pricing/${tld.tld}`}
|
||||
className="group relative p-4 bg-foreground/5 border border-border/30 rounded-xl
|
||||
hover:border-accent/30 transition-all duration-300 overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0 bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-mono text-2xl font-semibold text-foreground group-hover:text-accent transition-colors">.{tld.tld}</span>
|
||||
<span className={clsx(
|
||||
"text-xs font-bold px-2.5 py-1 rounded-lg border",
|
||||
(tld.price_change || 0) > 0
|
||||
? "text-orange-400 bg-orange-400/10 border-orange-400/20"
|
||||
: "text-accent bg-accent/10 border-accent/20"
|
||||
)}>
|
||||
{(tld.price_change || 0) > 0 ? '+' : ''}{(tld.price_change || 0).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-muted truncate">{tld.reason}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<TrendingUp className="w-10 h-10 text-foreground-subtle mx-auto mb-3" />
|
||||
<p className="text-foreground-muted">No trending TLDs available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
582
frontend/src/app/command/listings/page.tsx
Executable file
582
frontend/src/app/command/listings/page.tsx
Executable file
@ -0,0 +1,582 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import { PageContainer, StatCard, Badge, ActionButton } from '@/components/PremiumTable'
|
||||
import {
|
||||
Plus,
|
||||
Shield,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Trash2,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
DollarSign,
|
||||
X,
|
||||
Tag,
|
||||
Store,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface Listing {
|
||||
id: number
|
||||
domain: string
|
||||
slug: string
|
||||
title: string | null
|
||||
description: string | null
|
||||
asking_price: number | null
|
||||
min_offer: number | null
|
||||
currency: string
|
||||
price_type: string
|
||||
pounce_score: number | null
|
||||
estimated_value: number | null
|
||||
verification_status: string
|
||||
is_verified: boolean
|
||||
status: string
|
||||
show_valuation: boolean
|
||||
allow_offers: boolean
|
||||
view_count: number
|
||||
inquiry_count: number
|
||||
public_url: string
|
||||
created_at: string
|
||||
published_at: string | null
|
||||
}
|
||||
|
||||
interface VerificationInfo {
|
||||
verification_code: string
|
||||
dns_record_type: string
|
||||
dns_record_name: string
|
||||
dns_record_value: string
|
||||
instructions: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export default function MyListingsPage() {
|
||||
const { subscription } = useStore()
|
||||
const searchParams = useSearchParams()
|
||||
const prefillDomain = searchParams.get('domain')
|
||||
|
||||
const [listings, setListings] = useState<Listing[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Modals - auto-open if domain is prefilled
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showVerifyModal, setShowVerifyModal] = useState(false)
|
||||
const [selectedListing, setSelectedListing] = useState<Listing | null>(null)
|
||||
const [verificationInfo, setVerificationInfo] = useState<VerificationInfo | null>(null)
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
// Create form
|
||||
const [newListing, setNewListing] = useState({
|
||||
domain: '',
|
||||
title: '',
|
||||
description: '',
|
||||
asking_price: '',
|
||||
price_type: 'negotiable',
|
||||
allow_offers: true,
|
||||
})
|
||||
|
||||
const loadListings = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.request<Listing[]>('/listings/my')
|
||||
setListings(data)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load listings:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadListings()
|
||||
}, [loadListings])
|
||||
|
||||
// Auto-open create modal if domain is prefilled from portfolio
|
||||
useEffect(() => {
|
||||
if (prefillDomain) {
|
||||
setNewListing(prev => ({ ...prev, domain: prefillDomain }))
|
||||
setShowCreateModal(true)
|
||||
}
|
||||
}, [prefillDomain])
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await api.request('/listings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
domain: newListing.domain,
|
||||
title: newListing.title || null,
|
||||
description: newListing.description || null,
|
||||
asking_price: newListing.asking_price ? parseFloat(newListing.asking_price) : null,
|
||||
price_type: newListing.price_type,
|
||||
allow_offers: newListing.allow_offers,
|
||||
}),
|
||||
})
|
||||
setSuccess('Listing created! Now verify ownership to publish.')
|
||||
setShowCreateModal(false)
|
||||
setNewListing({ domain: '', title: '', description: '', asking_price: '', price_type: 'negotiable', allow_offers: true })
|
||||
loadListings()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartVerification = async (listing: Listing) => {
|
||||
setSelectedListing(listing)
|
||||
setVerifying(true)
|
||||
|
||||
try {
|
||||
const info = await api.request<VerificationInfo>(`/listings/${listing.id}/verify-dns`, {
|
||||
method: 'POST',
|
||||
})
|
||||
setVerificationInfo(info)
|
||||
setShowVerifyModal(true)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setVerifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckVerification = async () => {
|
||||
if (!selectedListing) return
|
||||
setVerifying(true)
|
||||
|
||||
try {
|
||||
const result = await api.request<{ verified: boolean; message: string }>(
|
||||
`/listings/${selectedListing.id}/verify-dns/check`
|
||||
)
|
||||
|
||||
if (result.verified) {
|
||||
setSuccess('Domain verified! You can now publish your listing.')
|
||||
setShowVerifyModal(false)
|
||||
loadListings()
|
||||
} else {
|
||||
setError(result.message)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setVerifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePublish = async (listing: Listing) => {
|
||||
try {
|
||||
await api.request(`/listings/${listing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: 'active' }),
|
||||
})
|
||||
setSuccess('Listing published!')
|
||||
loadListings()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (listing: Listing) => {
|
||||
if (!confirm(`Delete listing for ${listing.domain}?`)) return
|
||||
|
||||
try {
|
||||
await api.request(`/listings/${listing.id}`, { method: 'DELETE' })
|
||||
setSuccess('Listing deleted')
|
||||
loadListings()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setSuccess('Copied to clipboard!')
|
||||
}
|
||||
|
||||
const formatPrice = (price: number | null, currency: string) => {
|
||||
if (!price) return 'Make Offer'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string, isVerified: boolean) => {
|
||||
if (status === 'active') return <Badge variant="success">Live</Badge>
|
||||
if (status === 'draft' && !isVerified) return <Badge variant="warning">Needs Verification</Badge>
|
||||
if (status === 'draft') return <Badge>Draft</Badge>
|
||||
if (status === 'sold') return <Badge variant="accent">Sold</Badge>
|
||||
return <Badge>{status}</Badge>
|
||||
}
|
||||
|
||||
const tier = subscription?.tier || 'scout'
|
||||
const limits = { scout: 2, trader: 10, tycoon: 50 }
|
||||
const maxListings = limits[tier as keyof typeof limits] || 2
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="My Listings"
|
||||
subtitle={`Manage your domains for sale • ${listings.length}/${maxListings} slots used`}
|
||||
actions={
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/buy"
|
||||
className="flex items-center gap-2 px-4 py-2 text-foreground-muted text-sm font-medium
|
||||
border border-border rounded-lg hover:bg-foreground/5 transition-all"
|
||||
>
|
||||
<Store className="w-4 h-4" />
|
||||
Browse Marketplace
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
disabled={listings.length >= maxListings}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-background text-sm font-medium rounded-lg
|
||||
hover:bg-accent-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
List Domain
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PageContainer>
|
||||
{/* Messages */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400" />
|
||||
<p className="text-sm text-red-400 flex-1">{error}</p>
|
||||
<button onClick={() => setError(null)}><X className="w-4 h-4 text-red-400" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="p-4 bg-accent/10 border border-accent/20 rounded-xl flex items-center gap-3">
|
||||
<CheckCircle className="w-5 h-5 text-accent" />
|
||||
<p className="text-sm text-accent flex-1">{success}</p>
|
||||
<button onClick={() => setSuccess(null)}><X className="w-4 h-4 text-accent" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard title="My Listings" value={`${listings.length}/${maxListings}`} icon={Tag} />
|
||||
<StatCard
|
||||
title="Published"
|
||||
value={listings.filter(l => l.status === 'active').length}
|
||||
icon={CheckCircle}
|
||||
accent
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Views"
|
||||
value={listings.reduce((sum, l) => sum + l.view_count, 0)}
|
||||
icon={Eye}
|
||||
/>
|
||||
<StatCard
|
||||
title="Inquiries"
|
||||
value={listings.reduce((sum, l) => sum + l.inquiry_count, 0)}
|
||||
icon={MessageSquare}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Listings */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||
</div>
|
||||
) : listings.length === 0 ? (
|
||||
<div className="text-center py-16 bg-background-secondary/30 border border-border rounded-2xl">
|
||||
<Sparkles className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
|
||||
<h2 className="text-xl font-medium text-foreground mb-2">No Listings Yet</h2>
|
||||
<p className="text-foreground-muted mb-6 max-w-md mx-auto">
|
||||
Create your first listing to sell a domain on the Pounce marketplace.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Create Listing
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{listings.map((listing) => (
|
||||
<div
|
||||
key={listing.id}
|
||||
className="p-5 bg-background-secondary/30 border border-border rounded-2xl hover:border-border-hover transition-all"
|
||||
>
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
{/* Domain Info */}
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="font-mono text-lg font-medium text-foreground">{listing.domain}</h3>
|
||||
{getStatusBadge(listing.status, listing.is_verified)}
|
||||
{listing.is_verified && (
|
||||
<div className="w-6 h-6 bg-accent/10 rounded flex items-center justify-center" title="Verified">
|
||||
<Shield className="w-3 h-3 text-accent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{listing.title && (
|
||||
<p className="text-sm text-foreground-muted">{listing.title}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="text-right">
|
||||
<p className="text-xl font-semibold text-foreground">
|
||||
{formatPrice(listing.asking_price, listing.currency)}
|
||||
</p>
|
||||
{listing.pounce_score && (
|
||||
<p className="text-xs text-foreground-muted">Score: {listing.pounce_score}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-sm text-foreground-muted">
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="w-4 h-4" /> {listing.view_count}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare className="w-4 h-4" /> {listing.inquiry_count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{!listing.is_verified && (
|
||||
<button
|
||||
onClick={() => handleStartVerification(listing)}
|
||||
disabled={verifying}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-amber-500/10 text-amber-400 text-sm font-medium rounded-lg hover:bg-amber-500/20 transition-all"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
Verify
|
||||
</button>
|
||||
)}
|
||||
|
||||
{listing.is_verified && listing.status === 'draft' && (
|
||||
<button
|
||||
onClick={() => handlePublish(listing)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-accent text-background text-sm font-medium rounded-lg hover:bg-accent-hover transition-all"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Publish
|
||||
</button>
|
||||
)}
|
||||
|
||||
{listing.status === 'active' && (
|
||||
<Link
|
||||
href={`/buy/${listing.slug}`}
|
||||
target="_blank"
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-foreground/5 text-foreground-muted text-sm font-medium rounded-lg hover:bg-foreground/10 transition-all"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
View
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(listing)}
|
||||
className="p-2 text-foreground-subtle hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg bg-background-secondary border border-border rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-6">List Domain for Sale</h2>
|
||||
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Domain *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={newListing.domain}
|
||||
onChange={(e) => setNewListing({ ...newListing, domain: e.target.value })}
|
||||
placeholder="example.com"
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Headline</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newListing.title}
|
||||
onChange={(e) => setNewListing({ ...newListing, title: e.target.value })}
|
||||
placeholder="Perfect for AI startups"
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Description</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={newListing.description}
|
||||
onChange={(e) => setNewListing({ ...newListing, description: e.target.value })}
|
||||
placeholder="Tell potential buyers about this domain..."
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Asking Price (USD)</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||
<input
|
||||
type="number"
|
||||
value={newListing.asking_price}
|
||||
onChange={(e) => setNewListing({ ...newListing, asking_price: e.target.value })}
|
||||
placeholder="Leave empty for 'Make Offer'"
|
||||
className="w-full pl-9 pr-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Price Type</label>
|
||||
<select
|
||||
value={newListing.price_type}
|
||||
onChange={(e) => setNewListing({ ...newListing, price_type: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="negotiable">Negotiable</option>
|
||||
<option value="fixed">Fixed Price</option>
|
||||
<option value="make_offer">Make Offer Only</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newListing.allow_offers}
|
||||
onChange={(e) => setNewListing({ ...newListing, allow_offers: e.target.checked })}
|
||||
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
|
||||
/>
|
||||
<span className="text-sm text-foreground">Allow buyers to make offers</span>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="flex-1 px-4 py-3 border border-border text-foreground-muted rounded-xl hover:bg-foreground/5 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all disabled:opacity-50"
|
||||
>
|
||||
{creating ? <Loader2 className="w-5 h-5 animate-spin" /> : <Plus className="w-5 h-5" />}
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verification Modal */}
|
||||
{showVerifyModal && verificationInfo && selectedListing && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-xl bg-background-secondary border border-border rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-2">Verify Domain Ownership</h2>
|
||||
<p className="text-sm text-foreground-muted mb-6">
|
||||
Add a DNS TXT record to prove you own <strong>{selectedListing.domain}</strong>
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-background rounded-xl border border-border">
|
||||
<p className="text-sm text-foreground-muted mb-2">Record Type</p>
|
||||
<p className="font-mono text-foreground">{verificationInfo.dns_record_type}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-background rounded-xl border border-border">
|
||||
<p className="text-sm text-foreground-muted mb-2">Name / Host</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-mono text-foreground">{verificationInfo.dns_record_name}</p>
|
||||
<button
|
||||
onClick={() => copyToClipboard(verificationInfo.dns_record_name)}
|
||||
className="p-2 text-foreground-subtle hover:text-accent transition-colors"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-background rounded-xl border border-border">
|
||||
<p className="text-sm text-foreground-muted mb-2">Value</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-mono text-sm text-foreground break-all">{verificationInfo.dns_record_value}</p>
|
||||
<button
|
||||
onClick={() => copyToClipboard(verificationInfo.dns_record_value)}
|
||||
className="p-2 text-foreground-subtle hover:text-accent transition-colors shrink-0"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-accent/5 border border-accent/20 rounded-xl">
|
||||
<p className="text-sm text-foreground-muted whitespace-pre-line">
|
||||
{verificationInfo.instructions}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setShowVerifyModal(false)}
|
||||
className="flex-1 px-4 py-3 border border-border text-foreground-muted rounded-xl hover:bg-foreground/5 transition-all"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCheckVerification}
|
||||
disabled={verifying}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all disabled:opacity-50"
|
||||
>
|
||||
{verifying ? <Loader2 className="w-5 h-5 animate-spin" /> : <RefreshCw className="w-5 h-5" />}
|
||||
{verifying ? 'Checking...' : 'Check Verification'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
302
frontend/src/app/command/marketplace/page.tsx
Normal file
302
frontend/src/app/command/marketplace/page.tsx
Normal file
@ -0,0 +1,302 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||
import { api } from '@/lib/api'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import {
|
||||
PageContainer,
|
||||
StatCard,
|
||||
Badge,
|
||||
SearchInput,
|
||||
FilterBar,
|
||||
SelectDropdown,
|
||||
ActionButton,
|
||||
} from '@/components/PremiumTable'
|
||||
import {
|
||||
Search,
|
||||
Shield,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
Store,
|
||||
Tag,
|
||||
DollarSign,
|
||||
Filter,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface Listing {
|
||||
domain: string
|
||||
slug: string
|
||||
title: string | null
|
||||
description: string | null
|
||||
asking_price: number | null
|
||||
currency: string
|
||||
price_type: string
|
||||
pounce_score: number | null
|
||||
estimated_value: number | null
|
||||
is_verified: boolean
|
||||
allow_offers: boolean
|
||||
public_url: string
|
||||
seller_verified: boolean
|
||||
}
|
||||
|
||||
type SortOption = 'newest' | 'price_asc' | 'price_desc' | 'score'
|
||||
|
||||
export default function CommandMarketplacePage() {
|
||||
const [listings, setListings] = useState<Listing[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [minPrice, setMinPrice] = useState('')
|
||||
const [maxPrice, setMaxPrice] = useState('')
|
||||
const [verifiedOnly, setVerifiedOnly] = useState(false)
|
||||
const [sortBy, setSortBy] = useState<SortOption>('newest')
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
|
||||
const loadListings = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('limit', '100')
|
||||
if (sortBy === 'price_asc') params.set('sort', 'price_asc')
|
||||
if (sortBy === 'price_desc') params.set('sort', 'price_desc')
|
||||
if (verifiedOnly) params.set('verified_only', 'true')
|
||||
|
||||
const data = await api.request<Listing[]>(`/listings?${params.toString()}`)
|
||||
setListings(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load listings:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [sortBy, verifiedOnly])
|
||||
|
||||
useEffect(() => {
|
||||
loadListings()
|
||||
}, [loadListings])
|
||||
|
||||
const formatPrice = (price: number | null, currency: string) => {
|
||||
if (!price) return 'Make Offer'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
// Memoized filtered and sorted listings
|
||||
const sortedListings = useMemo(() => {
|
||||
let result = listings.filter(listing => {
|
||||
if (searchQuery && !listing.domain.toLowerCase().includes(searchQuery.toLowerCase())) return false
|
||||
if (minPrice && listing.asking_price && listing.asking_price < parseFloat(minPrice)) return false
|
||||
if (maxPrice && listing.asking_price && listing.asking_price > parseFloat(maxPrice)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
return result.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'price_asc': return (a.asking_price || 0) - (b.asking_price || 0)
|
||||
case 'price_desc': return (b.asking_price || 0) - (a.asking_price || 0)
|
||||
case 'score': return (b.pounce_score || 0) - (a.pounce_score || 0)
|
||||
default: return 0
|
||||
}
|
||||
})
|
||||
}, [listings, searchQuery, minPrice, maxPrice, sortBy])
|
||||
|
||||
// Memoized stats
|
||||
const stats = useMemo(() => {
|
||||
const verifiedCount = listings.filter(l => l.is_verified).length
|
||||
const pricesWithValue = listings.filter(l => l.asking_price)
|
||||
const avgPrice = pricesWithValue.length > 0
|
||||
? pricesWithValue.reduce((sum, l) => sum + (l.asking_price || 0), 0) / pricesWithValue.length
|
||||
: 0
|
||||
return { verifiedCount, avgPrice }
|
||||
}, [listings])
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="Marketplace"
|
||||
subtitle={`${listings.length} premium domains for sale`}
|
||||
actions={
|
||||
<Link href="/command/listings">
|
||||
<ActionButton icon={Tag} variant="secondary">My Listings</ActionButton>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<PageContainer>
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard title="Total Listings" value={listings.length} icon={Store} />
|
||||
<StatCard title="Verified Sellers" value={stats.verifiedCount} icon={Shield} />
|
||||
<StatCard
|
||||
title="Avg. Price"
|
||||
value={stats.avgPrice > 0 ? `$${Math.round(stats.avgPrice).toLocaleString()}` : '—'}
|
||||
icon={DollarSign}
|
||||
/>
|
||||
<StatCard title="Results" value={sortedListings.length} icon={Search} />
|
||||
</div>
|
||||
|
||||
{/* Search & Filters */}
|
||||
<div className="p-4 bg-background-secondary/30 border border-border rounded-2xl space-y-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search domains..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3 bg-background border border-border rounded-xl
|
||||
text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||
className="px-4 py-3 bg-background border border-border rounded-xl text-foreground
|
||||
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
||||
>
|
||||
<option value="newest">Newest First</option>
|
||||
<option value="price_asc">Price: Low to High</option>
|
||||
<option value="price_desc">Price: High to Low</option>
|
||||
<option value="score">Pounce Score</option>
|
||||
</select>
|
||||
|
||||
{/* Filter Toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-3 border rounded-xl transition-all",
|
||||
showFilters
|
||||
? "bg-accent/10 border-accent/30 text-accent"
|
||||
: "bg-background border-border text-foreground-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded Filters */}
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap items-center gap-4 pt-3 border-t border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground-muted">Price:</span>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Min"
|
||||
value={minPrice}
|
||||
onChange={(e) => setMinPrice(e.target.value)}
|
||||
className="w-24 px-3 py-2 bg-background border border-border rounded-lg text-sm text-foreground
|
||||
placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<span className="text-foreground-subtle">—</span>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Max"
|
||||
value={maxPrice}
|
||||
onChange={(e) => setMaxPrice(e.target.value)}
|
||||
className="w-24 px-3 py-2 bg-background border border-border rounded-lg text-sm text-foreground
|
||||
placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={verifiedOnly}
|
||||
onChange={(e) => setVerifiedOnly(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border text-accent focus:ring-accent"
|
||||
/>
|
||||
<span className="text-sm text-foreground">Verified sellers only</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Listings Grid */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||
</div>
|
||||
) : sortedListings.length === 0 ? (
|
||||
<div className="text-center py-16 bg-background-secondary/30 border border-border rounded-2xl">
|
||||
<Store className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
|
||||
<h2 className="text-xl font-medium text-foreground mb-2">No Domains Found</h2>
|
||||
<p className="text-foreground-muted mb-6">
|
||||
{searchQuery || minPrice || maxPrice
|
||||
? 'Try adjusting your filters'
|
||||
: 'No domains are currently listed for sale'}
|
||||
</p>
|
||||
<Link
|
||||
href="/command/listings"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||
>
|
||||
<Tag className="w-5 h-5" />
|
||||
List Your Domain
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sortedListings.map((listing) => (
|
||||
<Link
|
||||
key={listing.slug}
|
||||
href={`/buy/${listing.slug}`}
|
||||
className="group p-5 bg-background-secondary/30 border border-border rounded-2xl
|
||||
hover:border-accent/50 hover:bg-background-secondary/50 transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-mono text-lg font-medium text-foreground group-hover:text-accent transition-colors truncate">
|
||||
{listing.domain}
|
||||
</h3>
|
||||
{listing.title && (
|
||||
<p className="text-sm text-foreground-muted truncate mt-1">{listing.title}</p>
|
||||
)}
|
||||
</div>
|
||||
{listing.is_verified && (
|
||||
<div className="shrink-0 ml-2 w-8 h-8 bg-accent/10 rounded-lg flex items-center justify-center" title="Verified Seller">
|
||||
<Shield className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{listing.description && (
|
||||
<p className="text-sm text-foreground-subtle line-clamp-2 mb-4">
|
||||
{listing.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{listing.pounce_score && (
|
||||
<div className="px-2 py-1 bg-accent/10 text-accent rounded text-sm font-medium">
|
||||
{listing.pounce_score}
|
||||
</div>
|
||||
)}
|
||||
{listing.allow_offers && (
|
||||
<Badge variant="accent">Offers</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xl font-semibold text-foreground">
|
||||
{formatPrice(listing.asking_price, listing.currency)}
|
||||
</p>
|
||||
{listing.price_type === 'negotiable' && (
|
||||
<p className="text-xs text-accent">Negotiable</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
19
frontend/src/app/command/page.tsx
Normal file
19
frontend/src/app/command/page.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function CommandPage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
router.replace('/command/dashboard')
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
955
frontend/src/app/command/portfolio/page.tsx
Normal file
955
frontend/src/app/command/portfolio/page.tsx
Normal file
@ -0,0 +1,955 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api, PortfolioDomain, PortfolioSummary, DomainValuation, DomainHealthReport, HealthStatus } from '@/lib/api'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import { PremiumTable, StatCard, PageContainer, ActionButton } from '@/components/PremiumTable'
|
||||
import { Toast, useToast } from '@/components/Toast'
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Edit2,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
Building,
|
||||
Loader2,
|
||||
ArrowUpRight,
|
||||
X,
|
||||
Briefcase,
|
||||
ShoppingCart,
|
||||
Activity,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
Tag,
|
||||
MoreVertical,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
|
||||
// Health status configuration
|
||||
const healthStatusConfig: Record<HealthStatus, {
|
||||
label: string
|
||||
color: string
|
||||
bgColor: string
|
||||
icon: typeof Activity
|
||||
}> = {
|
||||
healthy: { label: 'Healthy', color: 'text-accent', bgColor: 'bg-accent/10', icon: Activity },
|
||||
weakening: { label: 'Weak', color: 'text-amber-400', bgColor: 'bg-amber-400/10', icon: AlertTriangle },
|
||||
parked: { label: 'Parked', color: 'text-orange-400', bgColor: 'bg-orange-400/10', icon: ShoppingCart },
|
||||
critical: { label: 'Critical', color: 'text-red-400', bgColor: 'bg-red-400/10', icon: AlertTriangle },
|
||||
unknown: { label: 'Unknown', color: 'text-foreground-muted', bgColor: 'bg-foreground/5', icon: Activity },
|
||||
}
|
||||
|
||||
export default function PortfolioPage() {
|
||||
const { subscription } = useStore()
|
||||
const { toast, showToast, hideToast } = useToast()
|
||||
|
||||
const [portfolio, setPortfolio] = useState<PortfolioDomain[]>([])
|
||||
const [summary, setSummary] = useState<PortfolioSummary | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [showSellModal, setShowSellModal] = useState(false)
|
||||
const [showValuationModal, setShowValuationModal] = useState(false)
|
||||
const [selectedDomain, setSelectedDomain] = useState<PortfolioDomain | null>(null)
|
||||
const [valuation, setValuation] = useState<DomainValuation | null>(null)
|
||||
const [valuatingDomain, setValuatingDomain] = useState('')
|
||||
const [addingDomain, setAddingDomain] = useState(false)
|
||||
const [savingEdit, setSavingEdit] = useState(false)
|
||||
const [processingSale, setProcessingSale] = useState(false)
|
||||
const [refreshingId, setRefreshingId] = useState<number | null>(null)
|
||||
|
||||
// Health monitoring state
|
||||
const [healthReports, setHealthReports] = useState<Record<string, DomainHealthReport>>({})
|
||||
const [loadingHealth, setLoadingHealth] = useState<Record<string, boolean>>({})
|
||||
const [selectedHealthDomain, setSelectedHealthDomain] = useState<string | null>(null)
|
||||
|
||||
// Dropdown menu state
|
||||
const [openMenuId, setOpenMenuId] = useState<number | null>(null)
|
||||
|
||||
const [addForm, setAddForm] = useState({
|
||||
domain: '',
|
||||
purchase_price: '',
|
||||
purchase_date: '',
|
||||
registrar: '',
|
||||
renewal_date: '',
|
||||
renewal_cost: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const [editForm, setEditForm] = useState({
|
||||
purchase_price: '',
|
||||
purchase_date: '',
|
||||
registrar: '',
|
||||
renewal_date: '',
|
||||
renewal_cost: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const [sellForm, setSellForm] = useState({
|
||||
sale_date: new Date().toISOString().split('T')[0],
|
||||
sale_price: '',
|
||||
})
|
||||
|
||||
const loadPortfolio = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [portfolioData, summaryData] = await Promise.all([
|
||||
api.getPortfolio(),
|
||||
api.getPortfolioSummary(),
|
||||
])
|
||||
setPortfolio(portfolioData)
|
||||
setSummary(summaryData)
|
||||
} catch (error) {
|
||||
console.error('Failed to load portfolio:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadPortfolio()
|
||||
}, [loadPortfolio])
|
||||
|
||||
const handleAddDomain = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!addForm.domain.trim()) return
|
||||
|
||||
setAddingDomain(true)
|
||||
try {
|
||||
await api.addPortfolioDomain({
|
||||
domain: addForm.domain.trim(),
|
||||
purchase_price: addForm.purchase_price ? parseFloat(addForm.purchase_price) : undefined,
|
||||
purchase_date: addForm.purchase_date || undefined,
|
||||
registrar: addForm.registrar || undefined,
|
||||
renewal_date: addForm.renewal_date || undefined,
|
||||
renewal_cost: addForm.renewal_cost ? parseFloat(addForm.renewal_cost) : undefined,
|
||||
notes: addForm.notes || undefined,
|
||||
})
|
||||
showToast(`Added ${addForm.domain} to portfolio`, 'success')
|
||||
setAddForm({ domain: '', purchase_price: '', purchase_date: '', registrar: '', renewal_date: '', renewal_cost: '', notes: '' })
|
||||
setShowAddModal(false)
|
||||
loadPortfolio()
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Failed to add domain', 'error')
|
||||
} finally {
|
||||
setAddingDomain(false)
|
||||
}
|
||||
}, [addForm, loadPortfolio, showToast])
|
||||
|
||||
const handleEditDomain = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedDomain) return
|
||||
|
||||
setSavingEdit(true)
|
||||
try {
|
||||
await api.updatePortfolioDomain(selectedDomain.id, {
|
||||
purchase_price: editForm.purchase_price ? parseFloat(editForm.purchase_price) : undefined,
|
||||
purchase_date: editForm.purchase_date || undefined,
|
||||
registrar: editForm.registrar || undefined,
|
||||
renewal_date: editForm.renewal_date || undefined,
|
||||
renewal_cost: editForm.renewal_cost ? parseFloat(editForm.renewal_cost) : undefined,
|
||||
notes: editForm.notes || undefined,
|
||||
})
|
||||
showToast('Domain updated', 'success')
|
||||
setShowEditModal(false)
|
||||
loadPortfolio()
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Failed to update', 'error')
|
||||
} finally {
|
||||
setSavingEdit(false)
|
||||
}
|
||||
}, [selectedDomain, editForm, loadPortfolio, showToast])
|
||||
|
||||
const handleSellDomain = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedDomain || !sellForm.sale_price) return
|
||||
|
||||
setProcessingSale(true)
|
||||
try {
|
||||
await api.markDomainSold(selectedDomain.id, sellForm.sale_date, parseFloat(sellForm.sale_price))
|
||||
showToast(`Marked ${selectedDomain.domain} as sold`, 'success')
|
||||
setShowSellModal(false)
|
||||
loadPortfolio()
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Failed to process sale', 'error')
|
||||
} finally {
|
||||
setProcessingSale(false)
|
||||
}
|
||||
}, [selectedDomain, sellForm, loadPortfolio, showToast])
|
||||
|
||||
const handleValuate = useCallback(async (domain: PortfolioDomain) => {
|
||||
setValuatingDomain(domain.domain)
|
||||
setShowValuationModal(true)
|
||||
try {
|
||||
const result = await api.getDomainValuation(domain.domain)
|
||||
setValuation(result)
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Failed to get valuation', 'error')
|
||||
setShowValuationModal(false)
|
||||
} finally {
|
||||
setValuatingDomain('')
|
||||
}
|
||||
}, [showToast])
|
||||
|
||||
const handleRefresh = useCallback(async (domain: PortfolioDomain) => {
|
||||
setRefreshingId(domain.id)
|
||||
try {
|
||||
await api.refreshDomainValue(domain.id)
|
||||
showToast('Valuation refreshed', 'success')
|
||||
loadPortfolio()
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Failed to refresh', 'error')
|
||||
} finally {
|
||||
setRefreshingId(null)
|
||||
}
|
||||
}, [loadPortfolio, showToast])
|
||||
|
||||
const handleHealthCheck = useCallback(async (domainName: string) => {
|
||||
if (loadingHealth[domainName]) return
|
||||
|
||||
setLoadingHealth(prev => ({ ...prev, [domainName]: true }))
|
||||
try {
|
||||
const report = await api.quickHealthCheck(domainName)
|
||||
setHealthReports(prev => ({ ...prev, [domainName]: report }))
|
||||
setSelectedHealthDomain(domainName)
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Health check failed', 'error')
|
||||
} finally {
|
||||
setLoadingHealth(prev => ({ ...prev, [domainName]: false }))
|
||||
}
|
||||
}, [loadingHealth, showToast])
|
||||
|
||||
const handleDelete = useCallback(async (domain: PortfolioDomain) => {
|
||||
if (!confirm(`Remove ${domain.domain} from your portfolio?`)) return
|
||||
|
||||
try {
|
||||
await api.deletePortfolioDomain(domain.id)
|
||||
showToast(`Removed ${domain.domain}`, 'success')
|
||||
loadPortfolio()
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Failed to remove', 'error')
|
||||
}
|
||||
}, [loadPortfolio, showToast])
|
||||
|
||||
const openEditModal = useCallback((domain: PortfolioDomain) => {
|
||||
setSelectedDomain(domain)
|
||||
setEditForm({
|
||||
purchase_price: domain.purchase_price?.toString() || '',
|
||||
purchase_date: domain.purchase_date || '',
|
||||
registrar: domain.registrar || '',
|
||||
renewal_date: domain.renewal_date || '',
|
||||
renewal_cost: domain.renewal_cost?.toString() || '',
|
||||
notes: domain.notes || '',
|
||||
})
|
||||
setShowEditModal(true)
|
||||
}, [])
|
||||
|
||||
const openSellModal = useCallback((domain: PortfolioDomain) => {
|
||||
setSelectedDomain(domain)
|
||||
setSellForm({
|
||||
sale_date: new Date().toISOString().split('T')[0],
|
||||
sale_price: '',
|
||||
})
|
||||
setShowSellModal(true)
|
||||
}, [])
|
||||
|
||||
const portfolioLimit = subscription?.portfolio_limit || 0
|
||||
const canAddMore = portfolioLimit === -1 || portfolio.length < portfolioLimit
|
||||
|
||||
// Memoized stats and subtitle
|
||||
const { expiringSoonCount, subtitle } = useMemo(() => {
|
||||
const expiring = portfolio.filter(d => {
|
||||
if (!d.renewal_date) return false
|
||||
const days = Math.ceil((new Date(d.renewal_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
return days <= 30 && days > 0
|
||||
}).length
|
||||
|
||||
let sub = ''
|
||||
if (loading) sub = 'Loading your portfolio...'
|
||||
else if (portfolio.length === 0) sub = 'Start tracking your domains'
|
||||
else if (expiring > 0) sub = `${portfolio.length} domain${portfolio.length !== 1 ? 's' : ''} • ${expiring} expiring soon`
|
||||
else sub = `Managing ${portfolio.length} domain${portfolio.length !== 1 ? 's' : ''}`
|
||||
|
||||
return { expiringSoonCount: expiring, subtitle: sub }
|
||||
}, [portfolio, loading])
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="Portfolio"
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
<ActionButton onClick={() => setShowAddModal(true)} disabled={!canAddMore} icon={Plus}>
|
||||
Add Domain
|
||||
</ActionButton>
|
||||
}
|
||||
>
|
||||
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
||||
|
||||
<PageContainer>
|
||||
{/* Summary Stats - Only reliable data */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard title="Total Domains" value={summary?.total_domains || 0} icon={Briefcase} />
|
||||
<StatCard title="Expiring Soon" value={expiringSoonCount} icon={Calendar} />
|
||||
<StatCard
|
||||
title="Need Attention"
|
||||
value={Object.values(healthReports).filter(r => r.status !== 'healthy').length}
|
||||
icon={AlertTriangle}
|
||||
/>
|
||||
<StatCard title="Listed for Sale" value={summary?.sold_domains || 0} icon={Tag} />
|
||||
</div>
|
||||
|
||||
{!canAddMore && (
|
||||
<div className="flex items-center justify-between p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl">
|
||||
<p className="text-sm text-amber-400">
|
||||
You've reached your portfolio limit. Upgrade to add more.
|
||||
</p>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="text-sm font-medium text-amber-400 hover:text-amber-300 flex items-center gap-1"
|
||||
>
|
||||
Upgrade <ArrowUpRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Portfolio Table */}
|
||||
<PremiumTable
|
||||
data={portfolio}
|
||||
keyExtractor={(d) => d.id}
|
||||
loading={loading}
|
||||
emptyIcon={<Briefcase className="w-12 h-12 text-foreground-subtle" />}
|
||||
emptyTitle="Your portfolio is empty"
|
||||
emptyDescription="Add your first domain to start tracking investments"
|
||||
columns={[
|
||||
{
|
||||
key: 'domain',
|
||||
header: 'Domain',
|
||||
render: (domain) => (
|
||||
<div>
|
||||
<span className="font-mono font-medium text-foreground">{domain.domain}</span>
|
||||
{domain.registrar && (
|
||||
<p className="text-xs text-foreground-muted flex items-center gap-1 mt-0.5">
|
||||
<Building className="w-3 h-3" /> {domain.registrar}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'added',
|
||||
header: 'Added',
|
||||
hideOnMobile: true,
|
||||
hideOnTablet: true,
|
||||
render: (domain) => (
|
||||
<span className="text-sm text-foreground-muted">
|
||||
{domain.purchase_date
|
||||
? new Date(domain.purchase_date).toLocaleDateString()
|
||||
: new Date(domain.created_at).toLocaleDateString()
|
||||
}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'renewal',
|
||||
header: 'Expires',
|
||||
hideOnMobile: true,
|
||||
render: (domain) => {
|
||||
if (!domain.renewal_date) {
|
||||
return <span className="text-foreground-subtle">—</span>
|
||||
}
|
||||
const days = Math.ceil((new Date(domain.renewal_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
const isExpiringSoon = days <= 30 && days > 0
|
||||
const isExpired = days <= 0
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={clsx(
|
||||
"text-sm",
|
||||
isExpired && "text-red-400",
|
||||
isExpiringSoon && "text-amber-400",
|
||||
!isExpired && !isExpiringSoon && "text-foreground-muted"
|
||||
)}>
|
||||
{new Date(domain.renewal_date).toLocaleDateString()}
|
||||
</span>
|
||||
{isExpiringSoon && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-amber-400/10 text-amber-400 rounded">
|
||||
{days}d
|
||||
</span>
|
||||
)}
|
||||
{isExpired && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-red-400/10 text-red-400 rounded">
|
||||
EXPIRED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'health',
|
||||
header: 'Health',
|
||||
hideOnMobile: true,
|
||||
render: (domain) => {
|
||||
const report = healthReports[domain.domain]
|
||||
if (loadingHealth[domain.domain]) {
|
||||
return <Loader2 className="w-4 h-4 text-foreground-muted animate-spin" />
|
||||
}
|
||||
if (report) {
|
||||
const config = healthStatusConfig[report.status]
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<button
|
||||
onClick={() => setSelectedHealthDomain(domain.domain)}
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-medium",
|
||||
config.bgColor, config.color
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{config.label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleHealthCheck(domain.domain)}
|
||||
className="text-xs text-foreground-muted hover:text-accent transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Activity className="w-3.5 h-3.5" />
|
||||
Check
|
||||
</button>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right',
|
||||
render: (domain) => (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpenMenuId(openMenuId === domain.id ? null : domain.id)
|
||||
}}
|
||||
className="p-2 text-foreground-muted hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{openMenuId === domain.id && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setOpenMenuId(null)}
|
||||
/>
|
||||
{/* Menu - opens downward */}
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-48 py-1 bg-background-secondary border border-border/50 rounded-xl shadow-xl">
|
||||
<button
|
||||
onClick={() => { handleHealthCheck(domain.domain); setOpenMenuId(null) }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
Health Check
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { openEditModal(domain); setOpenMenuId(null) }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
Edit Details
|
||||
</button>
|
||||
<div className="my-1 border-t border-border/30" />
|
||||
<Link
|
||||
href={`/command/listings?domain=${encodeURIComponent(domain.domain)}`}
|
||||
onClick={() => setOpenMenuId(null)}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-accent hover:bg-accent/5 transition-colors"
|
||||
>
|
||||
<Tag className="w-4 h-4" />
|
||||
List for Sale
|
||||
</Link>
|
||||
<a
|
||||
href={`https://${domain.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => setOpenMenuId(null)}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Visit Website
|
||||
</a>
|
||||
<div className="my-1 border-t border-border/30" />
|
||||
<button
|
||||
onClick={() => { openSellModal(domain); setOpenMenuId(null) }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
<DollarSign className="w-4 h-4" />
|
||||
Record Sale
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { handleDelete(domain); setOpenMenuId(null) }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-400 hover:bg-red-400/5 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</PageContainer>
|
||||
|
||||
{/* Add Modal */}
|
||||
{showAddModal && (
|
||||
<Modal title="Add Domain to Portfolio" onClose={() => setShowAddModal(false)}>
|
||||
<form onSubmit={handleAddDomain} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1.5">Domain *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={addForm.domain}
|
||||
onChange={(e) => setAddForm({ ...addForm, domain: e.target.value })}
|
||||
placeholder="example.com"
|
||||
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
|
||||
focus:outline-none focus:border-accent/50 transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1.5">Purchase Price</label>
|
||||
<input
|
||||
type="number"
|
||||
value={addForm.purchase_price}
|
||||
onChange={(e) => setAddForm({ ...addForm, purchase_price: e.target.value })}
|
||||
placeholder="100"
|
||||
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
|
||||
focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1.5">Purchase Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={addForm.purchase_date}
|
||||
onChange={(e) => setAddForm({ ...addForm, purchase_date: e.target.value })}
|
||||
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
|
||||
focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1.5">Registrar</label>
|
||||
<input
|
||||
type="text"
|
||||
value={addForm.registrar}
|
||||
onChange={(e) => setAddForm({ ...addForm, registrar: e.target.value })}
|
||||
placeholder="Namecheap"
|
||||
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
|
||||
focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="px-4 py-2.5 text-foreground-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addingDomain || !addForm.domain.trim()}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-accent to-accent/80
|
||||
text-background rounded-xl font-medium disabled:opacity-50 transition-all"
|
||||
>
|
||||
{addingDomain && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Add Domain
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{showEditModal && selectedDomain && (
|
||||
<Modal title={`Edit ${selectedDomain.domain}`} onClose={() => setShowEditModal(false)}>
|
||||
<form onSubmit={handleEditDomain} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1.5">Purchase Price</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.purchase_price}
|
||||
onChange={(e) => setEditForm({ ...editForm, purchase_price: e.target.value })}
|
||||
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
|
||||
focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1.5">Registrar</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.registrar}
|
||||
onChange={(e) => setEditForm({ ...editForm, registrar: e.target.value })}
|
||||
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
|
||||
focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEditModal(false)}
|
||||
className="px-4 py-2.5 text-foreground-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingEdit}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-accent to-accent/80
|
||||
text-background rounded-xl font-medium disabled:opacity-50 transition-all"
|
||||
>
|
||||
{savingEdit && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Record Sale Modal - for tracking completed sales */}
|
||||
{showSellModal && selectedDomain && (
|
||||
<Modal title={`Record Sale: ${selectedDomain.domain}`} onClose={() => setShowSellModal(false)}>
|
||||
<form onSubmit={handleSellDomain} className="space-y-4">
|
||||
<div className="p-3 bg-accent/10 border border-accent/20 rounded-lg text-sm text-foreground-muted">
|
||||
<p>Record a completed sale to track your profit/loss. This will mark the domain as sold in your portfolio.</p>
|
||||
<p className="mt-2 text-accent">Want to list it for sale instead? Use the <strong>"List"</strong> button.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1.5">Sale Price *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={sellForm.sale_price}
|
||||
onChange={(e) => setSellForm({ ...sellForm, sale_price: e.target.value })}
|
||||
placeholder="1000"
|
||||
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
|
||||
focus:outline-none focus:border-accent/50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1.5">Sale Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={sellForm.sale_date}
|
||||
onChange={(e) => setSellForm({ ...sellForm, sale_date: e.target.value })}
|
||||
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
|
||||
focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSellModal(false)}
|
||||
className="px-4 py-2.5 text-foreground-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={processingSale || !sellForm.sale_price}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-accent to-accent/80
|
||||
text-background rounded-xl font-medium disabled:opacity-50 transition-all"
|
||||
>
|
||||
{processingSale && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Mark as Sold
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Valuation Modal */}
|
||||
{showValuationModal && (
|
||||
<Modal title="Domain Valuation" onClose={() => { setShowValuationModal(false); setValuation(null); }}>
|
||||
{valuatingDomain ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-accent" />
|
||||
</div>
|
||||
) : valuation ? (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center p-6 bg-accent/5 border border-accent/20 rounded-xl">
|
||||
<p className="text-4xl font-semibold text-accent">${valuation.estimated_value.toLocaleString()}</p>
|
||||
<p className="text-sm text-foreground-muted mt-1">Pounce Score Estimate</p>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between items-center p-3 bg-foreground/5 rounded-lg">
|
||||
<span className="text-foreground-muted">Confidence Level</span>
|
||||
<span className={clsx(
|
||||
"px-2 py-0.5 rounded text-xs font-medium capitalize",
|
||||
valuation.confidence === 'high' && "bg-accent/20 text-accent",
|
||||
valuation.confidence === 'medium' && "bg-amber-400/20 text-amber-400",
|
||||
valuation.confidence === 'low' && "bg-foreground/10 text-foreground-muted"
|
||||
)}>
|
||||
{valuation.confidence}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3 bg-foreground/5 rounded-lg">
|
||||
<p className="text-foreground-muted mb-1">Valuation Formula</p>
|
||||
<p className="text-foreground font-mono text-xs break-all">{valuation.valuation_formula}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-amber-400/10 border border-amber-400/20 rounded-lg text-xs text-amber-400">
|
||||
<p>This is an algorithmic estimate based on domain length, TLD, and market patterns. Actual market value may vary.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Health Report Modal */}
|
||||
{selectedHealthDomain && healthReports[selectedHealthDomain] && (
|
||||
<HealthReportModal
|
||||
report={healthReports[selectedHealthDomain]}
|
||||
onClose={() => setSelectedHealthDomain(null)}
|
||||
/>
|
||||
)}
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Health Report Modal Component
|
||||
function HealthReportModal({ report, onClose }: { report: DomainHealthReport; onClose: () => void }) {
|
||||
const config = healthStatusConfig[report.status]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg bg-background-secondary border border-border/50 rounded-2xl shadow-2xl overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-border/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={clsx("p-2 rounded-lg", config.bgColor)}>
|
||||
<Icon className={clsx("w-5 h-5", config.color)} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-mono font-semibold text-foreground">{report.domain}</h3>
|
||||
<p className={clsx("text-xs font-medium", config.color)}>{config.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 text-foreground-muted hover:text-foreground transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
<div className="p-5 border-b border-border/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-foreground-muted">Health Score</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-32 h-2 bg-foreground/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={clsx(
|
||||
"h-full rounded-full transition-all",
|
||||
report.score >= 70 ? "bg-accent" :
|
||||
report.score >= 40 ? "bg-amber-400" : "bg-red-400"
|
||||
)}
|
||||
style={{ width: `${report.score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
"text-lg font-bold tabular-nums",
|
||||
report.score >= 70 ? "text-accent" :
|
||||
report.score >= 40 ? "text-amber-400" : "text-red-400"
|
||||
)}>
|
||||
{report.score}/100
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Check Results */}
|
||||
<div className="p-5 space-y-4">
|
||||
{/* DNS */}
|
||||
{report.dns && (
|
||||
<div className="p-4 bg-foreground/5 rounded-xl">
|
||||
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
|
||||
<span className={clsx(
|
||||
"w-2 h-2 rounded-full",
|
||||
report.dns.has_ns && report.dns.has_a ? "bg-accent" : "bg-red-400"
|
||||
)} />
|
||||
DNS Infrastructure
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={report.dns.has_ns ? "text-accent" : "text-red-400"}>
|
||||
{report.dns.has_ns ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className="text-foreground-muted">Nameservers</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={report.dns.has_a ? "text-accent" : "text-red-400"}>
|
||||
{report.dns.has_a ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className="text-foreground-muted">A Record</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={report.dns.has_mx ? "text-accent" : "text-foreground-muted"}>
|
||||
{report.dns.has_mx ? '✓' : '—'}
|
||||
</span>
|
||||
<span className="text-foreground-muted">MX Record</span>
|
||||
</div>
|
||||
</div>
|
||||
{report.dns.is_parked && (
|
||||
<p className="mt-2 text-xs text-orange-400">⚠ Parked at {report.dns.parking_provider || 'unknown provider'}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HTTP */}
|
||||
{report.http && (
|
||||
<div className="p-4 bg-foreground/5 rounded-xl">
|
||||
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
|
||||
<span className={clsx(
|
||||
"w-2 h-2 rounded-full",
|
||||
report.http.is_reachable && report.http.status_code === 200 ? "bg-accent" :
|
||||
report.http.is_reachable ? "bg-amber-400" : "bg-red-400"
|
||||
)} />
|
||||
Website Status
|
||||
</h4>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className={clsx(
|
||||
report.http.is_reachable ? "text-accent" : "text-red-400"
|
||||
)}>
|
||||
{report.http.is_reachable ? 'Reachable' : 'Unreachable'}
|
||||
</span>
|
||||
{report.http.status_code && (
|
||||
<span className="text-foreground-muted">
|
||||
HTTP {report.http.status_code}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{report.http.is_parked && (
|
||||
<p className="mt-2 text-xs text-orange-400">⚠ Parking page detected</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSL */}
|
||||
{report.ssl && (
|
||||
<div className="p-4 bg-foreground/5 rounded-xl">
|
||||
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
|
||||
<span className={clsx(
|
||||
"w-2 h-2 rounded-full",
|
||||
report.ssl.has_certificate && report.ssl.is_valid ? "bg-accent" :
|
||||
report.ssl.has_certificate ? "bg-amber-400" : "bg-foreground-muted"
|
||||
)} />
|
||||
SSL Certificate
|
||||
</h4>
|
||||
<div className="text-xs">
|
||||
{report.ssl.has_certificate ? (
|
||||
<div className="space-y-1">
|
||||
<p className={report.ssl.is_valid ? "text-accent" : "text-red-400"}>
|
||||
{report.ssl.is_valid ? '✓ Valid certificate' : '✗ Certificate invalid/expired'}
|
||||
</p>
|
||||
{report.ssl.days_until_expiry !== undefined && (
|
||||
<p className={clsx(
|
||||
report.ssl.days_until_expiry > 30 ? "text-foreground-muted" :
|
||||
report.ssl.days_until_expiry > 7 ? "text-amber-400" : "text-red-400"
|
||||
)}>
|
||||
Expires in {report.ssl.days_until_expiry} days
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-foreground-muted">No SSL certificate</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signals & Recommendations */}
|
||||
{((report.signals?.length || 0) > 0 || (report.recommendations?.length || 0) > 0) && (
|
||||
<div className="space-y-3">
|
||||
{(report.signals?.length || 0) > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-foreground-muted uppercase tracking-wider mb-2">Signals</h4>
|
||||
<ul className="space-y-1">
|
||||
{report.signals?.map((signal, i) => (
|
||||
<li key={i} className="text-xs text-foreground flex items-start gap-2">
|
||||
<span className="text-accent mt-0.5">•</span>
|
||||
{signal}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{(report.recommendations?.length || 0) > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-foreground-muted uppercase tracking-wider mb-2">Recommendations</h4>
|
||||
<ul className="space-y-1">
|
||||
{report.recommendations?.map((rec, i) => (
|
||||
<li key={i} className="text-xs text-foreground flex items-start gap-2">
|
||||
<span className="text-amber-400 mt-0.5">→</span>
|
||||
{rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 bg-foreground/5 border-t border-border/30">
|
||||
<p className="text-xs text-foreground-subtle text-center">
|
||||
Checked at {new Date(report.checked_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Modal Component
|
||||
function Modal({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md bg-background-secondary border border-border/50 rounded-2xl shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-5 border-b border-border/50">
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
<button onClick={onClose} className="p-1 text-foreground-muted hover:text-foreground transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
722
frontend/src/app/command/pricing/[tld]/page.tsx
Normal file
722
frontend/src/app/command/pricing/[tld]/page.tsx
Normal file
@ -0,0 +1,722 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import { PageContainer, StatCard } from '@/components/PremiumTable'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
ArrowLeft,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
Calendar,
|
||||
Globe,
|
||||
Building,
|
||||
ExternalLink,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Check,
|
||||
X,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
DollarSign,
|
||||
BarChart3,
|
||||
Shield,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface TldDetails {
|
||||
tld: string
|
||||
type: string
|
||||
description: string
|
||||
registry: string
|
||||
introduced: number
|
||||
trend: string
|
||||
trend_reason: string
|
||||
pricing: {
|
||||
avg: number
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
registrars: Array<{
|
||||
name: string
|
||||
registration_price: number
|
||||
renewal_price: number
|
||||
transfer_price: number
|
||||
}>
|
||||
cheapest_registrar: string
|
||||
// New fields from table
|
||||
min_renewal_price: number
|
||||
price_change_1y: number
|
||||
price_change_3y: number
|
||||
risk_level: 'low' | 'medium' | 'high'
|
||||
risk_reason: string
|
||||
}
|
||||
|
||||
interface TldHistory {
|
||||
tld: string
|
||||
current_price: number
|
||||
price_change_7d: number
|
||||
price_change_30d: number
|
||||
price_change_90d: number
|
||||
trend: string
|
||||
trend_reason: string
|
||||
history: Array<{
|
||||
date: string
|
||||
price: number
|
||||
}>
|
||||
}
|
||||
|
||||
interface DomainCheckResult {
|
||||
domain: string
|
||||
is_available: boolean
|
||||
status: string
|
||||
registrar?: string | null
|
||||
creation_date?: string | null
|
||||
expiration_date?: string | null
|
||||
}
|
||||
|
||||
// Registrar URLs
|
||||
const REGISTRAR_URLS: Record<string, string> = {
|
||||
'Namecheap': 'https://www.namecheap.com/domains/registration/results/?domain=',
|
||||
'Porkbun': 'https://porkbun.com/checkout/search?q=',
|
||||
'Cloudflare': 'https://www.cloudflare.com/products/registrar/',
|
||||
'Google Domains': 'https://domains.google.com/registrar/search?searchTerm=',
|
||||
'GoDaddy': 'https://www.godaddy.com/domainsearch/find?domainToCheck=',
|
||||
'porkbun': 'https://porkbun.com/checkout/search?q=',
|
||||
'Dynadot': 'https://www.dynadot.com/domain/search?domain=',
|
||||
}
|
||||
|
||||
type ChartPeriod = '1M' | '3M' | '1Y' | 'ALL'
|
||||
|
||||
// Premium Chart Component with real data
|
||||
function PriceChart({
|
||||
data,
|
||||
chartStats,
|
||||
}: {
|
||||
data: Array<{ date: string; price: number }>
|
||||
chartStats: { high: number; low: number; avg: number }
|
||||
}) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="h-48 flex items-center justify-center text-foreground-muted">
|
||||
No price history available
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const minPrice = Math.min(...data.map(d => d.price))
|
||||
const maxPrice = Math.max(...data.map(d => d.price))
|
||||
const priceRange = maxPrice - minPrice || 1
|
||||
|
||||
const points = data.map((d, i) => ({
|
||||
x: (i / (data.length - 1)) * 100,
|
||||
y: 100 - ((d.price - minPrice) / priceRange) * 80 - 10,
|
||||
...d,
|
||||
}))
|
||||
|
||||
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
||||
const areaPath = linePath + ` L${points[points.length - 1].x},100 L${points[0].x},100 Z`
|
||||
|
||||
const isRising = data[data.length - 1].price > data[0].price
|
||||
const strokeColor = isRising ? '#f97316' : '#00d4aa'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative h-48"
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
>
|
||||
<svg
|
||||
className="w-full h-full"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
onMouseMove={(e) => {
|
||||
if (!containerRef.current) return
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100
|
||||
const idx = Math.round((x / 100) * (points.length - 1))
|
||||
setHoveredIndex(Math.max(0, Math.min(idx, points.length - 1)))
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="chartGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={areaPath} fill="url(#chartGradient)" />
|
||||
<path d={linePath} fill="none" stroke={strokeColor} strokeWidth="2" />
|
||||
|
||||
{hoveredIndex !== null && points[hoveredIndex] && (
|
||||
<circle
|
||||
cx={points[hoveredIndex].x}
|
||||
cy={points[hoveredIndex].y}
|
||||
r="4"
|
||||
fill={strokeColor}
|
||||
stroke="#0a0a0a"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Tooltip */}
|
||||
{hoveredIndex !== null && points[hoveredIndex] && (
|
||||
<div
|
||||
className="absolute -top-2 transform -translate-x-1/2 bg-background border border-border rounded-lg px-3 py-2 shadow-lg z-10 pointer-events-none"
|
||||
style={{ left: `${points[hoveredIndex].x}%` }}
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground">${points[hoveredIndex].price.toFixed(2)}</p>
|
||||
<p className="text-xs text-foreground-muted">{new Date(points[hoveredIndex].date).toLocaleDateString()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
<div className="absolute left-0 top-0 bottom-0 flex flex-col justify-between text-xs text-foreground-subtle -ml-12 w-10 text-right">
|
||||
<span>${maxPrice.toFixed(2)}</span>
|
||||
<span>${((maxPrice + minPrice) / 2).toFixed(2)}</span>
|
||||
<span>${minPrice.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CommandTldDetailPage() {
|
||||
const params = useParams()
|
||||
const { fetchSubscription } = useStore()
|
||||
const tld = params.tld as string
|
||||
|
||||
const [details, setDetails] = useState<TldDetails | null>(null)
|
||||
const [history, setHistory] = useState<TldHistory | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [chartPeriod, setChartPeriod] = useState<ChartPeriod>('1Y')
|
||||
const [domainSearch, setDomainSearch] = useState('')
|
||||
const [checkingDomain, setCheckingDomain] = useState(false)
|
||||
const [domainResult, setDomainResult] = useState<DomainCheckResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubscription()
|
||||
if (tld) {
|
||||
loadData()
|
||||
}
|
||||
}, [tld, fetchSubscription])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [historyData, compareData, overviewData] = await Promise.all([
|
||||
api.getTldHistory(tld, 365),
|
||||
api.getTldCompare(tld),
|
||||
api.getTldOverview(1, 0, 'popularity', tld), // Get the specific TLD data
|
||||
])
|
||||
|
||||
if (historyData && compareData) {
|
||||
const sortedRegistrars = [...(compareData.registrars || [])].sort((a, b) =>
|
||||
a.registration_price - b.registration_price
|
||||
)
|
||||
|
||||
// Get additional data from overview API (1y, 3y change, risk)
|
||||
const tldFromOverview = overviewData?.tlds?.[0]
|
||||
|
||||
setDetails({
|
||||
tld: compareData.tld || tld,
|
||||
type: compareData.type || 'generic',
|
||||
description: compareData.description || `Domain extension .${tld}`,
|
||||
registry: compareData.registry || 'Various',
|
||||
introduced: compareData.introduced || 0,
|
||||
trend: historyData.trend || 'stable',
|
||||
trend_reason: historyData.trend_reason || 'Price tracking available',
|
||||
pricing: {
|
||||
avg: compareData.price_range?.avg || historyData.current_price || 0,
|
||||
min: compareData.price_range?.min || historyData.current_price || 0,
|
||||
max: compareData.price_range?.max || historyData.current_price || 0,
|
||||
},
|
||||
registrars: sortedRegistrars,
|
||||
cheapest_registrar: compareData.cheapest_registrar || sortedRegistrars[0]?.name || 'N/A',
|
||||
// New fields from overview
|
||||
min_renewal_price: tldFromOverview?.min_renewal_price || sortedRegistrars[0]?.renewal_price || 0,
|
||||
price_change_1y: tldFromOverview?.price_change_1y || 0,
|
||||
price_change_3y: tldFromOverview?.price_change_3y || 0,
|
||||
risk_level: tldFromOverview?.risk_level || 'low',
|
||||
risk_reason: tldFromOverview?.risk_reason || 'Stable',
|
||||
})
|
||||
setHistory(historyData)
|
||||
} else {
|
||||
setError('Failed to load TLD data')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading TLD data:', err)
|
||||
setError('Failed to load TLD data')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredHistory = useMemo(() => {
|
||||
if (!history?.history) return []
|
||||
|
||||
const now = new Date()
|
||||
let cutoffDays = 365
|
||||
|
||||
switch (chartPeriod) {
|
||||
case '1M': cutoffDays = 30; break
|
||||
case '3M': cutoffDays = 90; break
|
||||
case '1Y': cutoffDays = 365; break
|
||||
case 'ALL': cutoffDays = 9999; break
|
||||
}
|
||||
|
||||
const cutoff = new Date(now.getTime() - cutoffDays * 24 * 60 * 60 * 1000)
|
||||
return history.history.filter(h => new Date(h.date) >= cutoff)
|
||||
}, [history, chartPeriod])
|
||||
|
||||
const chartStats = useMemo(() => {
|
||||
if (filteredHistory.length === 0) return { high: 0, low: 0, avg: 0 }
|
||||
const prices = filteredHistory.map(h => h.price)
|
||||
return {
|
||||
high: Math.max(...prices),
|
||||
low: Math.min(...prices),
|
||||
avg: prices.reduce((a, b) => a + b, 0) / prices.length,
|
||||
}
|
||||
}, [filteredHistory])
|
||||
|
||||
const handleDomainCheck = async () => {
|
||||
if (!domainSearch.trim()) return
|
||||
|
||||
setCheckingDomain(true)
|
||||
setDomainResult(null)
|
||||
|
||||
try {
|
||||
const domain = domainSearch.includes('.') ? domainSearch : `${domainSearch}.${tld}`
|
||||
const result = await api.checkDomain(domain, false)
|
||||
setDomainResult({
|
||||
domain,
|
||||
is_available: result.is_available,
|
||||
status: result.status,
|
||||
registrar: result.registrar,
|
||||
creation_date: result.creation_date,
|
||||
expiration_date: result.expiration_date,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Domain check failed:', err)
|
||||
} finally {
|
||||
setCheckingDomain(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getRegistrarUrl = (registrarName: string, domain?: string) => {
|
||||
const baseUrl = REGISTRAR_URLS[registrarName]
|
||||
if (!baseUrl) return '#'
|
||||
if (domain) return `${baseUrl}${domain}`
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
// Calculate renewal trap info
|
||||
const getRenewalInfo = () => {
|
||||
if (!details?.registrars?.length) return null
|
||||
const cheapest = details.registrars[0]
|
||||
const ratio = cheapest.renewal_price / cheapest.registration_price
|
||||
return {
|
||||
registration: cheapest.registration_price,
|
||||
renewal: cheapest.renewal_price,
|
||||
ratio,
|
||||
isTrap: ratio > 2,
|
||||
}
|
||||
}
|
||||
|
||||
const renewalInfo = getRenewalInfo()
|
||||
|
||||
// Risk badge component
|
||||
const getRiskBadge = () => {
|
||||
if (!details) return null
|
||||
const level = details.risk_level
|
||||
const reason = details.risk_reason
|
||||
return (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium",
|
||||
level === 'high' && "bg-red-500/10 text-red-400",
|
||||
level === 'medium' && "bg-amber-500/10 text-amber-400",
|
||||
level === 'low' && "bg-accent/10 text-accent"
|
||||
)}>
|
||||
<span className={clsx(
|
||||
"w-2.5 h-2.5 rounded-full",
|
||||
level === 'high' && "bg-red-400",
|
||||
level === 'medium' && "bg-amber-400",
|
||||
level === 'low' && "bg-accent"
|
||||
)} />
|
||||
{reason}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<CommandCenterLayout title={`.${tld}`} subtitle="Loading...">
|
||||
<PageContainer>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<RefreshCw className="w-6 h-6 text-accent animate-spin" />
|
||||
</div>
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !details) {
|
||||
return (
|
||||
<CommandCenterLayout title="TLD Not Found" subtitle="Error loading data">
|
||||
<PageContainer>
|
||||
<div className="text-center py-20">
|
||||
<div className="w-16 h-16 bg-background-secondary rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<X className="w-8 h-8 text-foreground-subtle" />
|
||||
</div>
|
||||
<h1 className="text-xl font-medium text-foreground mb-2">TLD Not Found</h1>
|
||||
<p className="text-foreground-muted mb-8">{error || `The TLD .${tld} could not be found.`}</p>
|
||||
<Link
|
||||
href="/command/pricing"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background rounded-xl font-medium hover:bg-accent-hover transition-all"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to TLD Pricing
|
||||
</Link>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title={`.${details.tld}`}
|
||||
subtitle={details.description}
|
||||
>
|
||||
<PageContainer>
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-sm mb-6">
|
||||
<Link href="/command/pricing" className="text-foreground-subtle hover:text-foreground transition-colors">
|
||||
TLD Pricing
|
||||
</Link>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-foreground-subtle" />
|
||||
<span className="text-foreground font-medium">.{details.tld}</span>
|
||||
</nav>
|
||||
|
||||
{/* Stats Grid - All info from table */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div title="Lowest first-year registration price across all tracked registrars">
|
||||
<StatCard
|
||||
title="Buy Price (1y)"
|
||||
value={`$${details.pricing.min.toFixed(2)}`}
|
||||
subtitle={`at ${details.cheapest_registrar}`}
|
||||
icon={DollarSign}
|
||||
/>
|
||||
</div>
|
||||
<div title={renewalInfo?.isTrap
|
||||
? `Warning: Renewal is ${renewalInfo.ratio.toFixed(1)}x the registration price`
|
||||
: 'Annual renewal price after first year'}>
|
||||
<StatCard
|
||||
title="Renewal (1y)"
|
||||
value={details.min_renewal_price ? `$${details.min_renewal_price.toFixed(2)}` : '—'}
|
||||
subtitle={renewalInfo?.isTrap ? `${renewalInfo.ratio.toFixed(1)}x registration` : 'per year'}
|
||||
icon={RefreshCw}
|
||||
/>
|
||||
</div>
|
||||
<div title="Price change over the last 12 months">
|
||||
<StatCard
|
||||
title="1y Change"
|
||||
value={`${details.price_change_1y > 0 ? '+' : ''}${details.price_change_1y.toFixed(0)}%`}
|
||||
icon={details.price_change_1y > 0 ? TrendingUp : details.price_change_1y < 0 ? TrendingDown : Minus}
|
||||
/>
|
||||
</div>
|
||||
<div title="Price change over the last 3 years">
|
||||
<StatCard
|
||||
title="3y Change"
|
||||
value={`${details.price_change_3y > 0 ? '+' : ''}${details.price_change_3y.toFixed(0)}%`}
|
||||
icon={BarChart3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Level */}
|
||||
<div className="flex items-center gap-4 p-4 bg-background-secondary/30 border border-border/50 rounded-xl">
|
||||
<Shield className="w-5 h-5 text-foreground-muted" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-foreground">Risk Assessment</p>
|
||||
<p className="text-xs text-foreground-muted">Based on renewal ratio, price volatility, and market trends</p>
|
||||
</div>
|
||||
{getRiskBadge()}
|
||||
</div>
|
||||
|
||||
{/* Renewal Trap Warning */}
|
||||
{renewalInfo?.isTrap && (
|
||||
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-400">Renewal Trap Detected</p>
|
||||
<p className="text-sm text-foreground-muted mt-1">
|
||||
The renewal price (${renewalInfo.renewal.toFixed(2)}) is {renewalInfo.ratio.toFixed(1)}x higher than the registration price (${renewalInfo.registration.toFixed(2)}).
|
||||
Consider the total cost of ownership before registering.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price Chart */}
|
||||
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-medium text-foreground">Price History</h2>
|
||||
<div className="flex items-center gap-1 bg-foreground/5 rounded-lg p-1">
|
||||
{(['1M', '3M', '1Y', 'ALL'] as ChartPeriod[]).map((period) => (
|
||||
<button
|
||||
key={period}
|
||||
onClick={() => setChartPeriod(period)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 text-xs font-medium rounded-md transition-all",
|
||||
chartPeriod === period
|
||||
? "bg-accent text-background"
|
||||
: "text-foreground-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{period}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pl-14">
|
||||
<PriceChart data={filteredHistory} chartStats={chartStats} />
|
||||
</div>
|
||||
|
||||
{/* Chart Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mt-6 pt-6 border-t border-border/30">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-foreground-subtle uppercase mb-1">Period High</p>
|
||||
<p className="text-lg font-medium text-foreground tabular-nums">${chartStats.high.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-foreground-subtle uppercase mb-1">Average</p>
|
||||
<p className="text-lg font-medium text-foreground tabular-nums">${chartStats.avg.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-foreground-subtle uppercase mb-1">Period Low</p>
|
||||
<p className="text-lg font-medium text-foreground tabular-nums">${chartStats.low.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registrar Comparison */}
|
||||
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
|
||||
<h2 className="text-lg font-medium text-foreground mb-6">Registrar Comparison</h2>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/30">
|
||||
<th className="text-left pb-3 text-sm font-medium text-foreground-muted">Registrar</th>
|
||||
<th className="text-right pb-3 text-sm font-medium text-foreground-muted" title="First year registration price">Register</th>
|
||||
<th className="text-right pb-3 text-sm font-medium text-foreground-muted" title="Annual renewal price">Renew</th>
|
||||
<th className="text-right pb-3 text-sm font-medium text-foreground-muted" title="Transfer from another registrar">Transfer</th>
|
||||
<th className="text-right pb-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/20">
|
||||
{details.registrars.map((registrar, idx) => {
|
||||
const hasRenewalTrap = registrar.renewal_price / registrar.registration_price > 1.5
|
||||
const isBestValue = idx === 0 && !hasRenewalTrap
|
||||
|
||||
return (
|
||||
<tr key={registrar.name} className={clsx(isBestValue && "bg-accent/5")}>
|
||||
<td className="py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">{registrar.name}</span>
|
||||
{isBestValue && (
|
||||
<span
|
||||
className="px-2 py-0.5 text-xs bg-accent/10 text-accent rounded-full cursor-help"
|
||||
title="Best overall value: lowest registration price without renewal trap"
|
||||
>
|
||||
Best
|
||||
</span>
|
||||
)}
|
||||
{idx === 0 && hasRenewalTrap && (
|
||||
<span
|
||||
className="px-2 py-0.5 text-xs bg-amber-500/10 text-amber-400 rounded-full cursor-help"
|
||||
title="Cheapest registration but high renewal costs"
|
||||
>
|
||||
Cheap Start
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 text-right">
|
||||
<span
|
||||
className={clsx(
|
||||
"font-medium tabular-nums cursor-help",
|
||||
isBestValue ? "text-accent" : "text-foreground"
|
||||
)}
|
||||
title={`First year: $${registrar.registration_price.toFixed(2)}`}
|
||||
>
|
||||
${registrar.registration_price.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 text-right">
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<span
|
||||
className={clsx(
|
||||
"tabular-nums cursor-help",
|
||||
hasRenewalTrap ? "text-amber-400" : "text-foreground-muted"
|
||||
)}
|
||||
title={hasRenewalTrap
|
||||
? `Renewal is ${(registrar.renewal_price / registrar.registration_price).toFixed(1)}x the registration price`
|
||||
: `Annual renewal: $${registrar.renewal_price.toFixed(2)}`}
|
||||
>
|
||||
${registrar.renewal_price.toFixed(2)}
|
||||
</span>
|
||||
{hasRenewalTrap && (
|
||||
<AlertTriangle
|
||||
className="w-3.5 h-3.5 text-amber-400 cursor-help"
|
||||
title={`Renewal trap: ${(registrar.renewal_price / registrar.registration_price).toFixed(1)}x registration price`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 text-right">
|
||||
<span
|
||||
className="text-foreground-muted tabular-nums cursor-help"
|
||||
title={`Transfer from another registrar: $${registrar.transfer_price.toFixed(2)}`}
|
||||
>
|
||||
${registrar.transfer_price.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 text-right">
|
||||
<a
|
||||
href={getRegistrarUrl(registrar.name)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-accent hover:text-accent/80 transition-colors"
|
||||
title={`Register at ${registrar.name}`}
|
||||
>
|
||||
Visit
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Domain Check */}
|
||||
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
|
||||
<h2 className="text-lg font-medium text-foreground mb-4">Quick Domain Check</h2>
|
||||
<p className="text-sm text-foreground-muted mb-4">
|
||||
Check if a domain is available with .{tld}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
value={domainSearch}
|
||||
onChange={(e) => setDomainSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleDomainCheck()}
|
||||
placeholder={`example or example.${tld}`}
|
||||
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl
|
||||
text-sm text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:border-accent/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDomainCheck}
|
||||
disabled={checkingDomain || !domainSearch.trim()}
|
||||
className="h-11 px-6 bg-accent text-background font-medium rounded-xl
|
||||
hover:bg-accent-hover transition-all disabled:opacity-50"
|
||||
>
|
||||
{checkingDomain ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'Check'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
{domainResult && (
|
||||
<div className={clsx(
|
||||
"mt-4 p-4 rounded-xl border",
|
||||
domainResult.is_available
|
||||
? "bg-accent/10 border-accent/30"
|
||||
: "bg-foreground/5 border-border/50"
|
||||
)}>
|
||||
<div className="flex items-center gap-3">
|
||||
{domainResult.is_available ? (
|
||||
<Check className="w-5 h-5 text-accent" />
|
||||
) : (
|
||||
<X className="w-5 h-5 text-foreground-subtle" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{domainResult.domain}</p>
|
||||
<p className="text-sm text-foreground-muted">
|
||||
{domainResult.is_available ? 'Available for registration!' : 'Already registered'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{domainResult.is_available && (
|
||||
<a
|
||||
href={getRegistrarUrl(details.cheapest_registrar, domainResult.domain)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-accent text-background text-sm font-medium rounded-lg hover:bg-accent-hover transition-all"
|
||||
>
|
||||
Register at {details.cheapest_registrar}
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TLD Info */}
|
||||
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
|
||||
<h2 className="text-lg font-medium text-foreground mb-4">TLD Information</h2>
|
||||
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="p-4 bg-background/50 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-foreground-muted mb-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
<span className="text-xs uppercase">Type</span>
|
||||
</div>
|
||||
<p className="font-medium text-foreground capitalize">{details.type}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background/50 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-foreground-muted mb-2">
|
||||
<Building className="w-4 h-4" />
|
||||
<span className="text-xs uppercase">Registry</span>
|
||||
</div>
|
||||
<p className="font-medium text-foreground">{details.registry}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background/50 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-foreground-muted mb-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span className="text-xs uppercase">Introduced</span>
|
||||
</div>
|
||||
<p className="font-medium text-foreground">{details.introduced || 'Unknown'}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background/50 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-foreground-muted mb-2">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
<span className="text-xs uppercase">Registrars</span>
|
||||
</div>
|
||||
<p className="font-medium text-foreground">{details.registrars.length} tracked</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
387
frontend/src/app/command/pricing/page.tsx
Executable file
387
frontend/src/app/command/pricing/page.tsx
Executable file
@ -0,0 +1,387 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import {
|
||||
PremiumTable,
|
||||
StatCard,
|
||||
PageContainer,
|
||||
SearchInput,
|
||||
TabBar,
|
||||
FilterBar,
|
||||
SelectDropdown,
|
||||
ActionButton,
|
||||
} from '@/components/PremiumTable'
|
||||
import {
|
||||
TrendingUp,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
DollarSign,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
Cpu,
|
||||
MapPin,
|
||||
Coins,
|
||||
Crown,
|
||||
Info,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface TLDData {
|
||||
tld: string
|
||||
min_price: number
|
||||
avg_price: number
|
||||
max_price: number
|
||||
min_renewal_price: number
|
||||
avg_renewal_price: number
|
||||
cheapest_registrar?: string
|
||||
cheapest_registrar_url?: string
|
||||
price_change_7d: number
|
||||
price_change_1y: number
|
||||
price_change_3y: number
|
||||
risk_level: 'low' | 'medium' | 'high'
|
||||
risk_reason: string
|
||||
popularity_rank?: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
// Category definitions
|
||||
const CATEGORIES = [
|
||||
{ id: 'all', label: 'All', icon: Globe },
|
||||
{ id: 'tech', label: 'Tech', icon: Cpu },
|
||||
{ id: 'geo', label: 'Geo', icon: MapPin },
|
||||
{ id: 'budget', label: 'Budget', icon: Coins },
|
||||
{ id: 'premium', label: 'Premium', icon: Crown },
|
||||
]
|
||||
|
||||
const CATEGORY_FILTERS: Record<string, (tld: TLDData) => boolean> = {
|
||||
all: () => true,
|
||||
tech: (tld) => ['ai', 'io', 'app', 'dev', 'tech', 'code', 'cloud', 'data', 'api', 'software'].includes(tld.tld),
|
||||
geo: (tld) => ['ch', 'de', 'uk', 'us', 'fr', 'it', 'es', 'nl', 'at', 'eu', 'co', 'ca', 'au', 'nz', 'jp', 'cn', 'in', 'br', 'mx', 'nyc', 'london', 'paris', 'berlin', 'tokyo', 'swiss'].includes(tld.tld),
|
||||
budget: (tld) => tld.min_price < 5,
|
||||
premium: (tld) => tld.min_price >= 50,
|
||||
}
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'popularity', label: 'By Popularity' },
|
||||
{ value: 'price_asc', label: 'Price: Low → High' },
|
||||
{ value: 'price_desc', label: 'Price: High → Low' },
|
||||
{ value: 'change', label: 'By Price Change' },
|
||||
{ value: 'risk', label: 'By Risk Level' },
|
||||
]
|
||||
|
||||
// Memoized Sparkline
|
||||
const Sparkline = memo(function Sparkline({ trend }: { trend: number }) {
|
||||
const isPositive = trend > 0
|
||||
const isNeutral = trend === 0
|
||||
|
||||
return (
|
||||
<svg width="40" height="16" viewBox="0 0 40 16" className="overflow-visible">
|
||||
{isNeutral ? (
|
||||
<line x1="0" y1="8" x2="40" y2="8" stroke="currentColor" className="text-foreground-muted" strokeWidth="1.5" />
|
||||
) : isPositive ? (
|
||||
<polyline
|
||||
points="0,14 10,12 20,10 30,6 40,2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-orange-400"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
) : (
|
||||
<polyline
|
||||
points="0,2 10,4 20,8 30,12 40,14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-accent"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
})
|
||||
|
||||
export default function TLDPricingPage() {
|
||||
const { subscription } = useStore()
|
||||
|
||||
const [tldData, setTldData] = useState<TLDData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState('popularity')
|
||||
const [category, setCategory] = useState('all')
|
||||
const [page, setPage] = useState(0)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
const loadTLDData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await api.getTldOverview(
|
||||
50,
|
||||
page * 50,
|
||||
sortBy === 'risk' || sortBy === 'change' ? 'popularity' : sortBy as any,
|
||||
)
|
||||
const mapped: TLDData[] = (response.tlds || []).map((tld) => ({
|
||||
tld: tld.tld,
|
||||
min_price: tld.min_registration_price,
|
||||
avg_price: tld.avg_registration_price,
|
||||
max_price: tld.max_registration_price,
|
||||
min_renewal_price: tld.min_renewal_price,
|
||||
avg_renewal_price: tld.avg_renewal_price,
|
||||
price_change_7d: tld.price_change_7d,
|
||||
price_change_1y: tld.price_change_1y,
|
||||
price_change_3y: tld.price_change_3y,
|
||||
risk_level: tld.risk_level,
|
||||
risk_reason: tld.risk_reason,
|
||||
popularity_rank: tld.popularity_rank,
|
||||
type: tld.type,
|
||||
}))
|
||||
setTldData(mapped)
|
||||
setTotal(response.total || 0)
|
||||
} catch (error) {
|
||||
console.error('Failed to load TLD data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, sortBy])
|
||||
|
||||
useEffect(() => {
|
||||
loadTLDData()
|
||||
}, [loadTLDData])
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await loadTLDData()
|
||||
setRefreshing(false)
|
||||
}, [loadTLDData])
|
||||
|
||||
// Memoized filtered and sorted data
|
||||
const sortedData = useMemo(() => {
|
||||
let data = tldData.filter(CATEGORY_FILTERS[category] || (() => true))
|
||||
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
data = data.filter(tld => tld.tld.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
if (sortBy === 'risk') {
|
||||
const riskOrder = { high: 0, medium: 1, low: 2 }
|
||||
data = [...data].sort((a, b) => riskOrder[a.risk_level] - riskOrder[b.risk_level])
|
||||
}
|
||||
|
||||
return data
|
||||
}, [tldData, category, searchQuery, sortBy])
|
||||
|
||||
// Memoized stats
|
||||
const stats = useMemo(() => {
|
||||
const lowestPrice = tldData.length > 0
|
||||
? tldData.reduce((min, tld) => Math.min(min, tld.min_price), Infinity)
|
||||
: 0.99
|
||||
const hottestTld = tldData.find(tld => (tld.price_change_7d || 0) > 5)?.tld || 'ai'
|
||||
const trapCount = tldData.filter(tld => tld.risk_level === 'high' || tld.risk_level === 'medium').length
|
||||
return { lowestPrice, hottestTld, trapCount }
|
||||
}, [tldData])
|
||||
|
||||
const subtitle = useMemo(() => {
|
||||
if (loading && total === 0) return 'Loading TLD pricing data...'
|
||||
if (total === 0) return 'No TLD data available'
|
||||
return `Tracking ${total.toLocaleString()} TLDs • Updated daily`
|
||||
}, [loading, total])
|
||||
|
||||
// Memoized columns
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
key: 'tld',
|
||||
header: 'TLD',
|
||||
width: '100px',
|
||||
render: (tld: TLDData) => (
|
||||
<span className="font-mono text-lg font-semibold text-foreground group-hover:text-accent transition-colors">
|
||||
.{tld.tld}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'trend',
|
||||
header: 'Trend',
|
||||
width: '80px',
|
||||
hideOnMobile: true,
|
||||
render: (tld: TLDData) => <Sparkline trend={tld.price_change_1y || 0} />,
|
||||
},
|
||||
{
|
||||
key: 'buy_price',
|
||||
header: 'Buy (1y)',
|
||||
align: 'right' as const,
|
||||
width: '100px',
|
||||
render: (tld: TLDData) => (
|
||||
<span className="font-semibold text-foreground tabular-nums">${tld.min_price.toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'renew_price',
|
||||
header: 'Renew (1y)',
|
||||
align: 'right' as const,
|
||||
width: '120px',
|
||||
render: (tld: TLDData) => {
|
||||
const ratio = tld.min_renewal_price / tld.min_price
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<span className="text-foreground-muted tabular-nums">${tld.min_renewal_price.toFixed(2)}</span>
|
||||
{ratio > 2 && (
|
||||
<span className="text-amber-400" title={`Renewal is ${ratio.toFixed(1)}x registration`}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'change_1y',
|
||||
header: '1y',
|
||||
align: 'right' as const,
|
||||
width: '80px',
|
||||
hideOnMobile: true,
|
||||
render: (tld: TLDData) => {
|
||||
const change = tld.price_change_1y || 0
|
||||
return (
|
||||
<span className={clsx("font-medium tabular-nums", change > 0 ? "text-orange-400" : change < 0 ? "text-accent" : "text-foreground-muted")}>
|
||||
{change > 0 ? '+' : ''}{change.toFixed(0)}%
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'change_3y',
|
||||
header: '3y',
|
||||
align: 'right' as const,
|
||||
width: '80px',
|
||||
hideOnMobile: true,
|
||||
render: (tld: TLDData) => {
|
||||
const change = tld.price_change_3y || 0
|
||||
return (
|
||||
<span className={clsx("font-medium tabular-nums", change > 0 ? "text-orange-400" : change < 0 ? "text-accent" : "text-foreground-muted")}>
|
||||
{change > 0 ? '+' : ''}{change.toFixed(0)}%
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'risk',
|
||||
header: 'Risk',
|
||||
align: 'center' as const,
|
||||
width: '120px',
|
||||
render: (tld: TLDData) => (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
tld.risk_level === 'high' && "bg-red-500/10 text-red-400",
|
||||
tld.risk_level === 'medium' && "bg-amber-500/10 text-amber-400",
|
||||
tld.risk_level === 'low' && "bg-accent/10 text-accent"
|
||||
)}>
|
||||
<span className={clsx(
|
||||
"w-2 h-2 rounded-full",
|
||||
tld.risk_level === 'high' && "bg-red-400",
|
||||
tld.risk_level === 'medium' && "bg-amber-400",
|
||||
tld.risk_level === 'low' && "bg-accent"
|
||||
)} />
|
||||
<span className="hidden sm:inline">{tld.risk_reason}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right' as const,
|
||||
width: '50px',
|
||||
render: () => <ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />,
|
||||
},
|
||||
], [])
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="TLD Pricing"
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
<ActionButton onClick={handleRefresh} disabled={refreshing} variant="ghost" icon={refreshing ? Loader2 : RefreshCw}>
|
||||
{refreshing ? '' : 'Refresh'}
|
||||
</ActionButton>
|
||||
}
|
||||
>
|
||||
<PageContainer>
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard title="TLDs Tracked" value={total > 0 ? total.toLocaleString() : '—'} subtitle="updated daily" icon={Globe} />
|
||||
<StatCard title="Lowest Price" value={total > 0 ? `$${stats.lowestPrice.toFixed(2)}` : '—'} icon={DollarSign} />
|
||||
<StatCard title="Hottest TLD" value={total > 0 ? `.${stats.hottestTld}` : '—'} subtitle="rising prices" icon={TrendingUp} />
|
||||
<StatCard title="Renewal Traps" value={stats.trapCount.toString()} subtitle="high renewal ratio" icon={AlertTriangle} />
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
<TabBar
|
||||
tabs={CATEGORIES.map(c => ({ id: c.id, label: c.label, icon: c.icon }))}
|
||||
activeTab={category}
|
||||
onChange={setCategory}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<FilterBar>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search TLDs (e.g. com, io, dev)..."
|
||||
className="flex-1 max-w-md"
|
||||
/>
|
||||
<SelectDropdown value={sortBy} onChange={setSortBy} options={SORT_OPTIONS} />
|
||||
</FilterBar>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-foreground-muted">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<span>Tip: Renewal traps show ⚠️ when renewal price is >2x registration</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TLD Table */}
|
||||
<PremiumTable
|
||||
data={sortedData}
|
||||
keyExtractor={(tld) => tld.tld}
|
||||
loading={loading}
|
||||
onRowClick={(tld) => window.location.href = `/command/pricing/${tld.tld}`}
|
||||
emptyIcon={<Globe className="w-12 h-12 text-foreground-subtle" />}
|
||||
emptyTitle="No TLDs found"
|
||||
emptyDescription={searchQuery ? `No TLDs matching "${searchQuery}"` : "Check back later for TLD data"}
|
||||
columns={columns}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > 50 && (
|
||||
<div className="flex items-center justify-center gap-4 pt-2">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground bg-foreground/5 hover:bg-foreground/10 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-foreground-muted tabular-nums">
|
||||
Page {page + 1} of {Math.ceil(total / 50)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={(page + 1) * 50 >= total}
|
||||
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground bg-foreground/5 hover:bg-foreground/10 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
508
frontend/src/app/command/seo/page.tsx
Normal file
508
frontend/src/app/command/seo/page.tsx
Normal file
@ -0,0 +1,508 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import { PageContainer, StatCard, Badge } from '@/components/PremiumTable'
|
||||
import {
|
||||
Search,
|
||||
Link2,
|
||||
Globe,
|
||||
Shield,
|
||||
TrendingUp,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
X,
|
||||
ExternalLink,
|
||||
Crown,
|
||||
CheckCircle,
|
||||
Sparkles,
|
||||
BookOpen,
|
||||
Building,
|
||||
GraduationCap,
|
||||
Newspaper,
|
||||
Lock,
|
||||
Star,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface SEOData {
|
||||
domain: string
|
||||
seo_score: number
|
||||
value_category: string
|
||||
metrics: {
|
||||
domain_authority: number | null
|
||||
page_authority: number | null
|
||||
spam_score: number | null
|
||||
total_backlinks: number | null
|
||||
referring_domains: number | null
|
||||
}
|
||||
notable_links: {
|
||||
has_wikipedia: boolean
|
||||
has_gov: boolean
|
||||
has_edu: boolean
|
||||
has_news: boolean
|
||||
notable_domains: string[]
|
||||
}
|
||||
top_backlinks: Array<{
|
||||
domain: string
|
||||
authority: number
|
||||
page: string
|
||||
}>
|
||||
estimated_value: number | null
|
||||
data_source: string
|
||||
last_updated: string | null
|
||||
is_estimated: boolean
|
||||
}
|
||||
|
||||
export default function SEOPage() {
|
||||
const { subscription } = useStore()
|
||||
|
||||
const [domain, setDomain] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [seoData, setSeoData] = useState<SEOData | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([])
|
||||
|
||||
const tier = subscription?.tier?.toLowerCase() || 'scout'
|
||||
const isTycoon = tier === 'tycoon'
|
||||
|
||||
useEffect(() => {
|
||||
// Load recent searches from localStorage
|
||||
const saved = localStorage.getItem('seo-recent-searches')
|
||||
if (saved) {
|
||||
setRecentSearches(JSON.parse(saved))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const saveRecentSearch = (domain: string) => {
|
||||
const updated = [domain, ...recentSearches.filter(d => d !== domain)].slice(0, 5)
|
||||
setRecentSearches(updated)
|
||||
localStorage.setItem('seo-recent-searches', JSON.stringify(updated))
|
||||
}
|
||||
|
||||
const cleanDomain = (d: string): string => {
|
||||
// Remove whitespace, protocol, www, and trailing slashes
|
||||
return d.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/^www\./, '')
|
||||
.replace(/\/.*$/, '')
|
||||
}
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const cleanedDomain = cleanDomain(domain)
|
||||
if (!cleanedDomain) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSeoData(null)
|
||||
|
||||
try {
|
||||
const data = await api.request<SEOData>(`/seo/${encodeURIComponent(cleanedDomain)}`)
|
||||
setSeoData(data)
|
||||
saveRecentSearch(cleanedDomain)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to analyze domain')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickSearch = async (searchDomain: string) => {
|
||||
const cleanedDomain = cleanDomain(searchDomain)
|
||||
setDomain(cleanedDomain)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSeoData(null)
|
||||
|
||||
try {
|
||||
const data = await api.request<SEOData>(`/seo/${encodeURIComponent(cleanedDomain)}`)
|
||||
setSeoData(data)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to analyze domain')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 60) return 'text-accent'
|
||||
if (score >= 40) return 'text-amber-400'
|
||||
if (score >= 20) return 'text-orange-400'
|
||||
return 'text-foreground-muted'
|
||||
}
|
||||
|
||||
const getScoreBg = (score: number) => {
|
||||
if (score >= 60) return 'bg-accent/10 border-accent/30'
|
||||
if (score >= 40) return 'bg-amber-500/10 border-amber-500/30'
|
||||
if (score >= 20) return 'bg-orange-500/10 border-orange-500/30'
|
||||
return 'bg-foreground/5 border-border'
|
||||
}
|
||||
|
||||
const formatNumber = (num: number | null) => {
|
||||
if (num === null) return '-'
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
// Show upgrade prompt for non-Tycoon users
|
||||
if (!isTycoon) {
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="SEO Juice Detector"
|
||||
subtitle="Backlink analysis & domain authority"
|
||||
>
|
||||
<PageContainer>
|
||||
<div className="text-center py-16 bg-gradient-to-br from-accent/10 to-accent/5 border border-accent/20 rounded-2xl">
|
||||
<div className="w-20 h-20 bg-accent/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Crown className="w-10 h-10 text-accent" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-3">Tycoon Feature</h2>
|
||||
<p className="text-foreground-muted max-w-lg mx-auto mb-8">
|
||||
SEO Juice Detector is a premium feature for serious domain investors.
|
||||
Analyze backlinks, domain authority, and find hidden gems that SEO agencies pay
|
||||
$100-$500 for — even if the name is "ugly".
|
||||
</p>
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-4 max-w-2xl mx-auto mb-8">
|
||||
<div className="p-4 bg-background/50 rounded-xl">
|
||||
<Link2 className="w-6 h-6 text-accent mx-auto mb-2" />
|
||||
<p className="text-sm text-foreground font-medium">Backlink Analysis</p>
|
||||
<p className="text-xs text-foreground-muted">Top referring domains</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background/50 rounded-xl">
|
||||
<TrendingUp className="w-6 h-6 text-accent mx-auto mb-2" />
|
||||
<p className="text-sm text-foreground font-medium">Domain Authority</p>
|
||||
<p className="text-xs text-foreground-muted">Moz DA/PA scores</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background/50 rounded-xl">
|
||||
<Star className="w-6 h-6 text-accent mx-auto mb-2" />
|
||||
<p className="text-sm text-foreground font-medium">Notable Links</p>
|
||||
<p className="text-xs text-foreground-muted">Wikipedia, .gov, .edu</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||
>
|
||||
<Crown className="w-5 h-5" />
|
||||
Upgrade to Tycoon
|
||||
</Link>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="SEO Juice Detector"
|
||||
subtitle="Analyze backlinks, domain authority & find hidden SEO gems"
|
||||
>
|
||||
<PageContainer>
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400" />
|
||||
<p className="text-sm text-red-400 flex-1">{error}</p>
|
||||
<button onClick={() => setError(null)}><X className="w-4 h-4 text-red-400" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Form */}
|
||||
<div className="p-6 bg-background-secondary/30 border border-border rounded-2xl">
|
||||
<form onSubmit={handleSearch} className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Globe className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
|
||||
<input
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
placeholder="Enter domain to analyze (e.g., example.com)"
|
||||
className="w-full pl-12 pr-4 py-3 bg-background border border-border rounded-xl
|
||||
text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !domain.trim()}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl
|
||||
hover:bg-accent-hover transition-all disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-5 h-5" />
|
||||
)}
|
||||
Analyze
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Recent Searches */}
|
||||
{recentSearches.length > 0 && !seoData && (
|
||||
<div className="mt-4 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-foreground-muted">Recent:</span>
|
||||
{recentSearches.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => handleQuickSearch(d)}
|
||||
className="px-3 py-1 text-xs bg-foreground/5 text-foreground-muted rounded-full hover:bg-foreground/10 transition-colors"
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<Loader2 className="w-8 h-8 text-accent animate-spin mb-4" />
|
||||
<p className="text-foreground-muted">Analyzing backlinks & authority...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{seoData && !loading && (
|
||||
<div className="space-y-6 animate-slide-up">
|
||||
{/* Header with Score */}
|
||||
<div className="p-6 bg-background-secondary/30 border border-border rounded-2xl">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="font-mono text-2xl font-medium text-foreground mb-1">
|
||||
{seoData.domain}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={seoData.is_estimated ? 'warning' : 'success'}>
|
||||
{seoData.data_source === 'moz' ? 'Moz Data' : 'Estimated'}
|
||||
</Badge>
|
||||
<span className="text-sm text-foreground-muted">{seoData.value_category}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={clsx(
|
||||
"w-24 h-24 rounded-2xl border flex flex-col items-center justify-center",
|
||||
getScoreBg(seoData.seo_score)
|
||||
)}>
|
||||
<span className={clsx("text-3xl font-semibold", getScoreColor(seoData.seo_score))}>
|
||||
{seoData.seo_score}
|
||||
</span>
|
||||
<span className="text-xs text-foreground-muted">SEO Score</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Estimated Value */}
|
||||
{seoData.estimated_value && (
|
||||
<div className="mt-4 p-4 bg-accent/10 border border-accent/20 rounded-xl">
|
||||
<p className="text-sm text-foreground-muted mb-1">Estimated SEO Value</p>
|
||||
<p className="text-2xl font-semibold text-accent">
|
||||
${seoData.estimated_value.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-foreground-subtle mt-1">
|
||||
Based on domain authority & backlink profile
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<StatCard
|
||||
title="Domain Authority"
|
||||
value={seoData.metrics.domain_authority || 0}
|
||||
icon={TrendingUp}
|
||||
subtitle="/100"
|
||||
/>
|
||||
<StatCard
|
||||
title="Page Authority"
|
||||
value={seoData.metrics.page_authority || 0}
|
||||
icon={Globe}
|
||||
subtitle="/100"
|
||||
/>
|
||||
<StatCard
|
||||
title="Backlinks"
|
||||
value={formatNumber(seoData.metrics.total_backlinks)}
|
||||
icon={Link2}
|
||||
/>
|
||||
<StatCard
|
||||
title="Referring Domains"
|
||||
value={formatNumber(seoData.metrics.referring_domains)}
|
||||
icon={ExternalLink}
|
||||
/>
|
||||
<StatCard
|
||||
title="Spam Score"
|
||||
value={seoData.metrics.spam_score || 0}
|
||||
icon={Shield}
|
||||
subtitle={seoData.metrics.spam_score && seoData.metrics.spam_score > 30 ? '⚠️ High' : '✓ Low'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notable Links */}
|
||||
<div className="p-6 bg-background-secondary/30 border border-border rounded-2xl">
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">Notable Backlinks</h3>
|
||||
<div className="grid sm:grid-cols-4 gap-4">
|
||||
<div className={clsx(
|
||||
"p-4 rounded-xl border flex items-center gap-3",
|
||||
seoData.notable_links.has_wikipedia
|
||||
? "bg-accent/10 border-accent/30"
|
||||
: "bg-foreground/5 border-border"
|
||||
)}>
|
||||
<BookOpen className={clsx(
|
||||
"w-6 h-6",
|
||||
seoData.notable_links.has_wikipedia ? "text-accent" : "text-foreground-subtle"
|
||||
)} />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Wikipedia</p>
|
||||
<p className="text-xs text-foreground-muted">
|
||||
{seoData.notable_links.has_wikipedia ? '✓ Found' : 'Not found'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={clsx(
|
||||
"p-4 rounded-xl border flex items-center gap-3",
|
||||
seoData.notable_links.has_gov
|
||||
? "bg-accent/10 border-accent/30"
|
||||
: "bg-foreground/5 border-border"
|
||||
)}>
|
||||
<Building className={clsx(
|
||||
"w-6 h-6",
|
||||
seoData.notable_links.has_gov ? "text-accent" : "text-foreground-subtle"
|
||||
)} />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">.gov Links</p>
|
||||
<p className="text-xs text-foreground-muted">
|
||||
{seoData.notable_links.has_gov ? '✓ Found' : 'Not found'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={clsx(
|
||||
"p-4 rounded-xl border flex items-center gap-3",
|
||||
seoData.notable_links.has_edu
|
||||
? "bg-accent/10 border-accent/30"
|
||||
: "bg-foreground/5 border-border"
|
||||
)}>
|
||||
<GraduationCap className={clsx(
|
||||
"w-6 h-6",
|
||||
seoData.notable_links.has_edu ? "text-accent" : "text-foreground-subtle"
|
||||
)} />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">.edu Links</p>
|
||||
<p className="text-xs text-foreground-muted">
|
||||
{seoData.notable_links.has_edu ? '✓ Found' : 'Not found'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={clsx(
|
||||
"p-4 rounded-xl border flex items-center gap-3",
|
||||
seoData.notable_links.has_news
|
||||
? "bg-accent/10 border-accent/30"
|
||||
: "bg-foreground/5 border-border"
|
||||
)}>
|
||||
<Newspaper className={clsx(
|
||||
"w-6 h-6",
|
||||
seoData.notable_links.has_news ? "text-accent" : "text-foreground-subtle"
|
||||
)} />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">News Sites</p>
|
||||
<p className="text-xs text-foreground-muted">
|
||||
{seoData.notable_links.has_news ? '✓ Found' : 'Not found'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notable Domains List */}
|
||||
{seoData.notable_links.notable_domains.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-foreground-muted mb-2">High-authority referring domains:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{seoData.notable_links.notable_domains.map((d) => (
|
||||
<span key={d} className="px-3 py-1 bg-accent/10 text-accent text-sm rounded-full">
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top Backlinks */}
|
||||
{seoData.top_backlinks.length > 0 && (
|
||||
<div className="p-6 bg-background-secondary/30 border border-border rounded-2xl">
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">Top Backlinks</h3>
|
||||
<div className="space-y-2">
|
||||
{seoData.top_backlinks.map((link, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-3 bg-background rounded-xl border border-border/50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={clsx(
|
||||
"w-8 h-8 rounded-lg flex items-center justify-center text-sm font-medium",
|
||||
link.authority >= 60 ? "bg-accent/10 text-accent" :
|
||||
link.authority >= 40 ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-foreground/5 text-foreground-muted"
|
||||
)}>
|
||||
{link.authority}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-mono text-sm text-foreground">{link.domain}</p>
|
||||
{link.page && (
|
||||
<p className="text-xs text-foreground-muted truncate max-w-xs">{link.page}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`https://${link.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 text-foreground-subtle hover:text-accent transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data Source Note */}
|
||||
{seoData.is_estimated && (
|
||||
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl">
|
||||
<p className="text-sm text-amber-400">
|
||||
<AlertCircle className="w-4 h-4 inline mr-2" />
|
||||
This data is estimated based on domain characteristics.
|
||||
For live Moz data, configure MOZ_ACCESS_ID and MOZ_SECRET_KEY in the backend.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!seoData && !loading && !error && (
|
||||
<div className="text-center py-16">
|
||||
<Sparkles className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
|
||||
<h2 className="text-xl font-medium text-foreground mb-2">SEO Juice Detector</h2>
|
||||
<p className="text-foreground-muted max-w-md mx-auto">
|
||||
Enter a domain above to analyze its backlink profile, domain authority,
|
||||
and find hidden SEO value that others miss.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
563
frontend/src/app/command/settings/page.tsx
Normal file
563
frontend/src/app/command/settings/page.tsx
Normal file
@ -0,0 +1,563 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import { PageContainer, TabBar } from '@/components/PremiumTable'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api, PriceAlert } from '@/lib/api'
|
||||
import {
|
||||
User,
|
||||
Bell,
|
||||
CreditCard,
|
||||
Shield,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
Crown,
|
||||
Zap,
|
||||
Key,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type SettingsTab = 'profile' | 'notifications' | 'billing' | 'security'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter()
|
||||
const { user, isAuthenticated, isLoading, checkAuth, subscription } = useStore()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('profile')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Profile form
|
||||
const [profileForm, setProfileForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
})
|
||||
|
||||
// Notification preferences
|
||||
const [notificationPrefs, setNotificationPrefs] = useState({
|
||||
domain_availability: true,
|
||||
price_alerts: true,
|
||||
weekly_digest: false,
|
||||
})
|
||||
const [savingNotifications, setSavingNotifications] = useState(false)
|
||||
|
||||
// Price alerts
|
||||
const [priceAlerts, setPriceAlerts] = useState<PriceAlert[]>([])
|
||||
const [loadingAlerts, setLoadingAlerts] = useState(false)
|
||||
const [deletingAlertId, setDeletingAlertId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [checkAuth])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setProfileForm({
|
||||
name: user.name || '',
|
||||
email: user.email || '',
|
||||
})
|
||||
}
|
||||
}, [user])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && activeTab === 'notifications') {
|
||||
loadPriceAlerts()
|
||||
}
|
||||
}, [isAuthenticated, activeTab])
|
||||
|
||||
const loadPriceAlerts = async () => {
|
||||
setLoadingAlerts(true)
|
||||
try {
|
||||
const alerts = await api.getPriceAlerts()
|
||||
setPriceAlerts(alerts)
|
||||
} catch (err) {
|
||||
console.error('Failed to load alerts:', err)
|
||||
} finally {
|
||||
setLoadingAlerts(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
await api.updateMe({ name: profileForm.name || undefined })
|
||||
const { checkAuth } = useStore.getState()
|
||||
await checkAuth()
|
||||
setSuccess('Profile updated successfully')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update profile')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveNotifications = async () => {
|
||||
setSavingNotifications(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
localStorage.setItem('notification_prefs', JSON.stringify(notificationPrefs))
|
||||
setSuccess('Notification preferences saved')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save preferences')
|
||||
} finally {
|
||||
setSavingNotifications(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('notification_prefs')
|
||||
if (saved) {
|
||||
try {
|
||||
setNotificationPrefs(JSON.parse(saved))
|
||||
} catch {}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDeletePriceAlert = async (tld: string, alertId: number) => {
|
||||
setDeletingAlertId(alertId)
|
||||
try {
|
||||
await api.deletePriceAlert(tld)
|
||||
setPriceAlerts(prev => prev.filter(a => a.id !== alertId))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete alert')
|
||||
} finally {
|
||||
setDeletingAlertId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenBillingPortal = async () => {
|
||||
try {
|
||||
const { portal_url } = await api.createPortalSession()
|
||||
window.location.href = portal_url
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to open billing portal')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||
const isProOrHigher = ['Trader', 'Tycoon', 'Professional', 'Enterprise'].includes(tierName)
|
||||
|
||||
const tabs = [
|
||||
{ id: 'profile' as const, label: 'Profile', icon: User },
|
||||
{ id: 'notifications' as const, label: 'Notifications', icon: Bell },
|
||||
{ id: 'billing' as const, label: 'Billing', icon: CreditCard },
|
||||
{ id: 'security' as const, label: 'Security', icon: Shield },
|
||||
]
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="Settings"
|
||||
subtitle="Manage your account"
|
||||
>
|
||||
<PageContainer>
|
||||
{/* Messages */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
|
||||
<p className="text-sm text-red-400 flex-1">{error}</p>
|
||||
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="p-4 bg-accent/10 border border-accent/20 rounded-xl flex items-center gap-3">
|
||||
<Check className="w-5 h-5 text-accent shrink-0" />
|
||||
<p className="text-sm text-accent flex-1">{success}</p>
|
||||
<button onClick={() => setSuccess(null)} className="text-accent hover:text-accent/80">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Sidebar */}
|
||||
<div className="lg:w-72 shrink-0 space-y-5">
|
||||
{/* Mobile: Horizontal scroll tabs */}
|
||||
<nav className="lg:hidden flex gap-2 overflow-x-auto pb-2 -mx-4 px-4 scrollbar-hide">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2.5 px-5 py-3 text-sm font-medium rounded-xl whitespace-nowrap transition-all",
|
||||
activeTab === tab.id
|
||||
? "bg-gradient-to-r from-accent to-accent/80 text-background shadow-lg shadow-accent/20"
|
||||
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground border border-border/50"
|
||||
)}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Desktop: Vertical tabs */}
|
||||
<nav className="hidden lg:block p-2 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 border border-border/40 rounded-2xl backdrop-blur-sm">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium rounded-xl transition-all",
|
||||
activeTab === tab.id
|
||||
? "bg-gradient-to-r from-accent to-accent/80 text-background shadow-lg shadow-accent/20"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
)}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Plan info */}
|
||||
<div className="hidden lg:block p-5 bg-accent/5 border border-accent/20 rounded-2xl">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{isProOrHigher ? <Crown className="w-5 h-5 text-accent" /> : <Zap className="w-5 h-5 text-accent" />}
|
||||
<span className="text-sm font-semibold text-foreground">{tierName} Plan</span>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-muted mb-4">
|
||||
{subscription?.domains_used || 0} / {subscription?.domain_limit || 5} domains tracked
|
||||
</p>
|
||||
{!isProOrHigher && (
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="flex items-center justify-center gap-2 w-full py-2.5 bg-gradient-to-r from-accent to-accent/80 text-background text-sm font-medium rounded-xl hover:shadow-[0_0_20px_-5px_rgba(16,185,129,0.4)] transition-all"
|
||||
>
|
||||
Upgrade
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Profile Tab */}
|
||||
{activeTab === 'profile' && (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm p-6">
|
||||
<h2 className="text-lg font-medium text-foreground mb-6">Profile Information</h2>
|
||||
|
||||
<form onSubmit={handleSaveProfile} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-2">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileForm.name}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, name: e.target.value })}
|
||||
placeholder="Your name"
|
||||
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
|
||||
placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={profileForm.email}
|
||||
disabled
|
||||
className="w-full h-11 px-4 bg-foreground/5 border border-border/50 rounded-xl text-foreground-muted cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-xs text-foreground-subtle mt-1.5">Email cannot be changed</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-accent to-accent/80 text-background font-medium rounded-xl
|
||||
hover:shadow-[0_0_20px_-5px_rgba(16,185,129,0.4)] disabled:opacity-50 transition-all"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
Save Changes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notifications Tab */}
|
||||
{activeTab === 'notifications' && (
|
||||
<div className="space-y-6">
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm p-6">
|
||||
<h2 className="text-lg font-medium text-foreground mb-5">Email Preferences</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ key: 'domain_availability', label: 'Domain Availability', desc: 'Get notified when watched domains become available' },
|
||||
{ key: 'price_alerts', label: 'Price Alerts', desc: 'Get notified when TLD prices change' },
|
||||
{ key: 'weekly_digest', label: 'Weekly Digest', desc: 'Receive a weekly summary of your portfolio' },
|
||||
].map((item) => (
|
||||
<label key={item.key} className="flex items-center justify-between p-4 bg-foreground/5 border border-border/30 rounded-xl cursor-pointer hover:border-foreground/20 transition-colors">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{item.label}</p>
|
||||
<p className="text-xs text-foreground-muted">{item.desc}</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationPrefs[item.key as keyof typeof notificationPrefs]}
|
||||
onChange={(e) => setNotificationPrefs({ ...notificationPrefs, [item.key]: e.target.checked })}
|
||||
className="w-5 h-5 accent-accent cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSaveNotifications}
|
||||
disabled={savingNotifications}
|
||||
className="mt-5 flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-accent to-accent/80 text-background font-medium rounded-xl
|
||||
hover:shadow-[0_0_20px_-5px_rgba(16,185,129,0.4)] disabled:opacity-50 transition-all"
|
||||
>
|
||||
{savingNotifications ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
Save Preferences
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Active Price Alerts */}
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm p-6">
|
||||
<h2 className="text-lg font-medium text-foreground mb-5">Active Price Alerts</h2>
|
||||
|
||||
{loadingAlerts ? (
|
||||
<div className="py-10 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-accent" />
|
||||
</div>
|
||||
) : priceAlerts.length === 0 ? (
|
||||
<div className="py-12 text-center border border-dashed border-border/50 rounded-xl bg-foreground/5">
|
||||
<Bell className="w-10 h-10 text-foreground-subtle mx-auto mb-4" />
|
||||
<p className="text-foreground-muted mb-3">No price alerts set</p>
|
||||
<Link href="/command/pricing" className="text-accent hover:text-accent/80 text-sm font-medium">
|
||||
Browse TLD prices →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{priceAlerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="flex items-center justify-between p-4 bg-foreground/5 border border-border/30 rounded-xl hover:border-foreground/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<div className={clsx(
|
||||
"w-2.5 h-2.5 rounded-full",
|
||||
alert.is_active ? "bg-accent" : "bg-foreground-subtle"
|
||||
)} />
|
||||
{alert.is_active && (
|
||||
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-40" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
href={`/tld-pricing/${alert.tld}`}
|
||||
className="text-sm font-mono font-medium text-foreground hover:text-accent transition-colors"
|
||||
>
|
||||
.{alert.tld}
|
||||
</Link>
|
||||
<p className="text-xs text-foreground-muted">
|
||||
Alert on {alert.threshold_percent}% change
|
||||
{alert.target_price && ` or below $${alert.target_price}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeletePriceAlert(alert.tld, alert.id)}
|
||||
disabled={deletingAlertId === alert.id}
|
||||
className="p-2 text-foreground-subtle hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-all"
|
||||
>
|
||||
{deletingAlertId === alert.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing Tab */}
|
||||
{activeTab === 'billing' && (
|
||||
<div className="space-y-6">
|
||||
{/* Current Plan */}
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm p-6">
|
||||
<h2 className="text-lg font-medium text-foreground mb-6">Your Current Plan</h2>
|
||||
|
||||
<div className="p-5 bg-accent/5 border border-accent/20 rounded-xl mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{tierName === 'Tycoon' ? <Crown className="w-6 h-6 text-accent" /> : tierName === 'Trader' ? <TrendingUp className="w-6 h-6 text-accent" /> : <Zap className="w-6 h-6 text-accent" />}
|
||||
<div>
|
||||
<p className="text-xl font-semibold text-foreground">{tierName}</p>
|
||||
<p className="text-sm text-foreground-muted">
|
||||
{tierName === 'Scout' ? 'Free forever' : tierName === 'Trader' ? '$9/month' : '$29/month'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
"px-3 py-1.5 text-xs font-medium rounded-full",
|
||||
isProOrHigher ? "bg-accent/10 text-accent" : "bg-foreground/5 text-foreground-muted"
|
||||
)}>
|
||||
{isProOrHigher ? 'Active' : 'Free'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Plan Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 p-4 bg-background/50 rounded-xl mb-4">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-semibold text-foreground">{subscription?.domain_limit || 5}</p>
|
||||
<p className="text-xs text-foreground-muted">Domains</p>
|
||||
</div>
|
||||
<div className="text-center border-x border-border/50">
|
||||
<p className="text-2xl font-semibold text-foreground">
|
||||
{subscription?.check_frequency === 'realtime' ? '10m' :
|
||||
subscription?.check_frequency === 'hourly' ? '1h' : '24h'}
|
||||
</p>
|
||||
<p className="text-xs text-foreground-muted">Check Interval</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-semibold text-foreground">
|
||||
{subscription?.portfolio_limit === -1 ? '∞' : subscription?.portfolio_limit || 0}
|
||||
</p>
|
||||
<p className="text-xs text-foreground-muted">Portfolio</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isProOrHigher ? (
|
||||
<button
|
||||
onClick={handleOpenBillingPortal}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 bg-background text-foreground font-medium rounded-xl border border-border/50
|
||||
hover:border-foreground/20 transition-all"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Manage Subscription
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="w-full flex items-center justify-center gap-2 py-3 bg-gradient-to-r from-accent to-accent/80 text-background font-medium rounded-xl
|
||||
hover:shadow-[0_0_20px_-5px_rgba(16,185,129,0.4)] transition-all"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
Upgrade Plan
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plan Features */}
|
||||
<h3 className="text-sm font-medium text-foreground mb-3">Your Plan Includes</h3>
|
||||
<ul className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
`${subscription?.domain_limit || 5} Watchlist Domains`,
|
||||
`${subscription?.check_frequency === 'realtime' ? '10-minute' : subscription?.check_frequency === 'hourly' ? 'Hourly' : 'Daily'} Scans`,
|
||||
'Email Alerts',
|
||||
'TLD Price Data',
|
||||
].map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm">
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
<span className="text-foreground">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security Tab */}
|
||||
{activeTab === 'security' && (
|
||||
<div className="space-y-6">
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm p-6">
|
||||
<h2 className="text-lg font-medium text-foreground mb-4">Password</h2>
|
||||
<p className="text-sm text-foreground-muted mb-5">
|
||||
Change your password or reset it if you've forgotten it.
|
||||
</p>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="inline-flex items-center gap-2 px-5 py-3 bg-foreground/5 border border-border/50 text-foreground font-medium rounded-xl
|
||||
hover:border-foreground/20 transition-all"
|
||||
>
|
||||
<Key className="w-4 h-4" />
|
||||
Change Password
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm p-6">
|
||||
<h2 className="text-lg font-medium text-foreground mb-5">Account Security</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-4 bg-foreground/5 border border-border/30 rounded-xl">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Email Verified</p>
|
||||
<p className="text-xs text-foreground-muted">Your email address has been verified</p>
|
||||
</div>
|
||||
<div className="w-8 h-8 bg-accent/10 rounded-lg flex items-center justify-center">
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-foreground/5 border border-border/30 rounded-xl">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Two-Factor Authentication</p>
|
||||
<p className="text-xs text-foreground-muted">Coming soon</p>
|
||||
</div>
|
||||
<span className="text-xs px-2.5 py-1 bg-foreground/5 text-foreground-muted rounded-full border border-border/30">Soon</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden rounded-2xl border border-red-500/20 bg-red-500/5 p-6">
|
||||
<h2 className="text-lg font-medium text-red-400 mb-2">Danger Zone</h2>
|
||||
<p className="text-sm text-foreground-muted mb-5">
|
||||
Permanently delete your account and all associated data.
|
||||
</p>
|
||||
<button
|
||||
className="px-5 py-3 bg-red-500 text-white font-medium rounded-xl hover:bg-red-500/90 transition-all"
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
620
frontend/src/app/command/watchlist/page.tsx
Executable file
620
frontend/src/app/command/watchlist/page.tsx
Executable file
@ -0,0 +1,620 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api, DomainHealthReport, HealthStatus } from '@/lib/api'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import {
|
||||
PremiumTable,
|
||||
Badge,
|
||||
StatCard,
|
||||
PageContainer,
|
||||
TableActionButton,
|
||||
SearchInput,
|
||||
TabBar,
|
||||
FilterBar,
|
||||
ActionButton,
|
||||
} from '@/components/PremiumTable'
|
||||
import { Toast, useToast } from '@/components/Toast'
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Bell,
|
||||
BellOff,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
Sparkles,
|
||||
ArrowUpRight,
|
||||
X,
|
||||
Activity,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
ShoppingCart,
|
||||
HelpCircle,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
|
||||
// Health status badge colors and icons
|
||||
const healthStatusConfig: Record<HealthStatus, {
|
||||
label: string
|
||||
color: string
|
||||
bgColor: string
|
||||
icon: typeof Activity
|
||||
description: string
|
||||
}> = {
|
||||
healthy: {
|
||||
label: 'Healthy',
|
||||
color: 'text-accent',
|
||||
bgColor: 'bg-accent/10 border-accent/20',
|
||||
icon: Activity,
|
||||
description: 'Domain is active and well-maintained'
|
||||
},
|
||||
weakening: {
|
||||
label: 'Weakening',
|
||||
color: 'text-amber-400',
|
||||
bgColor: 'bg-amber-400/10 border-amber-400/20',
|
||||
icon: AlertTriangle,
|
||||
description: 'Warning signs detected - owner may be losing interest'
|
||||
},
|
||||
parked: {
|
||||
label: 'For Sale',
|
||||
color: 'text-orange-400',
|
||||
bgColor: 'bg-orange-400/10 border-orange-400/20',
|
||||
icon: ShoppingCart,
|
||||
description: 'Domain is parked and likely for sale'
|
||||
},
|
||||
critical: {
|
||||
label: 'Critical',
|
||||
color: 'text-red-400',
|
||||
bgColor: 'bg-red-400/10 border-red-400/20',
|
||||
icon: AlertTriangle,
|
||||
description: 'Domain drop is imminent!'
|
||||
},
|
||||
unknown: {
|
||||
label: 'Unknown',
|
||||
color: 'text-foreground-muted',
|
||||
bgColor: 'bg-foreground/5 border-border/30',
|
||||
icon: HelpCircle,
|
||||
description: 'Could not determine status'
|
||||
},
|
||||
}
|
||||
|
||||
type FilterStatus = 'all' | 'available' | 'watching'
|
||||
|
||||
export default function WatchlistPage() {
|
||||
const { domains, addDomain, deleteDomain, refreshDomain, subscription } = useStore()
|
||||
const { toast, showToast, hideToast } = useToast()
|
||||
|
||||
const [newDomain, setNewDomain] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [refreshingId, setRefreshingId] = useState<number | null>(null)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [togglingNotifyId, setTogglingNotifyId] = useState<number | null>(null)
|
||||
const [filterStatus, setFilterStatus] = useState<FilterStatus>('all')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Health check state
|
||||
const [healthReports, setHealthReports] = useState<Record<number, DomainHealthReport>>({})
|
||||
const [loadingHealth, setLoadingHealth] = useState<Record<number, boolean>>({})
|
||||
const [selectedHealthDomainId, setSelectedHealthDomainId] = useState<number | null>(null)
|
||||
|
||||
// Memoized stats - avoids recalculation on every render
|
||||
const stats = useMemo(() => ({
|
||||
availableCount: domains?.filter(d => d.is_available).length || 0,
|
||||
watchingCount: domains?.filter(d => !d.is_available).length || 0,
|
||||
domainsUsed: domains?.length || 0,
|
||||
domainLimit: subscription?.domain_limit || 5,
|
||||
}), [domains, subscription?.domain_limit])
|
||||
|
||||
const canAddMore = stats.domainsUsed < stats.domainLimit
|
||||
|
||||
// Memoized filtered domains
|
||||
const filteredDomains = useMemo(() => {
|
||||
if (!domains) return []
|
||||
|
||||
return domains.filter(domain => {
|
||||
if (searchQuery && !domain.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
if (filterStatus === 'available' && !domain.is_available) return false
|
||||
if (filterStatus === 'watching' && domain.is_available) return false
|
||||
return true
|
||||
})
|
||||
}, [domains, searchQuery, filterStatus])
|
||||
|
||||
// Memoized tabs config
|
||||
const tabs = useMemo(() => [
|
||||
{ id: 'all', label: 'All', count: stats.domainsUsed },
|
||||
{ id: 'available', label: 'Available', count: stats.availableCount, color: 'accent' as const },
|
||||
{ id: 'watching', label: 'Monitoring', count: stats.watchingCount },
|
||||
], [stats])
|
||||
|
||||
// Callbacks - prevent recreation on every render
|
||||
const handleAddDomain = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newDomain.trim()) return
|
||||
|
||||
setAdding(true)
|
||||
try {
|
||||
await addDomain(newDomain.trim())
|
||||
setNewDomain('')
|
||||
showToast(`Added ${newDomain.trim()} to watchlist`, 'success')
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Failed to add domain', 'error')
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}, [newDomain, addDomain, showToast])
|
||||
|
||||
const handleRefresh = useCallback(async (id: number) => {
|
||||
setRefreshingId(id)
|
||||
try {
|
||||
await refreshDomain(id)
|
||||
showToast('Domain status refreshed', 'success')
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Failed to refresh', 'error')
|
||||
} finally {
|
||||
setRefreshingId(null)
|
||||
}
|
||||
}, [refreshDomain, showToast])
|
||||
|
||||
const handleDelete = useCallback(async (id: number, name: string) => {
|
||||
if (!confirm(`Remove ${name} from your watchlist?`)) return
|
||||
|
||||
setDeletingId(id)
|
||||
try {
|
||||
await deleteDomain(id)
|
||||
showToast(`Removed ${name} from watchlist`, 'success')
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Failed to remove', 'error')
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}, [deleteDomain, showToast])
|
||||
|
||||
const handleToggleNotify = useCallback(async (id: number, currentState: boolean) => {
|
||||
setTogglingNotifyId(id)
|
||||
try {
|
||||
await api.updateDomainNotify(id, !currentState)
|
||||
showToast(!currentState ? 'Notifications enabled' : 'Notifications disabled', 'success')
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Failed to update', 'error')
|
||||
} finally {
|
||||
setTogglingNotifyId(null)
|
||||
}
|
||||
}, [showToast])
|
||||
|
||||
const handleHealthCheck = useCallback(async (domainId: number) => {
|
||||
if (loadingHealth[domainId]) return
|
||||
|
||||
setLoadingHealth(prev => ({ ...prev, [domainId]: true }))
|
||||
try {
|
||||
const report = await api.getDomainHealth(domainId)
|
||||
setHealthReports(prev => ({ ...prev, [domainId]: report }))
|
||||
setSelectedHealthDomainId(domainId)
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Health check failed', 'error')
|
||||
} finally {
|
||||
setLoadingHealth(prev => ({ ...prev, [domainId]: false }))
|
||||
}
|
||||
}, [loadingHealth, showToast])
|
||||
|
||||
// Dynamic subtitle
|
||||
const subtitle = useMemo(() => {
|
||||
if (stats.domainsUsed === 0) return 'Start tracking domains to monitor their availability'
|
||||
return `Monitoring ${stats.domainsUsed} domain${stats.domainsUsed !== 1 ? 's' : ''} • ${stats.domainLimit === -1 ? 'Unlimited' : `${stats.domainLimit - stats.domainsUsed} slots left`}`
|
||||
}, [stats])
|
||||
|
||||
// Memoized columns config
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
key: 'domain',
|
||||
header: 'Domain',
|
||||
render: (domain: any) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<span className={clsx(
|
||||
"block w-3 h-3 rounded-full",
|
||||
domain.is_available ? "bg-accent" : "bg-foreground-muted/50"
|
||||
)} />
|
||||
{domain.is_available && (
|
||||
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-mono font-medium text-foreground">{domain.name}</span>
|
||||
{domain.is_available && (
|
||||
<Badge variant="success" size="xs" className="ml-2">AVAILABLE</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
align: 'left' as const,
|
||||
hideOnMobile: true,
|
||||
render: (domain: any) => {
|
||||
const health = healthReports[domain.id]
|
||||
if (health) {
|
||||
const config = healthStatusConfig[health.status]
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<div className={clsx("inline-flex items-center gap-2 px-2.5 py-1 rounded-lg border", config.bgColor)}>
|
||||
<Icon className={clsx("w-3.5 h-3.5", config.color)} />
|
||||
<span className={clsx("text-xs font-medium", config.color)}>{config.label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className={clsx(
|
||||
"text-sm",
|
||||
domain.is_available ? "text-accent font-medium" : "text-foreground-muted"
|
||||
)}>
|
||||
{domain.is_available ? 'Ready to pounce!' : 'Monitoring...'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'notifications',
|
||||
header: 'Alerts',
|
||||
align: 'center' as const,
|
||||
width: '80px',
|
||||
hideOnMobile: true,
|
||||
render: (domain: any) => (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleToggleNotify(domain.id, domain.notify_on_available)
|
||||
}}
|
||||
disabled={togglingNotifyId === domain.id}
|
||||
className={clsx(
|
||||
"p-2 rounded-lg transition-colors",
|
||||
domain.notify_on_available
|
||||
? "bg-accent/10 text-accent hover:bg-accent/20"
|
||||
: "text-foreground-muted hover:bg-foreground/5"
|
||||
)}
|
||||
title={domain.notify_on_available ? "Disable alerts" : "Enable alerts"}
|
||||
>
|
||||
{togglingNotifyId === domain.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : domain.notify_on_available ? (
|
||||
<Bell className="w-4 h-4" />
|
||||
) : (
|
||||
<BellOff className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right' as const,
|
||||
render: (domain: any) => (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<TableActionButton
|
||||
icon={Activity}
|
||||
onClick={() => handleHealthCheck(domain.id)}
|
||||
loading={loadingHealth[domain.id]}
|
||||
title="Health check (DNS, HTTP, SSL)"
|
||||
variant={healthReports[domain.id] ? 'accent' : 'default'}
|
||||
/>
|
||||
<TableActionButton
|
||||
icon={RefreshCw}
|
||||
onClick={() => handleRefresh(domain.id)}
|
||||
loading={refreshingId === domain.id}
|
||||
title="Refresh availability"
|
||||
/>
|
||||
<TableActionButton
|
||||
icon={Trash2}
|
||||
onClick={() => handleDelete(domain.id, domain.name)}
|
||||
variant="danger"
|
||||
loading={deletingId === domain.id}
|
||||
title="Remove"
|
||||
/>
|
||||
{domain.is_available && (
|
||||
<a
|
||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${domain.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-accent text-background text-xs font-medium
|
||||
rounded-lg hover:bg-accent-hover transition-colors ml-1"
|
||||
>
|
||||
Register <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], [healthReports, togglingNotifyId, loadingHealth, refreshingId, deletingId, handleToggleNotify, handleHealthCheck, handleRefresh, handleDelete])
|
||||
|
||||
return (
|
||||
<CommandCenterLayout title="Watchlist" subtitle={subtitle}>
|
||||
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
||||
|
||||
<PageContainer>
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard title="Total Watched" value={stats.domainsUsed} icon={Eye} />
|
||||
<StatCard title="Available" value={stats.availableCount} icon={Sparkles} />
|
||||
<StatCard title="Monitoring" value={stats.watchingCount} subtitle="active checks" icon={Activity} />
|
||||
<StatCard title="Plan Limit" value={stats.domainLimit === -1 ? '∞' : stats.domainLimit} subtitle={`${stats.domainsUsed} used`} icon={Shield} />
|
||||
</div>
|
||||
|
||||
{/* Add Domain Form */}
|
||||
<FilterBar>
|
||||
<SearchInput
|
||||
value={newDomain}
|
||||
onChange={setNewDomain}
|
||||
placeholder="Enter domain to track (e.g., dream.com)"
|
||||
className="flex-1"
|
||||
/>
|
||||
<ActionButton
|
||||
onClick={handleAddDomain}
|
||||
disabled={adding || !newDomain.trim() || !canAddMore}
|
||||
icon={adding ? Loader2 : Plus}
|
||||
>
|
||||
Add Domain
|
||||
</ActionButton>
|
||||
</FilterBar>
|
||||
|
||||
{!canAddMore && (
|
||||
<div className="flex items-center justify-between p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl">
|
||||
<p className="text-sm text-amber-400">
|
||||
You've reached your domain limit. Upgrade to track more.
|
||||
</p>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="text-sm font-medium text-amber-400 hover:text-amber-300 flex items-center gap-1"
|
||||
>
|
||||
Upgrade <ArrowUpRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<FilterBar className="justify-between">
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTab={filterStatus}
|
||||
onChange={(id) => setFilterStatus(id as FilterStatus)}
|
||||
/>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Filter domains..."
|
||||
className="w-full sm:w-64"
|
||||
/>
|
||||
</FilterBar>
|
||||
|
||||
{/* Domain Table */}
|
||||
<PremiumTable
|
||||
data={filteredDomains}
|
||||
keyExtractor={(d) => d.id}
|
||||
emptyIcon={<Eye className="w-12 h-12 text-foreground-subtle" />}
|
||||
emptyTitle={stats.domainsUsed === 0 ? "Your watchlist is empty" : "No domains match your filters"}
|
||||
emptyDescription={stats.domainsUsed === 0 ? "Add a domain above to start tracking" : "Try adjusting your filter criteria"}
|
||||
columns={columns}
|
||||
/>
|
||||
|
||||
{/* Health Report Modal */}
|
||||
{selectedHealthDomainId && healthReports[selectedHealthDomainId] && (
|
||||
<HealthReportModal
|
||||
report={healthReports[selectedHealthDomainId]}
|
||||
onClose={() => setSelectedHealthDomainId(null)}
|
||||
/>
|
||||
)}
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Health Report Modal Component - memoized
|
||||
const HealthReportModal = memo(function HealthReportModal({
|
||||
report,
|
||||
onClose
|
||||
}: {
|
||||
report: DomainHealthReport
|
||||
onClose: () => void
|
||||
}) {
|
||||
const config = healthStatusConfig[report.status]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg bg-background-secondary border border-border/50 rounded-2xl shadow-2xl overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-border/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={clsx("p-2 rounded-lg border", config.bgColor)}>
|
||||
<Icon className={clsx("w-5 h-5", config.color)} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-mono font-semibold text-foreground">{report.domain}</h3>
|
||||
<p className="text-xs text-foreground-muted">{config.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 text-foreground-muted hover:text-foreground transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
<div className="p-5 border-b border-border/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-foreground-muted">Health Score</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-32 h-2 bg-foreground/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={clsx(
|
||||
"h-full rounded-full transition-all",
|
||||
report.score >= 70 ? "bg-accent" :
|
||||
report.score >= 40 ? "bg-amber-400" : "bg-red-400"
|
||||
)}
|
||||
style={{ width: `${report.score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
"text-lg font-bold tabular-nums",
|
||||
report.score >= 70 ? "text-accent" :
|
||||
report.score >= 40 ? "text-amber-400" : "text-red-400"
|
||||
)}>
|
||||
{report.score}/100
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Check Results */}
|
||||
<div className="p-5 space-y-4 max-h-80 overflow-y-auto">
|
||||
{/* DNS */}
|
||||
{report.dns && (
|
||||
<div className="p-4 bg-foreground/5 rounded-xl">
|
||||
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
|
||||
<span className={clsx(
|
||||
"w-2 h-2 rounded-full",
|
||||
report.dns.has_ns && report.dns.has_a ? "bg-accent" : "bg-red-400"
|
||||
)} />
|
||||
DNS Infrastructure
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={report.dns.has_ns ? "text-accent" : "text-red-400"}>
|
||||
{report.dns.has_ns ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className="text-foreground-muted">Nameservers</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={report.dns.has_a ? "text-accent" : "text-red-400"}>
|
||||
{report.dns.has_a ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className="text-foreground-muted">A Record</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={report.dns.has_mx ? "text-accent" : "text-foreground-muted"}>
|
||||
{report.dns.has_mx ? '✓' : '—'}
|
||||
</span>
|
||||
<span className="text-foreground-muted">MX Record</span>
|
||||
</div>
|
||||
</div>
|
||||
{report.dns.is_parked && (
|
||||
<p className="mt-2 text-xs text-orange-400">⚠ Parked at {report.dns.parking_provider || 'unknown provider'}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HTTP */}
|
||||
{report.http && (
|
||||
<div className="p-4 bg-foreground/5 rounded-xl">
|
||||
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
|
||||
<span className={clsx(
|
||||
"w-2 h-2 rounded-full",
|
||||
report.http.is_reachable && report.http.status_code === 200 ? "bg-accent" :
|
||||
report.http.is_reachable ? "bg-amber-400" : "bg-red-400"
|
||||
)} />
|
||||
Website Status
|
||||
</h4>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className={clsx(
|
||||
report.http.is_reachable ? "text-accent" : "text-red-400"
|
||||
)}>
|
||||
{report.http.is_reachable ? 'Reachable' : 'Unreachable'}
|
||||
</span>
|
||||
{report.http.status_code && (
|
||||
<span className="text-foreground-muted">HTTP {report.http.status_code}</span>
|
||||
)}
|
||||
</div>
|
||||
{report.http.is_parked && (
|
||||
<p className="mt-2 text-xs text-orange-400">⚠ Parking page detected</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSL */}
|
||||
{report.ssl && (
|
||||
<div className="p-4 bg-foreground/5 rounded-xl">
|
||||
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
|
||||
<span className={clsx(
|
||||
"w-2 h-2 rounded-full",
|
||||
report.ssl.has_certificate && report.ssl.is_valid ? "bg-accent" :
|
||||
report.ssl.has_certificate ? "bg-amber-400" : "bg-foreground-muted"
|
||||
)} />
|
||||
SSL Certificate
|
||||
</h4>
|
||||
<div className="text-xs">
|
||||
{report.ssl.has_certificate ? (
|
||||
<div className="space-y-1">
|
||||
<p className={report.ssl.is_valid ? "text-accent" : "text-red-400"}>
|
||||
{report.ssl.is_valid ? '✓ Valid certificate' : '✗ Certificate invalid/expired'}
|
||||
</p>
|
||||
{report.ssl.days_until_expiry !== undefined && (
|
||||
<p className={clsx(
|
||||
report.ssl.days_until_expiry > 30 ? "text-foreground-muted" :
|
||||
report.ssl.days_until_expiry > 7 ? "text-amber-400" : "text-red-400"
|
||||
)}>
|
||||
Expires in {report.ssl.days_until_expiry} days
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-foreground-muted">No SSL certificate</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signals & Recommendations */}
|
||||
{((report.signals?.length || 0) > 0 || (report.recommendations?.length || 0) > 0) && (
|
||||
<div className="space-y-3">
|
||||
{(report.signals?.length || 0) > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-foreground-muted uppercase tracking-wider mb-2">Signals</h4>
|
||||
<ul className="space-y-1">
|
||||
{report.signals?.map((signal, i) => (
|
||||
<li key={i} className="text-xs text-foreground flex items-start gap-2">
|
||||
<span className="text-accent mt-0.5">•</span>
|
||||
{signal}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{(report.recommendations?.length || 0) > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-foreground-muted uppercase tracking-wider mb-2">Recommendations</h4>
|
||||
<ul className="space-y-1">
|
||||
{report.recommendations?.map((rec, i) => (
|
||||
<li key={i} className="text-xs text-foreground flex items-start gap-2">
|
||||
<span className="text-amber-400 mt-0.5">→</span>
|
||||
{rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 bg-foreground/5 border-t border-border/30">
|
||||
<p className="text-xs text-foreground-subtle text-center">
|
||||
Checked at {new Date(report.checked_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
221
frontend/src/app/command/welcome/page.tsx
Normal file
221
frontend/src/app/command/welcome/page.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import { PageContainer } from '@/components/PremiumTable'
|
||||
import { useStore } from '@/lib/store'
|
||||
import {
|
||||
CheckCircle,
|
||||
Zap,
|
||||
Crown,
|
||||
ArrowRight,
|
||||
Eye,
|
||||
Store,
|
||||
Bell,
|
||||
BarChart3,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const planDetails = {
|
||||
trader: {
|
||||
name: 'Trader',
|
||||
icon: TrendingUp,
|
||||
color: 'text-accent',
|
||||
bgColor: 'bg-accent/10',
|
||||
features: [
|
||||
{ icon: Eye, text: '50 domains in watchlist', description: 'Track up to 50 domains at once' },
|
||||
{ icon: Zap, text: 'Hourly availability checks', description: '24x faster than Scout' },
|
||||
{ icon: Store, text: '10 For Sale listings', description: 'List your domains on the marketplace' },
|
||||
{ icon: Bell, text: '5 Sniper Alerts', description: 'Get notified when specific domains drop' },
|
||||
{ icon: BarChart3, text: 'Deal scores & valuations', description: 'Know what domains are worth' },
|
||||
],
|
||||
nextSteps: [
|
||||
{ href: '/command/watchlist', label: 'Add domains to watchlist', icon: Eye },
|
||||
{ href: '/command/alerts', label: 'Set up Sniper Alerts', icon: Bell },
|
||||
{ href: '/command/portfolio', label: 'Track your portfolio', icon: BarChart3 },
|
||||
],
|
||||
},
|
||||
tycoon: {
|
||||
name: 'Tycoon',
|
||||
icon: Crown,
|
||||
color: 'text-amber-400',
|
||||
bgColor: 'bg-amber-400/10',
|
||||
features: [
|
||||
{ icon: Eye, text: '500 domains in watchlist', description: 'Massive tracking capacity' },
|
||||
{ icon: Zap, text: 'Real-time checks (10 min)', description: 'Never miss a drop' },
|
||||
{ icon: Store, text: '50 For Sale listings', description: 'Full marketplace access' },
|
||||
{ icon: Bell, text: 'Unlimited Sniper Alerts', description: 'Set as many as you need' },
|
||||
{ icon: Sparkles, text: 'SEO Juice Detector', description: 'Find domains with backlinks' },
|
||||
],
|
||||
nextSteps: [
|
||||
{ href: '/command/watchlist', label: 'Add domains to watchlist', icon: Eye },
|
||||
{ href: '/command/seo', label: 'Analyze SEO metrics', icon: Sparkles },
|
||||
{ href: '/command/alerts', label: 'Create Sniper Alerts', icon: Bell },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default function WelcomePage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { fetchSubscription, checkAuth } = useStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showConfetti, setShowConfetti] = useState(true)
|
||||
|
||||
const planId = searchParams.get('plan') as 'trader' | 'tycoon' | null
|
||||
const plan = planId && planDetails[planId] ? planDetails[planId] : planDetails.trader
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
await checkAuth()
|
||||
await fetchSubscription()
|
||||
setLoading(false)
|
||||
}
|
||||
init()
|
||||
|
||||
// Hide confetti after animation
|
||||
const timer = setTimeout(() => setShowConfetti(false), 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [checkAuth, fetchSubscription])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<CommandCenterLayout title="Welcome" subtitle="Loading your new plan...">
|
||||
<PageContainer>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandCenterLayout title="Welcome" subtitle="Your upgrade is complete">
|
||||
<PageContainer>
|
||||
{/* Confetti Effect */}
|
||||
{showConfetti && (
|
||||
<div className="fixed inset-0 pointer-events-none z-50 overflow-hidden">
|
||||
{Array.from({ length: 50 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-2 h-2 rounded-full animate-[confetti_3s_ease-out_forwards]"
|
||||
style={{
|
||||
left: `${Math.random() * 100}%`,
|
||||
top: '-10px',
|
||||
backgroundColor: ['#10b981', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6'][Math.floor(Math.random() * 5)],
|
||||
animationDelay: `${Math.random() * 0.5}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Header */}
|
||||
<div className="text-center mb-12">
|
||||
<div className={clsx(
|
||||
"inline-flex items-center justify-center w-20 h-20 rounded-full mb-6",
|
||||
plan.bgColor
|
||||
)}>
|
||||
<CheckCircle className={clsx("w-10 h-10", plan.color)} />
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl sm:text-4xl font-semibold text-foreground mb-3">
|
||||
Welcome to {plan.name}!
|
||||
</h1>
|
||||
<p className="text-lg text-foreground-muted max-w-lg mx-auto">
|
||||
Your payment was successful. You now have access to all {plan.name} features.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Features Unlocked */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-6 text-center">
|
||||
Features Unlocked
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{plan.features.map((feature, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-5 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||
animate-slide-up"
|
||||
style={{ animationDelay: `${i * 100}ms` }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={clsx("w-10 h-10 rounded-xl flex items-center justify-center shrink-0", plan.bgColor)}>
|
||||
<feature.icon className={clsx("w-5 h-5", plan.color)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{feature.text}</p>
|
||||
<p className="text-sm text-foreground-muted mt-1">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next Steps */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-6 text-center">
|
||||
Get Started
|
||||
</h2>
|
||||
<div className="max-w-2xl mx-auto space-y-3">
|
||||
{plan.nextSteps.map((step, i) => (
|
||||
<Link
|
||||
key={i}
|
||||
href={step.href}
|
||||
className="flex items-center justify-between p-5 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||
hover:border-accent/30 hover:bg-background-secondary transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-foreground/5 flex items-center justify-center
|
||||
group-hover:bg-accent/10 transition-colors">
|
||||
<step.icon className="w-5 h-5 text-foreground-muted group-hover:text-accent transition-colors" />
|
||||
</div>
|
||||
<span className="font-medium text-foreground">{step.label}</span>
|
||||
</div>
|
||||
<ArrowRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Go to Dashboard */}
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/command/dashboard"
|
||||
className="inline-flex items-center gap-2 px-8 py-4 bg-accent text-background font-medium rounded-xl
|
||||
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
|
||||
>
|
||||
Go to Dashboard
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
<p className="text-sm text-foreground-muted mt-4">
|
||||
Need help? Check out our <Link href="/docs" className="text-accent hover:underline">documentation</Link> or{' '}
|
||||
<Link href="mailto:support@pounce.ch" className="text-accent hover:underline">contact support</Link>.
|
||||
</p>
|
||||
</div>
|
||||
</PageContainer>
|
||||
|
||||
{/* Custom CSS for confetti animation */}
|
||||
<style jsx>{`
|
||||
@keyframes confetti {
|
||||
0% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(100vh) rotate(720deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
26
frontend/src/app/intelligence/page.tsx
Normal file
26
frontend/src/app/intelligence/page.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
/**
|
||||
* Redirect /intelligence to /tld-pricing
|
||||
* This page is kept for backwards compatibility
|
||||
*/
|
||||
export default function IntelligenceRedirect() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
router.replace('/tld-pricing')
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<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 TLD Pricing...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -54,8 +54,17 @@ function LoginForm() {
|
||||
const [oauthProviders, setOauthProviders] = useState({ google_enabled: false, github_enabled: false })
|
||||
const [verified, setVerified] = useState(false)
|
||||
|
||||
// Get redirect URL from query params
|
||||
const redirectTo = searchParams.get('redirect') || '/dashboard'
|
||||
// Get redirect URL from query params or localStorage (set during registration)
|
||||
const paramRedirect = searchParams.get('redirect')
|
||||
const [redirectTo, setRedirectTo] = useState(paramRedirect || '/command/dashboard')
|
||||
|
||||
// Check localStorage for redirect (set during registration before email verification)
|
||||
useEffect(() => {
|
||||
const storedRedirect = localStorage.getItem('pounce_redirect_after_login')
|
||||
if (storedRedirect && !paramRedirect) {
|
||||
setRedirectTo(storedRedirect)
|
||||
}
|
||||
}, [paramRedirect])
|
||||
|
||||
// Check for verified status
|
||||
useEffect(() => {
|
||||
@ -79,6 +88,18 @@ function LoginForm() {
|
||||
|
||||
try {
|
||||
await login(email, password)
|
||||
|
||||
// Check if email is verified
|
||||
const user = await api.getMe()
|
||||
if (!user.is_verified) {
|
||||
// Redirect to verify-email page if not verified
|
||||
router.push(`/verify-email?email=${encodeURIComponent(email)}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear stored redirect (was set during registration)
|
||||
localStorage.removeItem('pounce_redirect_after_login')
|
||||
|
||||
// Redirect to intended destination or dashboard
|
||||
router.push(redirectTo)
|
||||
} catch (err: unknown) {
|
||||
@ -104,7 +125,7 @@ function LoginForm() {
|
||||
}
|
||||
|
||||
// Generate register link with redirect preserved
|
||||
const registerLink = redirectTo !== '/dashboard'
|
||||
const registerLink = redirectTo !== '/command/dashboard'
|
||||
? `/register?redirect=${encodeURIComponent(redirectTo)}`
|
||||
: '/register'
|
||||
|
||||
|
||||
252
frontend/src/app/market/page.tsx
Normal file
252
frontend/src/app/market/page.tsx
Normal file
@ -0,0 +1,252 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
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() {
|
||||
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':
|
||||
data = await api.getOpportunityAuctions(50)
|
||||
break
|
||||
default:
|
||||
const response = await api.getAuctions({ limit: 50, sort_by: sortBy })
|
||||
data = response.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 },
|
||||
]
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
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>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ function OAuthCallbackContent() {
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get('token')
|
||||
const redirect = searchParams.get('redirect') || '/dashboard'
|
||||
const redirect = searchParams.get('redirect') || '/command/dashboard'
|
||||
const isNew = searchParams.get('new') === 'true'
|
||||
const error = searchParams.get('error')
|
||||
|
||||
@ -22,8 +22,8 @@ function OAuthCallbackContent() {
|
||||
}
|
||||
|
||||
if (token) {
|
||||
// Store the token
|
||||
localStorage.setItem('auth_token', token)
|
||||
// Store the token (using 'token' key to match api.ts)
|
||||
localStorage.setItem('token', token)
|
||||
|
||||
// Update auth state
|
||||
checkAuth().then(() => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
@ -21,6 +21,18 @@ import {
|
||||
BarChart3,
|
||||
Globe,
|
||||
Check,
|
||||
Search,
|
||||
Target,
|
||||
Gavel,
|
||||
Sparkles,
|
||||
Activity,
|
||||
LineChart,
|
||||
Lock,
|
||||
Filter,
|
||||
Crosshair,
|
||||
Tag,
|
||||
AlertTriangle,
|
||||
Briefcase,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
@ -32,6 +44,13 @@ interface TrendingTld {
|
||||
price_change: number
|
||||
}
|
||||
|
||||
interface HotAuction {
|
||||
domain: string
|
||||
current_bid: number
|
||||
time_remaining: string
|
||||
platform: string
|
||||
}
|
||||
|
||||
// Shimmer for loading states
|
||||
function Shimmer({ className }: { className?: string }) {
|
||||
return (
|
||||
@ -70,24 +89,66 @@ function AnimatedNumber({ value, suffix = '' }: { value: number, suffix?: string
|
||||
return <>{count.toLocaleString()}{suffix}</>
|
||||
}
|
||||
|
||||
// Live Market Ticker
|
||||
function MarketTicker({ auctions }: { auctions: HotAuction[] }) {
|
||||
const tickerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Duplicate items for seamless loop
|
||||
const items = [...auctions, ...auctions]
|
||||
|
||||
if (auctions.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden bg-background-secondary/30 border-y border-border/50 py-3">
|
||||
<div
|
||||
ref={tickerRef}
|
||||
className="flex animate-[ticker_30s_linear_infinite] hover:[animation-play-state:paused]"
|
||||
style={{ width: 'max-content' }}
|
||||
>
|
||||
{items.map((auction, i) => (
|
||||
<div
|
||||
key={`${auction.domain}-${i}`}
|
||||
className="flex items-center gap-6 px-8 border-r border-border/30"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
||||
<span className="font-mono text-sm text-foreground">{auction.domain}</span>
|
||||
</div>
|
||||
<span className="text-sm text-accent font-semibold">${auction.current_bid}</span>
|
||||
<span className="text-xs text-foreground-subtle">{auction.time_remaining}</span>
|
||||
<span className="text-xs text-foreground-muted uppercase">{auction.platform}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const { checkAuth, isLoading, isAuthenticated } = useStore()
|
||||
const [trendingTlds, setTrendingTlds] = useState<TrendingTld[]>([])
|
||||
const [hotAuctions, setHotAuctions] = useState<HotAuction[]>([])
|
||||
const [loadingTlds, setLoadingTlds] = useState(true)
|
||||
const [loadingAuctions, setLoadingAuctions] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
fetchTldData()
|
||||
fetchData()
|
||||
}, [checkAuth])
|
||||
|
||||
const fetchTldData = async () => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const trending = await api.getTrendingTlds()
|
||||
const [trending, auctions] = await Promise.all([
|
||||
api.getTrendingTlds(),
|
||||
api.getHotAuctions(8).catch(() => [])
|
||||
])
|
||||
setTrendingTlds(trending.trending.slice(0, 4))
|
||||
setHotAuctions(auctions.slice(0, 8))
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch TLD data:', error)
|
||||
console.error('Failed to fetch data:', error)
|
||||
} finally {
|
||||
setLoadingTlds(false)
|
||||
setLoadingAuctions(false)
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,11 +170,8 @@ export default function HomePage() {
|
||||
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
{/* Primary glow */}
|
||||
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
|
||||
{/* Secondary glow */}
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
|
||||
{/* Grid pattern */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.015]"
|
||||
style={{
|
||||
@ -125,19 +183,19 @@ export default function HomePage() {
|
||||
|
||||
<Header />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-32 sm:pt-40 md:pt-48 lg:pt-56 pb-20 sm:pb-28 px-4 sm:px-6">
|
||||
{/* Hero Section - "Bloomberg meets Apple" */}
|
||||
<section className="relative pt-24 sm:pt-32 md:pt-36 pb-12 sm:pb-16 px-4 sm:px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center max-w-5xl mx-auto">
|
||||
<div className="text-center max-w-4xl mx-auto">
|
||||
{/* Puma Logo */}
|
||||
<div className="flex justify-center mb-8 sm:mb-10 animate-fade-in">
|
||||
<div className="flex justify-center mb-6 sm:mb-8 animate-fade-in">
|
||||
<div className="relative">
|
||||
<Image
|
||||
src="/pounce-puma.png"
|
||||
alt="pounce"
|
||||
width={400}
|
||||
height={280}
|
||||
className="w-40 h-auto sm:w-52 md:w-64 object-contain drop-shadow-[0_0_60px_rgba(16,185,129,0.3)]"
|
||||
width={320}
|
||||
height={224}
|
||||
className="w-32 h-auto sm:w-40 md:w-48 object-contain drop-shadow-[0_0_60px_rgba(16,185,129,0.3)]"
|
||||
priority
|
||||
/>
|
||||
{/* Glow ring */}
|
||||
@ -145,65 +203,389 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Headline - MASSIVE */}
|
||||
{/* Main Headline - kompakter */}
|
||||
<h1 className="animate-slide-up">
|
||||
<span className="block font-display text-[3rem] sm:text-[4rem] md:text-[5.5rem] lg:text-[7rem] xl:text-[8rem] leading-[0.9] tracking-[-0.04em] text-foreground">
|
||||
Others wait.
|
||||
<span className="block font-display text-[2rem] sm:text-[2.5rem] md:text-[3.5rem] lg:text-[4rem] leading-[0.95] tracking-[-0.03em] text-foreground">
|
||||
The market never sleeps.
|
||||
</span>
|
||||
<span className="block font-display text-[3rem] sm:text-[4rem] md:text-[5.5rem] lg:text-[7rem] xl:text-[8rem] leading-[0.9] tracking-[-0.04em] text-foreground/40 mt-2">
|
||||
You pounce.
|
||||
<span className="block font-display text-[2rem] sm:text-[2.5rem] md:text-[3.5rem] lg:text-[4rem] leading-[0.95] tracking-[-0.03em] text-foreground/30 mt-1">
|
||||
You should.
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Subheadline */}
|
||||
<p className="mt-8 sm:mt-10 md:mt-12 text-lg sm:text-xl md:text-2xl text-foreground-muted max-w-2xl mx-auto animate-slide-up delay-100 leading-relaxed">
|
||||
Domain intelligence for the decisive. Track any domain.
|
||||
Know the moment it drops. Move before anyone else.
|
||||
{/* Subheadline - kompakter */}
|
||||
<p className="mt-5 sm:mt-6 text-base sm:text-lg md:text-xl text-foreground-muted max-w-xl mx-auto animate-slide-up delay-100 leading-relaxed">
|
||||
We scan. We watch. We alert.{' '}
|
||||
<span className="text-foreground font-medium">You pounce.</span>
|
||||
</p>
|
||||
|
||||
{/* Domain Checker */}
|
||||
<div className="mt-10 sm:mt-14 md:mt-16 animate-slide-up delay-200">
|
||||
<DomainChecker />
|
||||
{/* Tagline */}
|
||||
<p className="mt-3 text-sm sm:text-base text-accent font-medium animate-slide-up delay-150">
|
||||
Don't guess. Know.
|
||||
</p>
|
||||
|
||||
{/* Domain Checker - PROMINENT */}
|
||||
<div className="mt-8 sm:mt-10 animate-slide-up delay-200">
|
||||
<div className="relative max-w-2xl mx-auto">
|
||||
{/* Glow effect behind search */}
|
||||
<div className="absolute inset-0 bg-accent/10 rounded-2xl blur-xl scale-105 -z-10" />
|
||||
<DomainChecker />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trust Indicators */}
|
||||
<div className="mt-12 sm:mt-16 flex flex-wrap items-center justify-center gap-8 sm:gap-12 text-foreground-subtle animate-fade-in delay-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-accent" />
|
||||
<span className="text-sm font-medium"><AnimatedNumber value={886} />+ TLDs tracked</span>
|
||||
<div className="mt-8 sm:mt-10 flex flex-wrap items-center justify-center gap-4 sm:gap-8 text-foreground-subtle animate-fade-in delay-300">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Globe className="w-3.5 h-3.5 text-accent" />
|
||||
<span className="text-xs sm:text-sm font-medium"><AnimatedNumber value={886} />+ TLDs</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-accent" />
|
||||
<span className="text-sm font-medium">Real-time pricing</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Gavel className="w-3.5 h-3.5 text-accent" />
|
||||
<span className="text-xs sm:text-sm font-medium">Live Auctions</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="w-4 h-4 text-accent" />
|
||||
<span className="text-sm font-medium">Instant alerts</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Bell className="w-3.5 h-3.5 text-accent" />
|
||||
<span className="text-xs sm:text-sm font-medium">Instant Alerts</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<LineChart className="w-3.5 h-3.5 text-accent" />
|
||||
<span className="text-xs sm:text-sm font-medium">Price Intel</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Live Market Ticker */}
|
||||
{!loadingAuctions && hotAuctions.length > 0 && (
|
||||
<MarketTicker auctions={hotAuctions} />
|
||||
)}
|
||||
|
||||
{/* Three Pillars: DISCOVER, TRACK, ACQUIRE */}
|
||||
<section className="relative py-24 sm:py-32 px-4 sm:px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Section Header */}
|
||||
<div className="text-center max-w-3xl mx-auto mb-16 sm:mb-20">
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Your Command Center</span>
|
||||
<h2 className="mt-4 font-display text-3xl sm:text-4xl md:text-5xl tracking-[-0.03em] text-foreground">
|
||||
Three moves to dominate.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Pillars */}
|
||||
<div className="grid md:grid-cols-3 gap-6 lg:gap-8">
|
||||
{/* DISCOVER */}
|
||||
<div className="group relative p-8 sm:p-10 bg-gradient-to-b from-background-secondary/80 to-background-secondary/40
|
||||
border border-border rounded-3xl hover:border-accent/30 transition-all duration-500">
|
||||
<div className="absolute inset-0 rounded-3xl bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 bg-accent/10 border border-accent/20 rounded-2xl flex items-center justify-center mb-6">
|
||||
<Search className="w-7 h-7 text-accent" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-display text-foreground mb-4">Discover</h3>
|
||||
<p className="text-foreground-muted mb-6 leading-relaxed">
|
||||
Instant domain intel. Not just "taken" — but <span className="text-foreground">why</span>,
|
||||
<span className="text-foreground"> when it expires</span>, and
|
||||
<span className="text-foreground"> smarter alternatives</span>.
|
||||
</p>
|
||||
<ul className="space-y-3 text-sm">
|
||||
<li className="flex items-center gap-3 text-foreground-subtle">
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<span>Real-time availability across 886+ TLDs</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-foreground-subtle">
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<span>Expiry dates & WHOIS data</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-foreground-subtle">
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<span>AI-powered alternatives</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TRACK */}
|
||||
<div className="group relative p-8 sm:p-10 bg-gradient-to-b from-background-secondary/80 to-background-secondary/40
|
||||
border border-border rounded-3xl hover:border-accent/30 transition-all duration-500
|
||||
md:-translate-y-4">
|
||||
<div className="absolute inset-0 rounded-3xl bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
{/* Popular badge */}
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span className="px-4 py-1 bg-accent text-background text-xs font-semibold rounded-full">
|
||||
Most Popular
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 bg-accent/10 border border-accent/20 rounded-2xl flex items-center justify-center mb-6">
|
||||
<Crosshair className="w-7 h-7 text-accent" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-display text-foreground mb-4">Track</h3>
|
||||
<p className="text-foreground-muted mb-6 leading-relaxed">
|
||||
Your private watchlist with <span className="text-foreground">4-layer health analysis</span>.
|
||||
<span className="text-foreground"> Know the second it weakens.</span>
|
||||
</p>
|
||||
<ul className="space-y-3 text-sm">
|
||||
<li className="flex items-center gap-3 text-foreground-subtle">
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<span>DNS, HTTP, SSL, WHOIS monitoring</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-foreground-subtle">
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<span>Real-time health status alerts</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-foreground-subtle">
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<span>Parked & pre-drop detection</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ACQUIRE */}
|
||||
<div className="group relative p-8 sm:p-10 bg-gradient-to-b from-background-secondary/80 to-background-secondary/40
|
||||
border border-border rounded-3xl hover:border-accent/30 transition-all duration-500">
|
||||
<div className="absolute inset-0 rounded-3xl bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 bg-accent/10 border border-accent/20 rounded-2xl flex items-center justify-center mb-6">
|
||||
<Gavel className="w-7 h-7 text-accent" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-display text-foreground mb-4">Acquire</h3>
|
||||
<p className="text-foreground-muted mb-6 leading-relaxed">
|
||||
All auctions. One place. <span className="text-foreground">Filtered</span>.
|
||||
<span className="text-foreground"> Valued</span>.
|
||||
<span className="text-foreground"> Ready to strike.</span>
|
||||
</p>
|
||||
<ul className="space-y-3 text-sm">
|
||||
<li className="flex items-center gap-3 text-foreground-subtle">
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<span>GoDaddy, Sedo, NameJet, DropCatch</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-foreground-subtle">
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<span>No-spam smart filters</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-foreground-subtle">
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<span>Deal score & valuation</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Transition Element */}
|
||||
<div className="relative h-24 sm:h-32">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-background to-background-secondary/50" />
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="w-px h-16 bg-gradient-to-b from-transparent via-accent/30 to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Beyond Hunting: Sell & Alert */}
|
||||
<section className="relative py-16 sm:py-24 px-4 sm:px-6 bg-background-secondary/50">
|
||||
{/* Subtle background pattern */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-accent/[0.02] rounded-full blur-[100px]" />
|
||||
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-accent/[0.02] rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-7xl mx-auto">
|
||||
{/* Section Header - Left aligned for flow */}
|
||||
<div className="mb-12 sm:mb-16">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-2 h-2 bg-accent rounded-full animate-pulse" />
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Beyond Hunting</span>
|
||||
</div>
|
||||
<h2 className="font-display text-3xl sm:text-4xl md:text-5xl tracking-[-0.03em] text-foreground max-w-2xl">
|
||||
Buy. Sell. Get alerted.
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-foreground-muted max-w-xl">
|
||||
Pounce isn't just for finding domains. It's your complete domain business platform.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 lg:gap-8">
|
||||
{/* For Sale Marketplace */}
|
||||
<div className="group relative p-8 bg-gradient-to-br from-accent/10 via-accent/5 to-transparent
|
||||
border border-accent/20 rounded-3xl hover:border-accent/40 transition-all duration-500
|
||||
backdrop-blur-sm">
|
||||
<div className="absolute top-0 right-0 w-20 h-20 bg-accent/10 rounded-bl-[80px] rounded-tr-3xl" />
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex items-start gap-4 mb-5">
|
||||
<div className="w-12 h-12 bg-accent/20 border border-accent/30 rounded-xl flex items-center justify-center shadow-lg shadow-accent/10">
|
||||
<Tag className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-display text-foreground mb-0.5">Sell Domains</h3>
|
||||
<p className="text-xs text-accent font-medium">Marketplace</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-muted mb-5 leading-relaxed">
|
||||
Create "For Sale" pages with DNS verification. Buyers contact you directly.
|
||||
</p>
|
||||
<ul className="space-y-2 text-xs mb-6">
|
||||
<li className="flex items-center gap-2 text-foreground-subtle">
|
||||
<Shield className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span>Verified Owner badge</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-foreground-subtle">
|
||||
<BarChart3 className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span>Pounce Score valuation</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-foreground-subtle">
|
||||
<Lock className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span>Secure contact form</span>
|
||||
</li>
|
||||
</ul>
|
||||
<Link
|
||||
href="/buy"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-accent text-background text-sm font-medium rounded-lg hover:bg-accent-hover transition-all"
|
||||
>
|
||||
Browse
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sniper Alerts */}
|
||||
<div className="group relative p-8 bg-gradient-to-br from-foreground/[0.03] to-transparent
|
||||
border border-border rounded-3xl hover:border-accent/30 transition-all duration-500
|
||||
backdrop-blur-sm">
|
||||
<div className="absolute top-5 right-5 flex gap-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent/50 animate-pulse" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent/30 animate-pulse delay-100" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent/20 animate-pulse delay-200" />
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex items-start gap-4 mb-5">
|
||||
<div className="w-12 h-12 bg-foreground/10 border border-border rounded-xl flex items-center justify-center">
|
||||
<Target className="w-5 h-5 text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-display text-foreground mb-0.5">Sniper Alerts</h3>
|
||||
<p className="text-xs text-foreground-muted">Hyper-Personalized</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-muted mb-5 leading-relaxed">
|
||||
Custom filters that notify you when matching domains appear.
|
||||
</p>
|
||||
<ul className="space-y-2 text-xs mb-6">
|
||||
<li className="flex items-center gap-2 text-foreground-subtle">
|
||||
<Filter className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span>TLD, length, price filters</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-foreground-subtle">
|
||||
<Bell className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span>Email & SMS alerts</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-foreground-subtle">
|
||||
<Zap className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span>Real-time matching</span>
|
||||
</li>
|
||||
</ul>
|
||||
<Link
|
||||
href="/command/alerts"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 border border-border text-foreground text-sm font-medium rounded-lg hover:border-accent hover:text-accent transition-all"
|
||||
>
|
||||
Set Up
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Portfolio Health */}
|
||||
<div className="group relative p-8 bg-gradient-to-br from-foreground/[0.03] to-transparent
|
||||
border border-border rounded-3xl hover:border-accent/30 transition-all duration-500
|
||||
backdrop-blur-sm">
|
||||
<div className="absolute top-5 right-5">
|
||||
<div className="w-6 h-6 rounded-full bg-accent/10 flex items-center justify-center">
|
||||
<Shield className="w-3 h-3 text-accent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex items-start gap-4 mb-5">
|
||||
<div className="w-12 h-12 bg-accent/10 border border-accent/20 rounded-xl flex items-center justify-center">
|
||||
<Briefcase className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-display text-foreground mb-0.5">Portfolio</h3>
|
||||
<p className="text-xs text-accent font-medium">Domain Insurance</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-muted mb-5 leading-relaxed">
|
||||
Monitor your domains 24/7. SSL, renewals, uptime & P&L tracking.
|
||||
</p>
|
||||
<ul className="space-y-2 text-xs mb-6">
|
||||
<li className="flex items-center gap-2 text-foreground-subtle">
|
||||
<Clock className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span>Expiry reminders</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-foreground-subtle">
|
||||
<Activity className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span>Uptime monitoring</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-foreground-subtle">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span>Valuation & P&L</span>
|
||||
</li>
|
||||
</ul>
|
||||
<Link
|
||||
href="/command/portfolio"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 border border-border text-foreground text-sm font-medium rounded-lg hover:border-accent hover:text-accent transition-all"
|
||||
>
|
||||
Manage
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Transition to TLDs */}
|
||||
<div className="relative h-16 sm:h-24 bg-background-secondary/50">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-background-secondary/50 to-background" />
|
||||
</div>
|
||||
|
||||
{/* Trending TLDs Section */}
|
||||
<section className="relative py-20 sm:py-28 px-4 sm:px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Section Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-6 mb-10 sm:mb-14">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-accent/10 border border-accent/20 rounded-full mb-5">
|
||||
<TrendingUp className="w-4 h-4 text-accent" />
|
||||
<span className="text-sm font-medium text-accent">Market Intel</span>
|
||||
</div>
|
||||
<h2 className="font-display text-3xl sm:text-4xl md:text-5xl tracking-[-0.03em] text-foreground">
|
||||
Trending Now
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">TLD Pricing</span>
|
||||
<h2 className="mt-4 font-display text-3xl sm:text-4xl md:text-5xl tracking-[-0.03em] text-foreground">
|
||||
The <span className="text-accent">real</span> price tag.
|
||||
</h2>
|
||||
<p className="mt-3 text-foreground-muted max-w-lg">
|
||||
Don't fall for $0.99 promos. We show renewal costs, price trends, and renewal traps across 886+ TLDs.
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-4 text-sm text-foreground-subtle">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400" />
|
||||
Trap Detection
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex gap-0.5">
|
||||
<span className="w-2 h-2 rounded-full bg-accent" />
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||
<span className="w-2 h-2 rounded-full bg-red-400" />
|
||||
</span>
|
||||
Risk Levels
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/tld-pricing"
|
||||
className="group inline-flex items-center gap-2 text-sm font-medium text-accent hover:text-accent-hover transition-colors"
|
||||
className="group inline-flex items-center gap-2 px-5 py-2.5 bg-foreground/5 border border-border rounded-xl text-sm font-medium text-foreground hover:border-accent hover:text-accent transition-all"
|
||||
>
|
||||
Explore all TLDs
|
||||
Explore TLD Pricing
|
||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
@ -212,7 +594,7 @@ export default function HomePage() {
|
||||
{loadingTlds ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="p-6 bg-background-secondary/50 border border-border rounded-2xl">
|
||||
<div key={i} className="p-6 bg-background border border-border rounded-2xl">
|
||||
<Shimmer className="h-8 w-20 mb-4" />
|
||||
<Shimmer className="h-4 w-full mb-2" />
|
||||
<Shimmer className="h-4 w-24" />
|
||||
@ -225,11 +607,10 @@ export default function HomePage() {
|
||||
<Link
|
||||
key={item.tld}
|
||||
href={isAuthenticated ? `/tld-pricing/${item.tld}` : `/login?redirect=/tld-pricing/${item.tld}`}
|
||||
className="group relative p-6 bg-background-secondary/50 border border-border rounded-2xl
|
||||
hover:border-accent/30 hover:bg-background-secondary transition-all duration-300"
|
||||
className="group relative p-6 bg-background border border-border rounded-2xl
|
||||
hover:border-accent/30 transition-all duration-300"
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
>
|
||||
{/* Hover glow */}
|
||||
<div className="absolute inset-0 rounded-2xl bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
|
||||
<div className="relative">
|
||||
@ -254,7 +635,10 @@ export default function HomePage() {
|
||||
{isAuthenticated ? (
|
||||
<span className="text-lg font-semibold text-foreground">${(item.current_price ?? 0).toFixed(2)}<span className="text-sm font-normal text-foreground-muted">/yr</span></span>
|
||||
) : (
|
||||
<Shimmer className="h-6 w-20" />
|
||||
<span className="text-sm text-foreground-subtle flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" />
|
||||
Sign in to view
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent group-hover:translate-x-1 transition-all" />
|
||||
</div>
|
||||
@ -266,89 +650,39 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="relative py-20 sm:py-28 px-4 sm:px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Section Header */}
|
||||
<div className="text-center max-w-3xl mx-auto mb-16 sm:mb-20">
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">How It Works</span>
|
||||
<h2 className="mt-4 font-display text-3xl sm:text-4xl md:text-5xl lg:text-6xl tracking-[-0.03em] text-foreground">
|
||||
Built for hunters.
|
||||
</h2>
|
||||
<p className="mt-5 text-lg text-foreground-muted">
|
||||
The tools that give you the edge. Simple. Powerful. Decisive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature Cards */}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[
|
||||
{
|
||||
icon: Eye,
|
||||
title: 'Always Watching',
|
||||
description: 'Daily scans across 886+ TLDs. You sleep, we hunt.',
|
||||
},
|
||||
{
|
||||
icon: Bell,
|
||||
title: 'Instant Alerts',
|
||||
description: 'Domain drops? You know first. Email alerts the moment it happens.',
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: 'Expiry Intel',
|
||||
description: 'See when domains expire. Plan your acquisition strategy.',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Your Strategy, Private',
|
||||
description: 'Your watchlist is yours alone. No one sees what you\'re tracking.',
|
||||
},
|
||||
].map((feature, i) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="group relative p-8 rounded-2xl border border-transparent hover:border-border
|
||||
bg-transparent hover:bg-background-secondary/50 transition-all duration-500"
|
||||
>
|
||||
<div className="w-14 h-14 bg-foreground/5 border border-border rounded-2xl flex items-center justify-center mb-6
|
||||
group-hover:border-accent/30 group-hover:bg-accent/5 transition-all duration-500">
|
||||
<feature.icon className="w-6 h-6 text-foreground-muted group-hover:text-accent transition-colors duration-500" strokeWidth={1.5} />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-3">{feature.title}</h3>
|
||||
<p className="text-sm text-foreground-subtle leading-relaxed">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Social Proof / Stats Section */}
|
||||
<section className="relative py-20 sm:py-28 px-4 sm:px-6">
|
||||
<section className="relative py-24 sm:py-32 px-4 sm:px-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="relative p-10 sm:p-14 md:p-20 bg-gradient-to-br from-background-secondary/80 to-background-secondary/40
|
||||
border border-border rounded-3xl overflow-hidden">
|
||||
{/* Background pattern */}
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-0 right-0 w-[400px] h-[400px] bg-accent/10 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative grid sm:grid-cols-3 gap-10 sm:gap-6 text-center">
|
||||
<div>
|
||||
<p className="font-display text-5xl sm:text-6xl md:text-7xl text-foreground mb-2">
|
||||
<AnimatedNumber value={886} />+
|
||||
</p>
|
||||
<p className="text-sm text-foreground-muted">TLDs Tracked</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-display text-5xl sm:text-6xl md:text-7xl text-foreground mb-2">
|
||||
24<span className="text-accent">/</span>7
|
||||
</p>
|
||||
<p className="text-sm text-foreground-muted">Monitoring</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-display text-5xl sm:text-6xl md:text-7xl text-foreground mb-2">
|
||||
<AnimatedNumber value={10} />s
|
||||
</p>
|
||||
<p className="text-sm text-foreground-muted">Alert Speed</p>
|
||||
<div className="relative">
|
||||
<h2 className="font-display text-3xl sm:text-4xl text-center text-foreground mb-12">
|
||||
The edge you need.
|
||||
</h2>
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-10 sm:gap-6 text-center">
|
||||
<div>
|
||||
<p className="font-display text-5xl sm:text-6xl md:text-7xl text-foreground mb-2">
|
||||
<AnimatedNumber value={886} />+
|
||||
</p>
|
||||
<p className="text-sm text-foreground-muted">TLDs Tracked Daily</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-display text-5xl sm:text-6xl md:text-7xl text-foreground mb-2">
|
||||
24<span className="text-accent">/</span>7
|
||||
</p>
|
||||
<p className="text-sm text-foreground-muted">Always Watching</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-display text-5xl sm:text-6xl md:text-7xl text-foreground mb-2">
|
||||
<AnimatedNumber value={10} />s
|
||||
</p>
|
||||
<p className="text-sm text-foreground-muted">Alert Speed</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -359,36 +693,72 @@ export default function HomePage() {
|
||||
<section className="relative py-20 sm:py-28 px-4 sm:px-6">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Pricing</span>
|
||||
<h2 className="mt-4 font-display text-3xl sm:text-4xl md:text-5xl lg:text-6xl tracking-[-0.03em] text-foreground">
|
||||
Pick your weapon.
|
||||
<h2 className="mt-4 font-display text-3xl sm:text-4xl md:text-5xl tracking-[-0.03em] text-foreground">
|
||||
Simple. Transparent. Powerful.
|
||||
</h2>
|
||||
<p className="mt-5 text-lg text-foreground-muted max-w-xl mx-auto">
|
||||
Start free with 5 domains. Scale to 500+ when you need more firepower.
|
||||
Start free. Scale when you're ready.
|
||||
</p>
|
||||
|
||||
{/* Quick Plans */}
|
||||
<div className="mt-12 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<div className="flex items-center gap-4 px-6 py-4 bg-background-secondary/50 border border-border rounded-2xl">
|
||||
<div className="w-12 h-12 bg-foreground/5 rounded-xl flex items-center justify-center">
|
||||
<Zap className="w-5 h-5 text-foreground-muted" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold text-foreground">Scout</p>
|
||||
<p className="text-sm text-foreground-muted">Free forever</p>
|
||||
<div className="mt-12 grid sm:grid-cols-2 gap-4 max-w-2xl mx-auto">
|
||||
{/* Free Plan */}
|
||||
<div className="p-6 bg-background-secondary/50 border border-border rounded-2xl text-left">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-foreground/5 rounded-xl flex items-center justify-center">
|
||||
<Zap className="w-5 h-5 text-foreground-muted" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">Scout</p>
|
||||
<p className="text-sm text-foreground-muted">Free forever</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-foreground-subtle">
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
<span>5 domains watched</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
<span>Daily status checks</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
<span>Market overview</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ArrowRight className="w-5 h-5 text-foreground-subtle hidden sm:block" />
|
||||
<ChevronRight className="w-5 h-5 text-foreground-subtle rotate-90 sm:hidden" />
|
||||
|
||||
<div className="flex items-center gap-4 px-6 py-4 bg-accent/5 border border-accent/20 rounded-2xl">
|
||||
<div className="w-12 h-12 bg-accent/10 rounded-xl flex items-center justify-center">
|
||||
<TrendingUp className="w-5 h-5 text-accent" />
|
||||
{/* Pro Plan */}
|
||||
<div className="p-6 bg-accent/5 border border-accent/20 rounded-2xl text-left relative">
|
||||
<div className="absolute -top-3 right-4">
|
||||
<span className="px-3 py-1 bg-accent text-background text-xs font-semibold rounded-full">
|
||||
Popular
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold text-foreground">Trader</p>
|
||||
<p className="text-sm text-accent">$19/month</p>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-accent/10 rounded-xl flex items-center justify-center">
|
||||
<Target className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">Trader</p>
|
||||
<p className="text-sm text-accent">$9/month</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-foreground-subtle">
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
<span>100 domains watched</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
<span>Priority alerts</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
<span>Full auction access</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -398,11 +768,11 @@ export default function HomePage() {
|
||||
className="inline-flex items-center gap-2 px-8 py-4 bg-foreground text-background rounded-xl
|
||||
font-semibold hover:bg-foreground/90 transition-all duration-300"
|
||||
>
|
||||
Compare Plans
|
||||
Compare All Plans
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={isAuthenticated ? "/dashboard" : "/register"}
|
||||
href={isAuthenticated ? "/command/dashboard" : "/register"}
|
||||
className="inline-flex items-center gap-2 px-8 py-4 text-foreground-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
{isAuthenticated ? "Go to Dashboard" : "Start Free"}
|
||||
@ -415,19 +785,20 @@ export default function HomePage() {
|
||||
{/* Final CTA */}
|
||||
<section className="relative py-24 sm:py-32 px-4 sm:px-6">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<p className="text-accent font-medium mb-4">Join the hunters.</p>
|
||||
<h2 className="font-display text-4xl sm:text-5xl md:text-6xl lg:text-7xl tracking-[-0.03em] text-foreground mb-6">
|
||||
Ready to hunt?
|
||||
Ready to pounce?
|
||||
</h2>
|
||||
<p className="text-xl text-foreground-muted mb-10 max-w-lg mx-auto">
|
||||
Track your first domain in under a minute. No credit card required.
|
||||
Track your first domain in under a minute. Free forever, no credit card.
|
||||
</p>
|
||||
<Link
|
||||
href={isAuthenticated ? "/dashboard" : "/register"}
|
||||
href={isAuthenticated ? "/command/dashboard" : "/register"}
|
||||
className="group inline-flex items-center gap-3 px-10 py-5 bg-accent text-background rounded-2xl
|
||||
text-lg font-semibold hover:bg-accent-hover transition-all duration-300
|
||||
shadow-[0_0_40px_rgba(16,185,129,0.2)] hover:shadow-[0_0_60px_rgba(16,185,129,0.3)]"
|
||||
>
|
||||
{isAuthenticated ? "Go to Dashboard" : "Get Started Free"}
|
||||
{isAuthenticated ? "Go to Dashboard" : "Start Hunting — It's Free"}
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
|
||||
@ -441,6 +812,14 @@ export default function HomePage() {
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
|
||||
{/* Ticker Animation Keyframes */}
|
||||
<style jsx global>{`
|
||||
@keyframes ticker {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { Check, ArrowRight, Zap, TrendingUp, Crown, Loader2, Clock, X } from 'lucide-react'
|
||||
import { Check, ArrowRight, Zap, TrendingUp, Crown, Loader2, Clock, X, AlertCircle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@ -22,9 +22,12 @@ const tiers = [
|
||||
{ text: '5 domains to track', highlight: false, available: true },
|
||||
{ text: 'Daily availability scans', highlight: false, available: true },
|
||||
{ text: 'Email alerts', highlight: false, available: true },
|
||||
{ text: 'TLD price overview', highlight: false, available: true },
|
||||
{ text: 'Raw auction feed', highlight: false, available: true, sublabel: 'Unfiltered' },
|
||||
{ text: '2 domain listings', highlight: false, available: true, sublabel: 'For Sale' },
|
||||
{ text: 'Deal scores & valuations', highlight: false, available: false },
|
||||
{ text: 'Sniper Alerts', highlight: false, available: false },
|
||||
],
|
||||
cta: 'Hunt Free',
|
||||
cta: 'Start Free',
|
||||
highlighted: false,
|
||||
badge: null,
|
||||
isPaid: false,
|
||||
@ -35,20 +38,20 @@ const tiers = [
|
||||
icon: TrendingUp,
|
||||
price: '9',
|
||||
period: '/mo',
|
||||
description: 'Hunt with precision. Daily intel.',
|
||||
description: 'The smart investor\'s choice.',
|
||||
features: [
|
||||
{ text: '50 domains to track', highlight: true, available: true },
|
||||
{ text: 'Hourly scans', highlight: true, available: true },
|
||||
{ text: 'Email alerts', highlight: false, available: true },
|
||||
{ text: 'Full TLD market data', highlight: false, available: true },
|
||||
{ text: 'Domain valuation', highlight: true, available: true },
|
||||
{ text: 'Portfolio (25 domains)', highlight: true, available: true },
|
||||
{ text: '90-day price history', highlight: false, available: true },
|
||||
{ text: 'Expiry tracking', highlight: true, available: true },
|
||||
{ text: 'Hourly scans', highlight: true, available: true, sublabel: '24x faster' },
|
||||
{ text: 'Smart spam filter', highlight: true, available: true, sublabel: 'Curated list' },
|
||||
{ text: 'Deal scores & valuations', highlight: true, available: true },
|
||||
{ text: '10 domain listings', highlight: true, available: true, sublabel: 'For Sale' },
|
||||
{ text: '5 Sniper Alerts', highlight: true, available: true },
|
||||
{ text: 'Portfolio tracking (25)', highlight: true, available: true },
|
||||
{ text: 'Expiry date tracking', highlight: true, available: true },
|
||||
],
|
||||
cta: 'Start Trading',
|
||||
cta: 'Upgrade to Trader',
|
||||
highlighted: true,
|
||||
badge: 'Most Popular',
|
||||
badge: 'Best Value',
|
||||
isPaid: true,
|
||||
},
|
||||
{
|
||||
@ -57,14 +60,16 @@ const tiers = [
|
||||
icon: Crown,
|
||||
price: '29',
|
||||
period: '/mo',
|
||||
description: 'Dominate the market. No limits.',
|
||||
description: 'For serious domain investors.',
|
||||
features: [
|
||||
{ text: '500 domains to track', highlight: true, available: true },
|
||||
{ text: 'Real-time scans (10 min)', highlight: true, available: true },
|
||||
{ text: 'Priority email alerts', highlight: false, available: true },
|
||||
{ text: 'Real-time scans', highlight: true, available: true, sublabel: 'Every 10 min' },
|
||||
{ text: '50 domain listings', highlight: true, available: true, sublabel: 'For Sale' },
|
||||
{ text: 'Unlimited Sniper Alerts', highlight: true, available: true },
|
||||
{ text: 'SEO Juice Detector', highlight: true, available: true, sublabel: 'Backlinks' },
|
||||
{ text: 'Unlimited portfolio', highlight: true, available: true },
|
||||
{ text: 'Full price history', highlight: true, available: true },
|
||||
{ text: 'Advanced valuation', highlight: true, available: true },
|
||||
{ text: 'API access', highlight: true, available: true, sublabel: 'Coming soon' },
|
||||
],
|
||||
cta: 'Go Tycoon',
|
||||
highlighted: false,
|
||||
@ -76,8 +81,12 @@ const tiers = [
|
||||
const comparisonFeatures = [
|
||||
{ name: 'Watchlist Domains', scout: '5', trader: '50', tycoon: '500' },
|
||||
{ name: 'Check Frequency', scout: 'Daily', trader: 'Hourly', tycoon: '10 min' },
|
||||
{ name: 'Auction Feed', scout: 'Raw (unfiltered)', trader: 'Curated (spam-free)', tycoon: 'Curated (spam-free)' },
|
||||
{ name: 'Deal Scores', scout: '—', trader: 'check', tycoon: 'check' },
|
||||
{ name: 'For Sale Listings', scout: '2', trader: '10', tycoon: '50' },
|
||||
{ name: 'Sniper Alerts', scout: '—', trader: '5', tycoon: 'Unlimited' },
|
||||
{ name: 'Portfolio Domains', scout: '—', trader: '25', tycoon: 'Unlimited' },
|
||||
{ name: 'Domain Valuation', scout: '—', trader: 'check', tycoon: 'check' },
|
||||
{ name: 'SEO Juice Detector', scout: '—', trader: '—', tycoon: 'check' },
|
||||
{ name: 'Price History', scout: '—', trader: '90 days', tycoon: 'Unlimited' },
|
||||
{ name: 'Expiry Tracking', scout: '—', trader: 'check', tycoon: 'check' },
|
||||
]
|
||||
@ -110,9 +119,20 @@ export default function PricingPage() {
|
||||
const { checkAuth, isLoading, isAuthenticated } = useStore()
|
||||
const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
|
||||
const [expandedFaq, setExpandedFaq] = useState<number | null>(null)
|
||||
const [showCancelledBanner, setShowCancelledBanner] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
|
||||
// Check if user cancelled checkout
|
||||
if (typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('cancelled') === 'true') {
|
||||
setShowCancelledBanner(true)
|
||||
// Clean up URL
|
||||
window.history.replaceState({}, '', '/pricing')
|
||||
}
|
||||
}
|
||||
}, [checkAuth])
|
||||
|
||||
const handleSelectPlan = async (planId: string, isPaid: boolean) => {
|
||||
@ -122,7 +142,7 @@ export default function PricingPage() {
|
||||
}
|
||||
|
||||
if (!isPaid) {
|
||||
router.push('/dashboard')
|
||||
router.push('/command/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
@ -130,8 +150,8 @@ export default function PricingPage() {
|
||||
try {
|
||||
const response = await api.createCheckoutSession(
|
||||
planId,
|
||||
`${window.location.origin}/dashboard?upgraded=true`,
|
||||
`${window.location.origin}/pricing`
|
||||
`${window.location.origin}/command/welcome?plan=${planId}`,
|
||||
`${window.location.origin}/pricing?cancelled=true`
|
||||
)
|
||||
window.location.href = response.checkout_url
|
||||
} catch (error) {
|
||||
@ -159,6 +179,26 @@ export default function PricingPage() {
|
||||
|
||||
<main className="flex-1 relative pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Cancelled Banner */}
|
||||
{showCancelledBanner && (
|
||||
<div className="mb-8 p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-3 animate-fade-in">
|
||||
<AlertCircle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-amber-400">Checkout cancelled</p>
|
||||
<p className="text-sm text-foreground-muted mt-1">
|
||||
No worries! Your card was not charged. You can try again whenever you're ready,
|
||||
or continue with the free Scout plan.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCancelledBanner(false)}
|
||||
className="p-1 text-foreground-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hero */}
|
||||
<div className="text-center mb-16 sm:mb-20 animate-fade-in">
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Pricing</span>
|
||||
@ -230,9 +270,24 @@ export default function PricingPage() {
|
||||
<ul className="space-y-3 mb-8 flex-1">
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature.text} className="flex items-start gap-3">
|
||||
<Check className="w-4 h-4 mt-0.5 shrink-0 text-accent" strokeWidth={2.5} />
|
||||
<span className="text-body-sm text-foreground">
|
||||
{feature.available ? (
|
||||
<Check className={clsx(
|
||||
"w-4 h-4 mt-0.5 shrink-0",
|
||||
feature.highlight ? "text-accent" : "text-foreground-muted"
|
||||
)} strokeWidth={2.5} />
|
||||
) : (
|
||||
<X className="w-4 h-4 mt-0.5 shrink-0 text-foreground-subtle" strokeWidth={2} />
|
||||
)}
|
||||
<span className={clsx(
|
||||
"text-body-sm",
|
||||
feature.available ? "text-foreground" : "text-foreground-subtle line-through"
|
||||
)}>
|
||||
{feature.text}
|
||||
{feature.sublabel && (
|
||||
<span className="ml-1.5 text-xs text-accent font-medium">
|
||||
{feature.sublabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
@ -340,10 +395,10 @@ export default function PricingPage() {
|
||||
Start with Scout. It's free forever. Upgrade when you need more.
|
||||
</p>
|
||||
<Link
|
||||
href={isAuthenticated ? "/dashboard" : "/register"}
|
||||
href={isAuthenticated ? "/command/dashboard" : "/register"}
|
||||
className="btn-primary inline-flex items-center gap-2 px-6 py-3"
|
||||
>
|
||||
{isAuthenticated ? "Go to Dashboard" : "Get Started Free"}
|
||||
{isAuthenticated ? "Command Center" : "Join the Hunt"}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@ -62,7 +62,7 @@ function RegisterForm() {
|
||||
const [registered, setRegistered] = useState(false)
|
||||
|
||||
// Get redirect URL from query params
|
||||
const redirectTo = searchParams.get('redirect') || '/dashboard'
|
||||
const redirectTo = searchParams.get('redirect') || '/command/dashboard'
|
||||
|
||||
// Load OAuth providers
|
||||
useEffect(() => {
|
||||
@ -76,6 +76,13 @@ function RegisterForm() {
|
||||
|
||||
try {
|
||||
await register(email, password)
|
||||
|
||||
// Store redirect URL for after email verification
|
||||
// This will be picked up by the login page after verification
|
||||
if (redirectTo !== '/command/dashboard') {
|
||||
localStorage.setItem('pounce_redirect_after_login', redirectTo)
|
||||
}
|
||||
|
||||
// Show verification message
|
||||
setRegistered(true)
|
||||
} catch (err) {
|
||||
@ -86,7 +93,7 @@ function RegisterForm() {
|
||||
}
|
||||
|
||||
// Generate login link with redirect preserved
|
||||
const loginLink = redirectTo !== '/dashboard'
|
||||
const loginLink = redirectTo !== '/command/dashboard'
|
||||
? `/login?redirect=${encodeURIComponent(redirectTo)}`
|
||||
: '/login'
|
||||
|
||||
|
||||
@ -215,7 +215,7 @@ export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<main className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-pulse text-foreground-muted">Loading...</div>
|
||||
<div className="animate-pulse text-foreground-muted">Authenticating...</div>
|
||||
</main>
|
||||
}>
|
||||
<ResetPasswordContent />
|
||||
|
||||
@ -1,743 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api, PriceAlert } from '@/lib/api'
|
||||
import {
|
||||
User,
|
||||
Bell,
|
||||
CreditCard,
|
||||
Shield,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
Crown,
|
||||
Zap,
|
||||
Key,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type SettingsTab = 'profile' | 'notifications' | 'billing' | 'security'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter()
|
||||
const { user, isAuthenticated, isLoading, checkAuth, subscription } = useStore()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('profile')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Profile form
|
||||
const [profileForm, setProfileForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
})
|
||||
|
||||
// Notification preferences (local state - would be persisted via API in production)
|
||||
const [notificationPrefs, setNotificationPrefs] = useState({
|
||||
domain_availability: true,
|
||||
price_alerts: true,
|
||||
weekly_digest: false,
|
||||
})
|
||||
const [savingNotifications, setSavingNotifications] = useState(false)
|
||||
|
||||
// Price alerts
|
||||
const [priceAlerts, setPriceAlerts] = useState<PriceAlert[]>([])
|
||||
const [loadingAlerts, setLoadingAlerts] = useState(false)
|
||||
const [deletingAlertId, setDeletingAlertId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [checkAuth])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setProfileForm({
|
||||
name: user.name || '',
|
||||
email: user.email || '',
|
||||
})
|
||||
}
|
||||
}, [user])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && activeTab === 'notifications') {
|
||||
loadPriceAlerts()
|
||||
}
|
||||
}, [isAuthenticated, activeTab])
|
||||
|
||||
const loadPriceAlerts = async () => {
|
||||
setLoadingAlerts(true)
|
||||
try {
|
||||
const alerts = await api.getPriceAlerts()
|
||||
setPriceAlerts(alerts)
|
||||
} catch (err) {
|
||||
console.error('Failed to load alerts:', err)
|
||||
} finally {
|
||||
setLoadingAlerts(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
await api.updateMe({ name: profileForm.name || undefined })
|
||||
// Update store with new user info
|
||||
const { checkAuth } = useStore.getState()
|
||||
await checkAuth()
|
||||
setSuccess('Profile updated successfully')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update profile')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveNotifications = async () => {
|
||||
setSavingNotifications(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
// Store in localStorage for now (would be API in production)
|
||||
localStorage.setItem('notification_prefs', JSON.stringify(notificationPrefs))
|
||||
setSuccess('Notification preferences saved')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save preferences')
|
||||
} finally {
|
||||
setSavingNotifications(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load notification preferences from localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('notification_prefs')
|
||||
if (saved) {
|
||||
try {
|
||||
setNotificationPrefs(JSON.parse(saved))
|
||||
} catch {}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDeletePriceAlert = async (tld: string, alertId: number) => {
|
||||
setDeletingAlertId(alertId)
|
||||
try {
|
||||
await api.deletePriceAlert(tld)
|
||||
setPriceAlerts(prev => prev.filter(a => a.id !== alertId))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete alert')
|
||||
} finally {
|
||||
setDeletingAlertId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenBillingPortal = async () => {
|
||||
try {
|
||||
const { portal_url } = await api.createPortalSession()
|
||||
window.location.href = portal_url
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to open billing portal')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||
const isProOrHigher = ['Trader', 'Tycoon', 'Professional', 'Enterprise'].includes(tierName)
|
||||
|
||||
const tabs = [
|
||||
{ id: 'profile' as const, label: 'Profile', icon: User },
|
||||
{ id: 'notifications' as const, label: 'Notifications', icon: Bell },
|
||||
{ id: 'billing' as const, label: 'Billing', icon: CreditCard },
|
||||
{ id: 'security' as const, label: 'Security', icon: Shield },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||
{/* Background Effects - matching landing page */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.015]"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
|
||||
backgroundSize: '64px 64px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="relative flex-1 pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-12 sm:mb-16 animate-fade-in">
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Settings</span>
|
||||
<h1 className="mt-4 font-display text-[2rem] sm:text-[2.75rem] md:text-[3.5rem] leading-[1.1] tracking-[-0.03em] text-foreground">
|
||||
Your account.
|
||||
</h1>
|
||||
<p className="mt-3 text-lg text-foreground-muted">
|
||||
Your rules. Configure everything in one place.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-danger/5 border border-danger/20 rounded-2xl flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-danger shrink-0" />
|
||||
<p className="text-body-sm text-danger flex-1">{error}</p>
|
||||
<button onClick={() => setError(null)} className="text-danger hover:text-danger/80">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-6 p-4 bg-accent/5 border border-accent/20 rounded-2xl flex items-center gap-3">
|
||||
<Check className="w-5 h-5 text-accent shrink-0" />
|
||||
<p className="text-body-sm text-accent flex-1">{success}</p>
|
||||
<button onClick={() => setSuccess(null)} className="text-accent hover:text-accent/80">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8 animate-slide-up">
|
||||
{/* Sidebar - Horizontal scroll on mobile, vertical on desktop */}
|
||||
<div className="lg:w-72 shrink-0">
|
||||
{/* Mobile: Horizontal scroll tabs */}
|
||||
<nav className="lg:hidden flex gap-2 overflow-x-auto pb-2 -mx-4 px-4 scrollbar-hide">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2.5 px-5 py-3 text-sm font-medium rounded-xl whitespace-nowrap transition-all duration-300",
|
||||
activeTab === tab.id
|
||||
? "bg-accent text-background shadow-lg shadow-accent/20"
|
||||
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground border border-border hover:border-accent/30"
|
||||
)}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Desktop: Vertical tabs */}
|
||||
<nav className="hidden lg:block p-2 bg-background-secondary/50 border border-border rounded-2xl">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium rounded-xl transition-all duration-300",
|
||||
activeTab === tab.id
|
||||
? "bg-accent text-background shadow-lg shadow-accent/20"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
)}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Plan info - hidden on mobile, shown in content area instead */}
|
||||
<div className="hidden lg:block mt-5 p-6 bg-accent/5 border border-accent/20 rounded-2xl">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{isProOrHigher ? <Crown className="w-5 h-5 text-accent" /> : <Zap className="w-5 h-5 text-accent" />}
|
||||
<span className="text-sm font-semibold text-foreground">{tierName} Plan</span>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-muted mb-4">
|
||||
{subscription?.domains_used || 0} / {subscription?.domain_limit || 5} domains tracked
|
||||
</p>
|
||||
{!isProOrHigher && (
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="flex items-center justify-center gap-2 w-full py-2.5 bg-accent text-background text-ui-sm font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||
>
|
||||
Upgrade
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Profile Tab */}
|
||||
{activeTab === 'profile' && (
|
||||
<div className="p-6 sm:p-8 bg-background-secondary/30 border border-border rounded-2xl">
|
||||
<h2 className="text-body-lg font-medium text-foreground mb-6">Profile Information</h2>
|
||||
|
||||
<form onSubmit={handleSaveProfile} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-ui-sm text-foreground-muted mb-2">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileForm.name}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, name: e.target.value })}
|
||||
placeholder="Your name"
|
||||
className="w-full px-4 py-3.5 bg-background border border-border rounded-xl text-body text-foreground
|
||||
placeholder:text-foreground-subtle focus:outline-none focus:ring-1 focus:ring-accent/50 focus:border-accent/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-ui-sm text-foreground-muted mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={profileForm.email}
|
||||
disabled
|
||||
className="w-full px-4 py-3.5 bg-background-tertiary border border-border rounded-xl text-body text-foreground-muted cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-ui-xs text-foreground-subtle mt-1.5">Email cannot be changed</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-6 py-3.5 bg-foreground text-background text-ui font-medium rounded-xl
|
||||
hover:bg-foreground/90 disabled:opacity-50 transition-all flex items-center gap-2 shadow-lg shadow-foreground/10"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
Save Changes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notifications Tab */}
|
||||
{activeTab === 'notifications' && (
|
||||
<div className="space-y-6">
|
||||
<div className="p-6 sm:p-8 bg-background-secondary/30 border border-border rounded-2xl">
|
||||
<h2 className="text-body-lg font-medium text-foreground mb-5">Email Preferences</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center justify-between p-4 bg-background border border-border rounded-xl cursor-pointer hover:border-foreground/20 transition-colors">
|
||||
<div>
|
||||
<p className="text-body-sm font-medium text-foreground">Domain Availability</p>
|
||||
<p className="text-body-xs text-foreground-muted">Get notified when watched domains become available</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationPrefs.domain_availability}
|
||||
onChange={(e) => setNotificationPrefs({ ...notificationPrefs, domain_availability: e.target.checked })}
|
||||
className="w-5 h-5 accent-accent cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between p-4 bg-background border border-border rounded-xl cursor-pointer hover:border-foreground/20 transition-colors">
|
||||
<div>
|
||||
<p className="text-body-sm font-medium text-foreground">Price Alerts</p>
|
||||
<p className="text-body-xs text-foreground-muted">Get notified when TLD prices change</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationPrefs.price_alerts}
|
||||
onChange={(e) => setNotificationPrefs({ ...notificationPrefs, price_alerts: e.target.checked })}
|
||||
className="w-5 h-5 accent-accent cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between p-4 bg-background border border-border rounded-xl cursor-pointer hover:border-foreground/20 transition-colors">
|
||||
<div>
|
||||
<p className="text-body-sm font-medium text-foreground">Weekly Digest</p>
|
||||
<p className="text-body-xs text-foreground-muted">Receive a weekly summary of your portfolio</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationPrefs.weekly_digest}
|
||||
onChange={(e) => setNotificationPrefs({ ...notificationPrefs, weekly_digest: e.target.checked })}
|
||||
className="w-5 h-5 accent-accent cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSaveNotifications}
|
||||
disabled={savingNotifications}
|
||||
className="mt-5 px-6 py-3 bg-foreground text-background text-ui font-medium rounded-xl
|
||||
hover:bg-foreground/90 disabled:opacity-50 transition-all flex items-center gap-2"
|
||||
>
|
||||
{savingNotifications ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
Save Preferences
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Active Price Alerts */}
|
||||
<div className="p-6 sm:p-8 bg-background-secondary/30 border border-border rounded-2xl">
|
||||
<h2 className="text-body-lg font-medium text-foreground mb-5">Active Price Alerts</h2>
|
||||
|
||||
{loadingAlerts ? (
|
||||
<div className="py-10 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-accent" />
|
||||
</div>
|
||||
) : priceAlerts.length === 0 ? (
|
||||
<div className="py-12 text-center border border-dashed border-border/50 rounded-xl bg-background/30">
|
||||
<Bell className="w-10 h-10 text-foreground-subtle mx-auto mb-4" />
|
||||
<p className="text-body text-foreground-muted mb-3">No price alerts set</p>
|
||||
<Link
|
||||
href="/tld-pricing"
|
||||
className="text-accent hover:text-accent-hover text-body-sm font-medium"
|
||||
>
|
||||
Browse TLD prices →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{priceAlerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="flex items-center justify-between p-4 bg-background border border-border rounded-xl hover:border-foreground/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<div className={clsx(
|
||||
"w-2.5 h-2.5 rounded-full",
|
||||
alert.is_active ? "bg-accent" : "bg-foreground-subtle"
|
||||
)} />
|
||||
{alert.is_active && (
|
||||
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-40" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
href={`/tld-pricing/${alert.tld}`}
|
||||
className="text-body-sm font-mono font-medium text-foreground hover:text-accent transition-colors"
|
||||
>
|
||||
.{alert.tld}
|
||||
</Link>
|
||||
<p className="text-body-xs text-foreground-muted">
|
||||
Alert on {alert.threshold_percent}% change
|
||||
{alert.target_price && ` or below $${alert.target_price}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeletePriceAlert(alert.tld, alert.id)}
|
||||
disabled={deletingAlertId === alert.id}
|
||||
className="p-2 text-foreground-subtle hover:text-danger hover:bg-danger/10 rounded-lg transition-all"
|
||||
>
|
||||
{deletingAlertId === alert.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing Tab */}
|
||||
{activeTab === 'billing' && (
|
||||
<div className="space-y-6">
|
||||
{/* Current Plan */}
|
||||
<div className="p-6 sm:p-8 bg-background-secondary/30 border border-border rounded-2xl">
|
||||
<h2 className="text-body-lg font-medium text-foreground mb-6">Your Current Plan</h2>
|
||||
|
||||
<div className="p-5 bg-accent/5 border border-accent/20 rounded-xl mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{tierName === 'Tycoon' ? (
|
||||
<Crown className="w-6 h-6 text-accent" />
|
||||
) : tierName === 'Trader' ? (
|
||||
<TrendingUp className="w-6 h-6 text-accent" />
|
||||
) : (
|
||||
<Zap className="w-6 h-6 text-accent" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xl font-semibold text-foreground">{tierName}</p>
|
||||
<p className="text-body-sm text-foreground-muted">
|
||||
{tierName === 'Scout' ? 'Free forever' : tierName === 'Trader' ? '$9/month' : '$29/month'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
"px-3 py-1.5 text-ui-xs font-medium rounded-full",
|
||||
isProOrHigher ? "bg-accent/10 text-accent" : "bg-foreground/5 text-foreground-muted"
|
||||
)}>
|
||||
{isProOrHigher ? 'Active' : 'Free'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Plan Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 p-4 bg-background/50 rounded-xl mb-4">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-semibold text-foreground">{subscription?.domain_limit || 5}</p>
|
||||
<p className="text-xs text-foreground-muted">Domains</p>
|
||||
</div>
|
||||
<div className="text-center border-x border-border/50">
|
||||
<p className="text-2xl font-semibold text-foreground">
|
||||
{subscription?.check_frequency === 'realtime' ? '10m' :
|
||||
subscription?.check_frequency === 'hourly' ? '1h' : '24h'}
|
||||
</p>
|
||||
<p className="text-xs text-foreground-muted">Check Interval</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-semibold text-foreground">
|
||||
{subscription?.portfolio_limit === -1 ? '∞' : subscription?.portfolio_limit || 0}
|
||||
</p>
|
||||
<p className="text-xs text-foreground-muted">Portfolio</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isProOrHigher ? (
|
||||
<button
|
||||
onClick={handleOpenBillingPortal}
|
||||
className="w-full py-3 bg-background text-foreground text-ui font-medium rounded-xl border border-border
|
||||
hover:border-foreground/20 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Manage Subscription
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="w-full py-3 bg-accent text-background text-ui font-medium rounded-xl
|
||||
hover:bg-accent-hover transition-all flex items-center justify-center gap-2 shadow-lg shadow-accent/20"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
Upgrade Plan
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plan Features */}
|
||||
<h3 className="text-body-sm font-medium text-foreground mb-3">Your Plan Includes</h3>
|
||||
<ul className="grid grid-cols-2 gap-2">
|
||||
<li className="flex items-center gap-2 text-body-sm">
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
<span className="text-foreground">{subscription?.domain_limit || 5} Watchlist Domains</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-body-sm">
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
<span className="text-foreground">
|
||||
{subscription?.check_frequency === 'realtime' ? '10-minute' :
|
||||
subscription?.check_frequency === 'hourly' ? 'Hourly' : 'Daily'} Scans
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-body-sm">
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
<span className="text-foreground">Email Alerts</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-body-sm">
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
<span className="text-foreground">TLD Price Data</span>
|
||||
</li>
|
||||
{subscription?.features?.domain_valuation && (
|
||||
<li className="flex items-center gap-2 text-body-sm">
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
<span className="text-foreground">Domain Valuation</span>
|
||||
</li>
|
||||
)}
|
||||
{(subscription?.portfolio_limit ?? 0) !== 0 && (
|
||||
<li className="flex items-center gap-2 text-body-sm">
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
<span className="text-foreground">
|
||||
{subscription?.portfolio_limit === -1 ? 'Unlimited' : subscription?.portfolio_limit} Portfolio
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
{subscription?.features?.expiration_tracking && (
|
||||
<li className="flex items-center gap-2 text-body-sm">
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
<span className="text-foreground">Expiry Tracking</span>
|
||||
</li>
|
||||
)}
|
||||
{(subscription?.history_days ?? 0) !== 0 && (
|
||||
<li className="flex items-center gap-2 text-body-sm">
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
<span className="text-foreground">
|
||||
{subscription?.history_days === -1 ? 'Full' : `${subscription?.history_days}-day`} History
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Compare All Plans */}
|
||||
<div className="p-6 sm:p-8 bg-background-secondary/30 border border-border rounded-2xl">
|
||||
<h2 className="text-body-lg font-medium text-foreground mb-6">Compare All Plans</h2>
|
||||
|
||||
<div className="overflow-x-auto -mx-2">
|
||||
<table className="w-full min-w-[500px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-3 px-3 text-body-sm font-medium text-foreground-muted">Feature</th>
|
||||
<th className={clsx(
|
||||
"text-center py-3 px-3 text-body-sm font-medium",
|
||||
tierName === 'Scout' ? "text-accent" : "text-foreground-muted"
|
||||
)}>Scout</th>
|
||||
<th className={clsx(
|
||||
"text-center py-3 px-3 text-body-sm font-medium",
|
||||
tierName === 'Trader' ? "text-accent" : "text-foreground-muted"
|
||||
)}>Trader</th>
|
||||
<th className={clsx(
|
||||
"text-center py-3 px-3 text-body-sm font-medium",
|
||||
tierName === 'Tycoon' ? "text-accent" : "text-foreground-muted"
|
||||
)}>Tycoon</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-3 px-3 text-body-sm text-foreground">Price</td>
|
||||
<td className="py-3 px-3 text-center text-body-sm text-foreground">Free</td>
|
||||
<td className="py-3 px-3 text-center text-body-sm text-foreground">$9/mo</td>
|
||||
<td className="py-3 px-3 text-center text-body-sm text-foreground">$29/mo</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-3 px-3 text-body-sm text-foreground">Watchlist Domains</td>
|
||||
<td className="py-3 px-3 text-center text-body-sm text-foreground">5</td>
|
||||
<td className="py-3 px-3 text-center text-body-sm text-foreground">50</td>
|
||||
<td className="py-3 px-3 text-center text-body-sm text-foreground">500</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-3 px-3 text-body-sm text-foreground">Scan Frequency</td>
|
||||
<td className="py-3 px-3 text-center text-body-sm text-foreground">Daily</td>
|
||||
<td className="py-3 px-3 text-center text-body-sm text-foreground">Hourly</td>
|
||||
<td className="py-3 px-3 text-center text-body-sm text-accent font-medium">10 min</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-3 px-3 text-body-sm text-foreground">Portfolio</td>
|
||||
<td className="py-3 px-3 text-center text-body-sm text-foreground-muted">—</td>
|
||||
<td className="py-3 px-3 text-center text-body-sm text-foreground">25</td>
|
||||
<td className="py-3 px-3 text-center text-body-sm text-foreground">Unlimited</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-3 px-3 text-body-sm text-foreground">Domain Valuation</td>
|
||||
<td className="py-3 px-3 text-center text-body-sm text-foreground-muted">—</td>
|
||||
<td className="py-3 px-3 text-center"><Check className="w-5 h-5 text-accent mx-auto" /></td>
|
||||
<td className="py-3 px-3 text-center"><Check className="w-5 h-5 text-accent mx-auto" /></td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-3 px-3 text-body-sm text-foreground">Price History</td>
|
||||
<td className="py-3 px-3 text-center text-body-sm text-foreground-muted">—</td>
|
||||
<td className="py-3 px-3 text-center text-body-sm text-foreground">90 days</td>
|
||||
<td className="py-3 px-3 text-center text-body-sm text-foreground">Unlimited</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 px-3 text-body-sm text-foreground">Expiry Tracking</td>
|
||||
<td className="py-3 px-3 text-center text-body-sm text-foreground-muted">—</td>
|
||||
<td className="py-3 px-3 text-center"><Check className="w-5 h-5 text-accent mx-auto" /></td>
|
||||
<td className="py-3 px-3 text-center"><Check className="w-5 h-5 text-accent mx-auto" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!isProOrHigher && (
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background text-ui font-medium rounded-xl
|
||||
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
Upgrade Now
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security Tab */}
|
||||
{activeTab === 'security' && (
|
||||
<div className="space-y-6">
|
||||
<div className="p-6 sm:p-8 bg-background-secondary/30 border border-border rounded-2xl">
|
||||
<h2 className="text-body-lg font-medium text-foreground mb-4">Password</h2>
|
||||
<p className="text-body-sm text-foreground-muted mb-5">
|
||||
Change your password or reset it if you've forgotten it.
|
||||
</p>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="inline-flex items-center gap-2 px-5 py-3 bg-background border border-border text-foreground text-ui font-medium rounded-xl
|
||||
hover:border-foreground/20 transition-all"
|
||||
>
|
||||
<Key className="w-4 h-4" />
|
||||
Change Password
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="p-6 sm:p-8 bg-background-secondary/30 border border-border rounded-2xl">
|
||||
<h2 className="text-body-lg font-medium text-foreground mb-5">Account Security</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-4 bg-background border border-border rounded-xl">
|
||||
<div>
|
||||
<p className="text-body-sm font-medium text-foreground">Email Verified</p>
|
||||
<p className="text-body-xs text-foreground-muted">Your email address has been verified</p>
|
||||
</div>
|
||||
<div className="w-8 h-8 bg-accent/10 rounded-lg flex items-center justify-center">
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-background border border-border rounded-xl">
|
||||
<div>
|
||||
<p className="text-body-sm font-medium text-foreground">Two-Factor Authentication</p>
|
||||
<p className="text-body-xs text-foreground-muted">Coming soon</p>
|
||||
</div>
|
||||
<span className="text-ui-xs px-2.5 py-1 bg-foreground/5 text-foreground-muted rounded-full">Soon</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 sm:p-8 bg-danger/5 border border-danger/20 rounded-2xl">
|
||||
<h2 className="text-body-lg font-medium text-danger mb-2">Danger Zone</h2>
|
||||
<p className="text-body-sm text-foreground-muted mb-5">
|
||||
Permanently delete your account and all associated data.
|
||||
</p>
|
||||
<button
|
||||
className="px-5 py-3 bg-danger text-white text-ui font-medium rounded-xl hover:bg-danger/90 transition-all"
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { useStore } from '@/lib/store'
|
||||
@ -15,10 +15,8 @@ import {
|
||||
Globe,
|
||||
Building,
|
||||
ExternalLink,
|
||||
Bell,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
Check,
|
||||
X,
|
||||
Lock,
|
||||
@ -26,6 +24,7 @@ import {
|
||||
Clock,
|
||||
Shield,
|
||||
Zap,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
@ -50,6 +49,12 @@ interface TldDetails {
|
||||
transfer_price: number
|
||||
}>
|
||||
cheapest_registrar: string
|
||||
// New fields from table
|
||||
min_renewal_price: number
|
||||
price_change_1y: number
|
||||
price_change_3y: number
|
||||
risk_level: 'low' | 'medium' | 'high'
|
||||
risk_reason: string
|
||||
}
|
||||
|
||||
interface TldHistory {
|
||||
@ -79,8 +84,7 @@ interface DomainCheckResult {
|
||||
expiration_date?: string | null
|
||||
}
|
||||
|
||||
// Registrar URLs with affiliate parameters
|
||||
// Note: Replace REF_CODE with actual affiliate IDs when available
|
||||
// Registrar URLs
|
||||
const REGISTRAR_URLS: Record<string, string> = {
|
||||
'Namecheap': 'https://www.namecheap.com/domains/registration/results/?domain=',
|
||||
'Porkbun': 'https://porkbun.com/checkout/search?q=',
|
||||
@ -120,7 +124,7 @@ function Shimmer({ className }: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Premium Chart Component
|
||||
// Premium Chart Component with real data
|
||||
function PriceChart({
|
||||
data,
|
||||
isAuthenticated,
|
||||
@ -294,7 +298,7 @@ function PriceChart({
|
||||
)
|
||||
}
|
||||
|
||||
// Domain Check Result Card (like landing page)
|
||||
// Domain Check Result Card
|
||||
function DomainResultCard({
|
||||
result,
|
||||
tld,
|
||||
@ -390,7 +394,6 @@ function DomainResultCard({
|
||||
|
||||
export default function TldDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { isAuthenticated, checkAuth, isLoading: authLoading, subscription, fetchSubscription } = useStore()
|
||||
const tld = params.tld as string
|
||||
|
||||
@ -406,8 +409,6 @@ export default function TldDetailPage() {
|
||||
const [domainSearch, setDomainSearch] = useState('')
|
||||
const [checkingDomain, setCheckingDomain] = useState(false)
|
||||
const [domainResult, setDomainResult] = useState<DomainCheckResult | null>(null)
|
||||
const [alertEnabled, setAlertEnabled] = useState(false)
|
||||
const [alertLoading, setAlertLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
@ -418,53 +419,25 @@ export default function TldDetailPage() {
|
||||
if (tld) {
|
||||
loadData()
|
||||
loadRelatedTlds()
|
||||
loadAlertStatus()
|
||||
}
|
||||
}, [tld])
|
||||
|
||||
// Load alert status for this TLD
|
||||
const loadAlertStatus = async () => {
|
||||
try {
|
||||
const status = await api.getPriceAlertStatus(tld)
|
||||
setAlertEnabled(status.has_alert && status.is_active)
|
||||
} catch (err) {
|
||||
// Ignore - user may not be logged in
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle price alert
|
||||
const handleToggleAlert = async () => {
|
||||
if (!isAuthenticated) {
|
||||
// Redirect to login
|
||||
window.location.href = `/login?redirect=/tld-pricing/${tld}`
|
||||
return
|
||||
}
|
||||
|
||||
setAlertLoading(true)
|
||||
try {
|
||||
const result = await api.togglePriceAlert(tld)
|
||||
setAlertEnabled(result.is_active)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to toggle alert:', err)
|
||||
} finally {
|
||||
setAlertLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [historyData, compareData] = await Promise.all([
|
||||
const [historyData, compareData, overviewData] = await Promise.all([
|
||||
api.getTldHistory(tld, 365),
|
||||
api.getTldCompare(tld),
|
||||
api.getTldOverview(1, 0, 'popularity', tld),
|
||||
])
|
||||
|
||||
if (historyData && compareData) {
|
||||
// Sort registrars by price for display
|
||||
const sortedRegistrars = [...(compareData.registrars || [])].sort((a, b) =>
|
||||
a.registration_price - b.registration_price
|
||||
)
|
||||
|
||||
// Use API data directly for consistency with overview table
|
||||
// Get additional data from overview API
|
||||
const tldFromOverview = overviewData?.tlds?.[0]
|
||||
|
||||
setDetails({
|
||||
tld: compareData.tld || tld,
|
||||
type: compareData.type || 'generic',
|
||||
@ -474,13 +447,18 @@ export default function TldDetailPage() {
|
||||
trend: historyData.trend || 'stable',
|
||||
trend_reason: historyData.trend_reason || 'Price tracking available',
|
||||
pricing: {
|
||||
// Use price_range from API for consistency with overview
|
||||
avg: compareData.price_range?.avg || historyData.current_price || 0,
|
||||
min: compareData.price_range?.min || historyData.current_price || 0,
|
||||
max: compareData.price_range?.max || historyData.current_price || 0,
|
||||
},
|
||||
registrars: sortedRegistrars,
|
||||
cheapest_registrar: compareData.cheapest_registrar || sortedRegistrars[0]?.name || 'N/A',
|
||||
// New fields from overview
|
||||
min_renewal_price: tldFromOverview?.min_renewal_price || sortedRegistrars[0]?.renewal_price || 0,
|
||||
price_change_1y: tldFromOverview?.price_change_1y || 0,
|
||||
price_change_3y: tldFromOverview?.price_change_3y || 0,
|
||||
risk_level: tldFromOverview?.risk_level || 'low',
|
||||
risk_reason: tldFromOverview?.risk_reason || 'Stable',
|
||||
})
|
||||
setHistory(historyData)
|
||||
} else {
|
||||
@ -580,6 +558,42 @@ export default function TldDetailPage() {
|
||||
}
|
||||
}, [details])
|
||||
|
||||
// Renewal trap info
|
||||
const renewalInfo = useMemo(() => {
|
||||
if (!details?.registrars?.length) return null
|
||||
const cheapest = details.registrars[0]
|
||||
const ratio = cheapest.renewal_price / cheapest.registration_price
|
||||
return {
|
||||
registration: cheapest.registration_price,
|
||||
renewal: cheapest.renewal_price,
|
||||
ratio,
|
||||
isTrap: ratio > 2,
|
||||
}
|
||||
}, [details])
|
||||
|
||||
// Risk badge component
|
||||
const getRiskBadge = () => {
|
||||
if (!details) return null
|
||||
const level = details.risk_level
|
||||
const reason = details.risk_reason
|
||||
return (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium",
|
||||
level === 'high' && "bg-red-500/10 text-red-400",
|
||||
level === 'medium' && "bg-amber-500/10 text-amber-400",
|
||||
level === 'low' && "bg-accent/10 text-accent"
|
||||
)}>
|
||||
<span className={clsx(
|
||||
"w-2.5 h-2.5 rounded-full",
|
||||
level === 'high' && "bg-red-400",
|
||||
level === 'medium' && "bg-amber-400",
|
||||
level === 'low' && "bg-accent"
|
||||
)} />
|
||||
{reason}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const getTrendIcon = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'up': return <TrendingUp className="w-4 h-4" />
|
||||
@ -674,50 +688,87 @@ export default function TldDetailPage() {
|
||||
<p className="text-body-lg text-foreground-muted mb-2">{details.description}</p>
|
||||
<p className="text-body-sm text-foreground-subtle">{details.trend_reason}</p>
|
||||
|
||||
{/* Quick Stats - Only for authenticated */}
|
||||
{/* Quick Stats - All data from table */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-8">
|
||||
<div className="p-4 bg-background-secondary/50 border border-border/50 rounded-xl">
|
||||
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">Average</p>
|
||||
<div
|
||||
className="p-4 bg-background-secondary/50 border border-border/50 rounded-xl cursor-help"
|
||||
title="Lowest first-year registration price across all tracked registrars"
|
||||
>
|
||||
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">Buy (1y)</p>
|
||||
{isAuthenticated ? (
|
||||
<p className="text-body-lg font-medium text-foreground tabular-nums">${details.pricing.avg.toFixed(2)}</p>
|
||||
<p className="text-body-lg font-medium text-foreground tabular-nums">${details.pricing.min.toFixed(2)}</p>
|
||||
) : (
|
||||
<Shimmer className="h-6 w-16 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 bg-background-secondary/50 border border-border/50 rounded-xl">
|
||||
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">Range</p>
|
||||
<div
|
||||
className="p-4 bg-background-secondary/50 border border-border/50 rounded-xl cursor-help"
|
||||
title={renewalInfo?.isTrap
|
||||
? `Warning: Renewal is ${renewalInfo.ratio.toFixed(1)}x the registration price`
|
||||
: 'Annual renewal price after first year'}
|
||||
>
|
||||
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">Renew (1y)</p>
|
||||
{isAuthenticated ? (
|
||||
<p className="text-body-lg font-medium text-foreground tabular-nums">
|
||||
${details.pricing.min.toFixed(0)}–${details.pricing.max.toFixed(0)}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-body-lg font-medium text-foreground tabular-nums">
|
||||
${details.min_renewal_price.toFixed(2)}
|
||||
</p>
|
||||
{renewalInfo?.isTrap && (
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400" title={`Renewal trap: ${renewalInfo.ratio.toFixed(1)}x registration`} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Shimmer className="h-6 w-20 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 bg-background-secondary/50 border border-border/50 rounded-xl">
|
||||
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">30d Change</p>
|
||||
{isAuthenticated && history ? (
|
||||
<div
|
||||
className="p-4 bg-background-secondary/50 border border-border/50 rounded-xl cursor-help"
|
||||
title="Price change over the last 12 months"
|
||||
>
|
||||
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">1y Change</p>
|
||||
{isAuthenticated ? (
|
||||
<p className={clsx(
|
||||
"text-body-lg font-medium tabular-nums",
|
||||
history.price_change_30d > 0 ? "text-orange-400" :
|
||||
history.price_change_30d < 0 ? "text-accent" :
|
||||
details.price_change_1y > 0 ? "text-orange-400" :
|
||||
details.price_change_1y < 0 ? "text-accent" :
|
||||
"text-foreground"
|
||||
)}>
|
||||
{history.price_change_30d > 0 ? '+' : ''}{history.price_change_30d.toFixed(1)}%
|
||||
{details.price_change_1y > 0 ? '+' : ''}{details.price_change_1y.toFixed(0)}%
|
||||
</p>
|
||||
) : (
|
||||
<Shimmer className="h-6 w-14 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 bg-background-secondary/50 border border-border/50 rounded-xl">
|
||||
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">Registrars</p>
|
||||
<div
|
||||
className="p-4 bg-background-secondary/50 border border-border/50 rounded-xl cursor-help"
|
||||
title="Price change over the last 3 years"
|
||||
>
|
||||
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">3y Change</p>
|
||||
{isAuthenticated ? (
|
||||
<p className="text-body-lg font-medium text-foreground">{details.registrars.length}</p>
|
||||
<p className={clsx(
|
||||
"text-body-lg font-medium tabular-nums",
|
||||
details.price_change_3y > 0 ? "text-orange-400" :
|
||||
details.price_change_3y < 0 ? "text-accent" :
|
||||
"text-foreground"
|
||||
)}>
|
||||
{details.price_change_3y > 0 ? '+' : ''}{details.price_change_3y.toFixed(0)}%
|
||||
</p>
|
||||
) : (
|
||||
<Shimmer className="h-6 w-8 mt-1" />
|
||||
<Shimmer className="h-6 w-14 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Assessment */}
|
||||
{isAuthenticated && (
|
||||
<div className="flex items-center gap-4 mt-4 p-4 bg-background-secondary/30 border border-border/50 rounded-xl">
|
||||
<Shield className="w-5 h-5 text-foreground-muted" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-foreground">Risk Assessment</p>
|
||||
</div>
|
||||
{getRiskBadge()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Price Card */}
|
||||
@ -745,35 +796,12 @@ export default function TldDetailPage() {
|
||||
Register Domain
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
|
||||
<button
|
||||
onClick={handleToggleAlert}
|
||||
disabled={alertLoading}
|
||||
className={clsx(
|
||||
"flex items-center justify-center gap-2 w-full py-3.5 font-medium rounded-xl transition-all disabled:opacity-50",
|
||||
alertEnabled
|
||||
? "bg-accent/10 text-accent border border-accent/30"
|
||||
: "bg-background border border-border text-foreground hover:bg-background-secondary"
|
||||
)}
|
||||
>
|
||||
{alertLoading ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Bell className={clsx("w-4 h-4", alertEnabled && "fill-current")} />
|
||||
)}
|
||||
{alertLoading
|
||||
? 'Updating...'
|
||||
: alertEnabled
|
||||
? 'Price Alert Active'
|
||||
: 'Enable Price Alert'
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{savings && savings.amount > 0.5 && (
|
||||
<div className="mt-5 pt-5 border-t border-border/50">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Sparkles className="w-4 h-4 text-accent mt-0.5 shrink-0" />
|
||||
<Zap className="w-4 h-4 text-accent mt-0.5 shrink-0" />
|
||||
<p className="text-ui-sm text-foreground-muted leading-relaxed">
|
||||
Save <span className="text-accent font-medium">${savings.amount.toFixed(2)}</span>/yr vs {savings.expensiveName}
|
||||
</p>
|
||||
@ -798,6 +826,20 @@ export default function TldDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Renewal Trap Warning */}
|
||||
{isAuthenticated && renewalInfo?.isTrap && (
|
||||
<div className="mb-8 p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-400">Renewal Trap Detected</p>
|
||||
<p className="text-sm text-foreground-muted mt-1">
|
||||
The renewal price (${renewalInfo.renewal.toFixed(2)}) is {renewalInfo.ratio.toFixed(1)}x higher than the registration price (${renewalInfo.registration.toFixed(2)}).
|
||||
Consider the total cost of ownership before registering.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price Chart */}
|
||||
<section className="mb-12">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@ -941,68 +983,102 @@ export default function TldDetailPage() {
|
||||
<th className="text-left text-ui-xs text-foreground-subtle font-medium uppercase tracking-wider px-5 py-4">
|
||||
Registrar
|
||||
</th>
|
||||
<th className="text-right text-ui-xs text-foreground-subtle font-medium uppercase tracking-wider px-5 py-4">
|
||||
<th className="text-right text-ui-xs text-foreground-subtle font-medium uppercase tracking-wider px-5 py-4 cursor-help" title="First year registration price">
|
||||
Register
|
||||
</th>
|
||||
<th className="text-right text-ui-xs text-foreground-subtle font-medium uppercase tracking-wider px-5 py-4 hidden sm:table-cell">
|
||||
<th className="text-right text-ui-xs text-foreground-subtle font-medium uppercase tracking-wider px-5 py-4 hidden sm:table-cell cursor-help" title="Annual renewal price">
|
||||
Renew
|
||||
</th>
|
||||
<th className="text-right text-ui-xs text-foreground-subtle font-medium uppercase tracking-wider px-5 py-4 hidden sm:table-cell">
|
||||
<th className="text-right text-ui-xs text-foreground-subtle font-medium uppercase tracking-wider px-5 py-4 hidden sm:table-cell cursor-help" title="Transfer from another registrar">
|
||||
Transfer
|
||||
</th>
|
||||
<th className="px-5 py-4 w-24"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/30">
|
||||
{details.registrars.map((registrar, i) => (
|
||||
<tr key={registrar.name} className={clsx(
|
||||
"transition-colors group",
|
||||
i === 0 && "bg-accent/[0.03]"
|
||||
)}>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-body-sm font-medium text-foreground">{registrar.name}</span>
|
||||
{i === 0 && (
|
||||
<span className="text-ui-xs text-accent bg-accent/10 px-2 py-0.5 rounded-full font-medium">
|
||||
Best
|
||||
</span>
|
||||
{details.registrars.map((registrar, i) => {
|
||||
const hasRenewalTrap = registrar.renewal_price / registrar.registration_price > 1.5
|
||||
const isBestValue = i === 0 && !hasRenewalTrap
|
||||
|
||||
return (
|
||||
<tr key={registrar.name} className={clsx(
|
||||
"transition-colors group",
|
||||
isBestValue && "bg-accent/[0.03]"
|
||||
)}>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-body-sm font-medium text-foreground">{registrar.name}</span>
|
||||
{isBestValue && (
|
||||
<span
|
||||
className="text-ui-xs text-accent bg-accent/10 px-2 py-0.5 rounded-full font-medium cursor-help"
|
||||
title="Best overall value: lowest registration price without renewal trap"
|
||||
>
|
||||
Best
|
||||
</span>
|
||||
)}
|
||||
{i === 0 && hasRenewalTrap && (
|
||||
<span
|
||||
className="text-ui-xs text-amber-400 bg-amber-500/10 px-2 py-0.5 rounded-full font-medium cursor-help"
|
||||
title="Cheapest registration but high renewal costs"
|
||||
>
|
||||
Cheap Start
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<span
|
||||
className={clsx(
|
||||
"text-body-sm font-medium tabular-nums cursor-help",
|
||||
isBestValue ? "text-accent" : "text-foreground"
|
||||
)}
|
||||
title={`First year: $${registrar.registration_price.toFixed(2)}`}
|
||||
>
|
||||
${registrar.registration_price.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right hidden sm:table-cell">
|
||||
<span
|
||||
className={clsx(
|
||||
"text-body-sm tabular-nums cursor-help",
|
||||
hasRenewalTrap ? "text-amber-400" : "text-foreground-muted"
|
||||
)}
|
||||
title={hasRenewalTrap
|
||||
? `Renewal is ${(registrar.renewal_price / registrar.registration_price).toFixed(1)}x the registration price`
|
||||
: `Annual renewal: $${registrar.renewal_price.toFixed(2)}`}
|
||||
>
|
||||
${registrar.renewal_price.toFixed(2)}
|
||||
</span>
|
||||
{hasRenewalTrap && (
|
||||
<AlertTriangle
|
||||
className="inline-block ml-1.5 w-3.5 h-3.5 text-amber-400 cursor-help"
|
||||
title={`Renewal trap: ${(registrar.renewal_price / registrar.registration_price).toFixed(1)}x registration price`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<span className={clsx(
|
||||
"text-body-sm font-medium tabular-nums",
|
||||
i === 0 ? "text-accent" : "text-foreground"
|
||||
)}>
|
||||
${registrar.registration_price.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right hidden sm:table-cell">
|
||||
<span className="text-body-sm text-foreground-muted tabular-nums">
|
||||
${registrar.renewal_price.toFixed(2)}
|
||||
</span>
|
||||
{registrar.renewal_price > registrar.registration_price * 1.5 && (
|
||||
<span className="ml-1.5 text-orange-400" title="High renewal">⚠</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right hidden sm:table-cell">
|
||||
<span className="text-body-sm text-foreground-muted tabular-nums">
|
||||
${registrar.transfer_price.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<a
|
||||
href={getRegistrarUrl(registrar.name, `example.${tld}`)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-ui-sm text-foreground-muted hover:text-accent transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
Visit
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right hidden sm:table-cell">
|
||||
<span
|
||||
className="text-body-sm text-foreground-muted tabular-nums cursor-help"
|
||||
title={`Transfer from another registrar: $${registrar.transfer_price.toFixed(2)}`}
|
||||
>
|
||||
${registrar.transfer_price.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<a
|
||||
href={getRegistrarUrl(registrar.name, `example.${tld}`)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-ui-sm text-foreground-muted hover:text-accent transition-colors opacity-0 group-hover:opacity-100"
|
||||
title={`Register at ${registrar.name}`}
|
||||
>
|
||||
Visit
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -1027,7 +1103,7 @@ export default function TldDetailPage() {
|
||||
href="/register"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-accent text-background text-ui-sm font-medium rounded-lg hover:bg-accent-hover transition-all"
|
||||
>
|
||||
Get Started Free
|
||||
Join the Hunt
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@ -1093,10 +1169,10 @@ export default function TldDetailPage() {
|
||||
Monitor specific domains and get instant notifications when they become available.
|
||||
</p>
|
||||
<Link
|
||||
href={isAuthenticated ? '/dashboard' : '/register'}
|
||||
href={isAuthenticated ? '/command' : '/register'}
|
||||
className="inline-flex items-center gap-2 px-8 py-4 bg-foreground text-background text-ui font-medium rounded-xl hover:bg-foreground/90 transition-all"
|
||||
>
|
||||
{isAuthenticated ? 'Go to Dashboard' : 'Start Monitoring Free'}
|
||||
{isAuthenticated ? 'Go to Command Center' : 'Start Monitoring Free'}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
@ -1,24 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { PremiumTable } from '@/components/PremiumTable'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
Lock,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Search,
|
||||
X,
|
||||
Lock,
|
||||
Globe,
|
||||
AlertTriangle,
|
||||
ArrowUpDown,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
@ -30,8 +27,15 @@ interface TldData {
|
||||
avg_registration_price: number
|
||||
min_registration_price: number
|
||||
max_registration_price: number
|
||||
min_renewal_price: number
|
||||
avg_renewal_price: number
|
||||
registrar_count: number
|
||||
trend: string
|
||||
price_change_7d: number
|
||||
price_change_1y: number
|
||||
price_change_3y: number
|
||||
risk_level: 'low' | 'medium' | 'high'
|
||||
risk_reason: string
|
||||
popularity_rank?: number
|
||||
}
|
||||
|
||||
@ -49,118 +53,54 @@ interface PaginationData {
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
type SortField = 'popularity' | 'tld' | 'avg_registration_price' | 'min_registration_price'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
// Mini sparkline chart component
|
||||
function MiniChart({ tld, isAuthenticated }: { tld: string, isAuthenticated: boolean }) {
|
||||
const [historyData, setHistoryData] = useState<number[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadHistory()
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [tld, isAuthenticated])
|
||||
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const data = await api.getTldHistory(tld, 365)
|
||||
const history = data.history || []
|
||||
const sampledData = history
|
||||
.filter((_: unknown, i: number) => i % Math.max(1, Math.floor(history.length / 12)) === 0)
|
||||
.slice(0, 12)
|
||||
.map((h: { price: number }) => h.price)
|
||||
|
||||
setHistoryData(sampledData.length > 0 ? sampledData : [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load history:', error)
|
||||
setHistoryData([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-ui-sm text-foreground-subtle">
|
||||
<Lock className="w-3 h-3" />
|
||||
<span>Sign in</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="w-32 h-10 bg-background-tertiary rounded animate-pulse" />
|
||||
}
|
||||
|
||||
if (historyData.length === 0) {
|
||||
return <div className="w-32 h-10 flex items-center justify-center text-ui-sm text-foreground-subtle">No data</div>
|
||||
}
|
||||
|
||||
const min = Math.min(...historyData)
|
||||
const max = Math.max(...historyData)
|
||||
const range = max - min || 1
|
||||
const isIncreasing = historyData[historyData.length - 1] > historyData[0]
|
||||
|
||||
const linePoints = historyData.map((value, i) => {
|
||||
const x = (i / (historyData.length - 1)) * 100
|
||||
const y = 100 - ((value - min) / range) * 80 - 10
|
||||
return `${x},${y}`
|
||||
}).join(' ')
|
||||
|
||||
const areaPath = historyData.map((value, i) => {
|
||||
const x = (i / (historyData.length - 1)) * 100
|
||||
const y = 100 - ((value - min) / range) * 80 - 10
|
||||
return i === 0 ? `M${x},${y}` : `L${x},${y}`
|
||||
}).join(' ') + ' L100,100 L0,100 Z'
|
||||
|
||||
const gradientId = `gradient-${tld}`
|
||||
|
||||
// Sparkline component - matching Command Center exactly
|
||||
function Sparkline({ trend }: { trend: number }) {
|
||||
const isPositive = trend > 0
|
||||
const isNeutral = trend === 0
|
||||
|
||||
return (
|
||||
<svg className="w-32 h-10" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={isIncreasing ? "#f97316" : "#00d4aa"} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={isIncreasing ? "#f97316" : "#00d4aa"} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={areaPath} fill={`url(#${gradientId})`} />
|
||||
<polyline
|
||||
points={linePoints}
|
||||
fill="none"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={isIncreasing ? "stroke-[#f97316]" : "stroke-accent"}
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg width="40" height="16" viewBox="0 0 40 16" className="overflow-visible">
|
||||
{isNeutral ? (
|
||||
<line x1="0" y1="8" x2="40" y2="8" stroke="currentColor" className="text-foreground-muted" strokeWidth="1.5" />
|
||||
) : isPositive ? (
|
||||
<polyline
|
||||
points="0,14 10,12 20,10 30,6 40,2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-orange-400"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
) : (
|
||||
<polyline
|
||||
points="0,2 10,6 20,10 30,12 40,14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-accent"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortIcon({ field, currentField, direction }: { field: SortField, currentField: SortField, direction: SortDirection }) {
|
||||
if (field !== currentField) {
|
||||
return <ChevronsUpDown className="w-4 h-4 text-foreground-subtle" />
|
||||
}
|
||||
return direction === 'asc'
|
||||
? <ChevronUp className="w-4 h-4 text-accent" />
|
||||
: <ChevronDown className="w-4 h-4 text-accent" />
|
||||
}
|
||||
|
||||
export default function TldPricingPage() {
|
||||
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
|
||||
const [tlds, setTlds] = useState<TldData[]>([])
|
||||
const [trending, setTrending] = useState<TrendingTld[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [pagination, setPagination] = useState<PaginationData>({ total: 0, limit: 25, offset: 0, has_more: false })
|
||||
const [pagination, setPagination] = useState<PaginationData>({ total: 0, limit: 50, offset: 0, has_more: false })
|
||||
|
||||
// Search & Sort state
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [sortField, setSortField] = useState<SortField>('popularity')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
||||
const [sortBy, setSortBy] = useState('popularity')
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
@ -178,28 +118,25 @@ export default function TldPricingPage() {
|
||||
// Load TLDs with pagination, search, and sort
|
||||
useEffect(() => {
|
||||
loadTlds()
|
||||
}, [debouncedSearch, sortField, sortDirection, pagination.offset])
|
||||
}, [debouncedSearch, sortBy, page])
|
||||
|
||||
const loadTlds = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const sortBy = sortField === 'tld' ? 'name' : sortField === 'popularity' ? 'popularity' :
|
||||
sortField === 'avg_registration_price' ? (sortDirection === 'asc' ? 'price_asc' : 'price_desc') :
|
||||
(sortDirection === 'asc' ? 'price_asc' : 'price_desc')
|
||||
|
||||
const data = await api.getTldOverview(
|
||||
pagination.limit,
|
||||
pagination.offset,
|
||||
50,
|
||||
page * 50,
|
||||
sortBy,
|
||||
debouncedSearch || undefined
|
||||
)
|
||||
|
||||
setTlds(data?.tlds || [])
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
setPagination({
|
||||
total: data?.total || 0,
|
||||
limit: 50,
|
||||
offset: page * 50,
|
||||
has_more: data?.has_more || false,
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load TLD data:', error)
|
||||
setTlds([])
|
||||
@ -217,32 +154,40 @@ export default function TldPricingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortField(field)
|
||||
setSortDirection('asc')
|
||||
}
|
||||
// Reset to first page on sort change
|
||||
setPagination(prev => ({ ...prev, offset: 0 }))
|
||||
// Risk badge - matching Command Center exactly
|
||||
const getRiskBadge = (tld: TldData) => {
|
||||
const level = tld.risk_level || 'low'
|
||||
const reason = tld.risk_reason || 'Stable'
|
||||
return (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
level === 'high' && "bg-red-500/10 text-red-400",
|
||||
level === 'medium' && "bg-amber-500/10 text-amber-400",
|
||||
level === 'low' && "bg-accent/10 text-accent"
|
||||
)}>
|
||||
<span className={clsx(
|
||||
"w-2.5 h-2.5 rounded-full",
|
||||
level === 'high' && "bg-red-400",
|
||||
level === 'medium' && "bg-amber-400",
|
||||
level === 'low' && "bg-accent"
|
||||
)} />
|
||||
<span className="hidden sm:inline ml-1">{reason}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const handlePageChange = (newOffset: number) => {
|
||||
setPagination(prev => ({ ...prev, offset: newOffset }))
|
||||
// Scroll to top of table
|
||||
window.scrollTo({ top: 300, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const getTrendIcon = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'up':
|
||||
return <TrendingUp className="w-4 h-4 text-[#f97316]" />
|
||||
case 'down':
|
||||
return <TrendingDown className="w-4 h-4 text-accent" />
|
||||
default:
|
||||
return <Minus className="w-4 h-4 text-foreground-subtle" />
|
||||
// Get renewal trap indicator
|
||||
const getRenewalTrap = (tld: TldData) => {
|
||||
if (!tld.min_renewal_price || !tld.min_registration_price) return null
|
||||
const ratio = tld.min_renewal_price / tld.min_registration_price
|
||||
if (ratio > 2) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-amber-400" title={`Renewal is ${ratio.toFixed(1)}x the registration price`}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Pagination calculations
|
||||
@ -277,37 +222,63 @@ export default function TldPricingPage() {
|
||||
<main className="relative pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6 flex-1">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-16 sm:mb-20 animate-fade-in">
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Market Intel</span>
|
||||
<h1 className="mt-4 font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] lg:text-[5rem] leading-[0.95] tracking-[-0.03em] text-foreground">
|
||||
{pagination.total}+ TLDs. Live Prices.
|
||||
<div className="text-center mb-12 sm:mb-16 animate-fade-in">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 text-accent text-sm mb-6">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span>Real-time Market Data</span>
|
||||
</div>
|
||||
<h1 className="font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] leading-[0.95] tracking-[-0.03em] text-foreground">
|
||||
{pagination.total}+ TLDs.
|
||||
<span className="block text-accent">True Costs.</span>
|
||||
</h1>
|
||||
<p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
|
||||
See what domains cost. Spot trends. Find opportunities.
|
||||
Don't fall for promo prices. See renewal costs, spot traps, and track price trends across every extension.
|
||||
</p>
|
||||
|
||||
{/* Feature Pills */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 mt-8">
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-foreground/5 rounded-full text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-foreground-muted">Renewal Trap Detection</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-foreground/5 rounded-full text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-accent" />
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||
<span className="w-2 h-2 rounded-full bg-red-400" />
|
||||
</div>
|
||||
<span className="text-foreground-muted">Risk Levels</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-foreground/5 rounded-full text-sm">
|
||||
<TrendingUp className="w-4 h-4 text-orange-400" />
|
||||
<span className="text-foreground-muted">1y/3y Trends</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Banner for non-authenticated users */}
|
||||
{!isAuthenticated && (
|
||||
<div className="mb-8 p-5 bg-accent-muted border border-accent/20 rounded-xl flex flex-col sm:flex-row items-center justify-between gap-4 animate-fade-in">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-accent/20 rounded-xl flex items-center justify-center">
|
||||
<Lock className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-body-sm font-medium text-foreground">See the full picture</p>
|
||||
<p className="text-ui-sm text-foreground-muted">
|
||||
Sign in for detailed pricing, charts, and trends.
|
||||
</p>
|
||||
<div className="mb-8 p-6 bg-gradient-to-r from-accent/10 to-accent/5 border border-accent/20 rounded-2xl animate-fade-in">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-accent/20 rounded-xl flex items-center justify-center">
|
||||
<Lock className="w-6 h-6 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Stop overpaying. Know the true costs.</p>
|
||||
<p className="text-sm text-foreground-muted">
|
||||
Unlock renewal traps, 1y/3y trends, and risk analysis for {pagination.total}+ TLDs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/register"
|
||||
className="shrink-0 px-6 py-3 bg-accent text-background font-medium rounded-xl
|
||||
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
|
||||
>
|
||||
Start Free
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/register"
|
||||
className="shrink-0 px-5 py-2.5 bg-accent text-background text-ui font-medium rounded-lg
|
||||
hover:bg-accent-hover transition-all duration-300"
|
||||
>
|
||||
Hunt Free
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -351,9 +322,10 @@ export default function TldPricingPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-6 animate-slide-up">
|
||||
<div className="relative max-w-md">
|
||||
{/* Search & Sort Controls */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6 animate-slide-up">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
|
||||
<input
|
||||
type="text"
|
||||
@ -361,7 +333,7 @@ export default function TldPricingPage() {
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setPagination(prev => ({ ...prev, offset: 0 }))
|
||||
setPage(0)
|
||||
}}
|
||||
className="w-full pl-12 pr-10 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||
text-body text-foreground placeholder:text-foreground-subtle
|
||||
@ -372,7 +344,7 @@ export default function TldPricingPage() {
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
setPagination(prev => ({ ...prev, offset: 0 }))
|
||||
setPage(0)
|
||||
}}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 text-foreground-subtle hover:text-foreground transition-colors"
|
||||
>
|
||||
@ -380,234 +352,213 @@ export default function TldPricingPage() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TLD Table */}
|
||||
<div className="bg-background-secondary/30 border border-border rounded-xl overflow-hidden animate-slide-up">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-background-secondary border-b border-border">
|
||||
<th className="text-left px-4 sm:px-6 py-4">
|
||||
<button
|
||||
onClick={() => handleSort('popularity')}
|
||||
className="flex items-center gap-2 text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
#
|
||||
<SortIcon field="popularity" currentField={sortField} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left px-4 sm:px-6 py-4">
|
||||
<button
|
||||
onClick={() => handleSort('tld')}
|
||||
className="flex items-center gap-2 text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
TLD
|
||||
<SortIcon field="tld" currentField={sortField} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left px-4 sm:px-6 py-4 hidden lg:table-cell">
|
||||
<span className="text-ui-sm text-foreground-subtle font-medium">Type</span>
|
||||
</th>
|
||||
<th className="text-left px-4 sm:px-6 py-4 hidden md:table-cell">
|
||||
<span className="text-ui-sm text-foreground-subtle font-medium">12-Month Trend</span>
|
||||
</th>
|
||||
<th className="text-right px-4 sm:px-6 py-4">
|
||||
<button
|
||||
onClick={() => handleSort('avg_registration_price')}
|
||||
className="flex items-center gap-2 ml-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
Avg. Price
|
||||
<SortIcon field="avg_registration_price" currentField={sortField} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-right px-4 sm:px-6 py-4 hidden sm:table-cell">
|
||||
<button
|
||||
onClick={() => handleSort('min_registration_price')}
|
||||
className="flex items-center gap-2 ml-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
From
|
||||
<SortIcon field="min_registration_price" currentField={sortField} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-center px-4 sm:px-6 py-4 hidden sm:table-cell">
|
||||
<span className="text-ui-sm text-foreground-subtle font-medium">Trend</span>
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading ? (
|
||||
// Loading skeleton
|
||||
Array.from({ length: 10 }).map((_, idx) => (
|
||||
<tr key={idx} className="animate-pulse">
|
||||
<td className="px-4 sm:px-6 py-4"><div className="h-4 w-8 bg-background-tertiary rounded" /></td>
|
||||
<td className="px-4 sm:px-6 py-4"><div className="h-4 w-16 bg-background-tertiary rounded" /></td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden lg:table-cell"><div className="h-4 w-20 bg-background-tertiary rounded" /></td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden md:table-cell"><div className="h-10 w-32 bg-background-tertiary rounded" /></td>
|
||||
<td className="px-4 sm:px-6 py-4"><div className="h-4 w-16 bg-background-tertiary rounded ml-auto" /></td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden sm:table-cell"><div className="h-4 w-16 bg-background-tertiary rounded ml-auto" /></td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden sm:table-cell"><div className="h-4 w-6 bg-background-tertiary rounded mx-auto" /></td>
|
||||
<td className="px-4 sm:px-6 py-4"><div className="h-4 w-12 bg-background-tertiary rounded" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : tlds.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-12 text-center text-foreground-muted">
|
||||
{searchQuery ? `No TLDs found matching "${searchQuery}"` : 'No TLDs found'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
tlds.map((tld, idx) => (
|
||||
<tr
|
||||
key={tld.tld}
|
||||
className="hover:bg-background-secondary/50 transition-colors group"
|
||||
>
|
||||
<td className="px-4 sm:px-6 py-4">
|
||||
<span className="text-body-sm text-foreground-subtle">
|
||||
{pagination.offset + idx + 1}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4">
|
||||
<span className="font-mono text-body-sm sm:text-body font-medium text-foreground">
|
||||
.{tld.tld}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden lg:table-cell">
|
||||
<span className={clsx(
|
||||
"text-ui-sm px-2 py-0.5 rounded-full",
|
||||
tld.type === 'generic' ? 'text-accent bg-accent-muted' :
|
||||
tld.type === 'ccTLD' ? 'text-blue-400 bg-blue-400/10' :
|
||||
'text-purple-400 bg-purple-400/10'
|
||||
)}>
|
||||
{tld.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden md:table-cell">
|
||||
<MiniChart tld={tld.tld} isAuthenticated={isAuthenticated} />
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
{isAuthenticated ? (
|
||||
<span className="text-body-sm font-medium text-foreground">
|
||||
${tld.avg_registration_price.toFixed(2)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-body-sm text-foreground-subtle">•••</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 text-right hidden sm:table-cell">
|
||||
{isAuthenticated ? (
|
||||
<span className="text-body-sm text-accent">
|
||||
${tld.min_registration_price.toFixed(2)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-body-sm text-foreground-subtle">•••</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 text-center hidden sm:table-cell">
|
||||
{isAuthenticated ? getTrendIcon(tld.trend) : <Minus className="w-4 h-4 text-foreground-subtle mx-auto" />}
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4">
|
||||
<Link
|
||||
href={isAuthenticated ? `/tld-pricing/${tld.tld}` : '/register'}
|
||||
className="flex items-center gap-1 text-ui-sm text-accent hover:text-accent-hover transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
Details
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => {
|
||||
setSortBy(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
className="appearance-none pl-4 pr-10 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||
text-body text-foreground focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent
|
||||
transition-all cursor-pointer min-w-[180px]"
|
||||
>
|
||||
<option value="popularity">Most Popular</option>
|
||||
<option value="name">Alphabetical</option>
|
||||
<option value="price_asc">Price: Low → High</option>
|
||||
<option value="price_desc">Price: High → Low</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>
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && pagination.total > pagination.limit && (
|
||||
<div className="px-4 sm:px-6 py-4 border-t border-border flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-ui-sm text-foreground-subtle">
|
||||
Showing {pagination.offset + 1}-{Math.min(pagination.offset + pagination.limit, pagination.total)} of {pagination.total} TLDs
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Previous Button */}
|
||||
<button
|
||||
onClick={() => handlePageChange(pagination.offset - pagination.limit)}
|
||||
disabled={pagination.offset === 0}
|
||||
className={clsx(
|
||||
"flex items-center gap-1 px-3 py-2 rounded-lg text-ui-sm transition-all",
|
||||
pagination.offset === 0
|
||||
? "text-foreground-subtle cursor-not-allowed"
|
||||
: "text-foreground hover:bg-background-secondary"
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Prev
|
||||
</button>
|
||||
|
||||
{/* Page Numbers */}
|
||||
<div className="hidden sm:flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let pageNum: number
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1
|
||||
} else if (currentPage <= 3) {
|
||||
pageNum = i + 1
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i
|
||||
} else {
|
||||
pageNum = currentPage - 2 + i
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => handlePageChange((pageNum - 1) * pagination.limit)}
|
||||
className={clsx(
|
||||
"w-9 h-9 rounded-lg text-ui-sm font-medium transition-all",
|
||||
currentPage === pageNum
|
||||
? "bg-accent text-background"
|
||||
: "text-foreground-muted hover:bg-background-secondary hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Mobile Page Indicator */}
|
||||
<span className="sm:hidden text-ui-sm text-foreground-muted">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
|
||||
{/* Next Button */}
|
||||
<button
|
||||
onClick={() => handlePageChange(pagination.offset + pagination.limit)}
|
||||
disabled={!pagination.has_more}
|
||||
className={clsx(
|
||||
"flex items-center gap-1 px-3 py-2 rounded-lg text-ui-sm transition-all",
|
||||
!pagination.has_more
|
||||
? "text-foreground-subtle cursor-not-allowed"
|
||||
: "text-foreground hover:bg-background-secondary"
|
||||
)}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TLD Table using PremiumTable - matching Command Center exactly */}
|
||||
<PremiumTable
|
||||
data={tlds}
|
||||
keyExtractor={(tld) => tld.tld}
|
||||
loading={loading}
|
||||
onRowClick={(tld) => {
|
||||
if (isAuthenticated) {
|
||||
window.location.href = `/tld-pricing/${tld.tld}`
|
||||
} else {
|
||||
window.location.href = `/login?redirect=/tld-pricing/${tld.tld}`
|
||||
}
|
||||
}}
|
||||
emptyIcon={<Globe className="w-12 h-12 text-foreground-subtle" />}
|
||||
emptyTitle="No TLDs found"
|
||||
emptyDescription={searchQuery ? `No TLDs matching "${searchQuery}"` : "Check back later for TLD data"}
|
||||
columns={[
|
||||
{
|
||||
key: 'tld',
|
||||
header: 'TLD',
|
||||
width: '100px',
|
||||
render: (tld, idx) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-lg font-semibold text-foreground group-hover:text-accent transition-colors">
|
||||
.{tld.tld}
|
||||
</span>
|
||||
{!isAuthenticated && idx === 0 && page === 0 && (
|
||||
<span className="text-xs text-accent">Preview</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'trend',
|
||||
header: 'Trend',
|
||||
width: '80px',
|
||||
hideOnMobile: true,
|
||||
render: (tld, idx) => {
|
||||
const showData = isAuthenticated || (page === 0 && idx === 0)
|
||||
if (!showData) {
|
||||
return <div className="w-10 h-4 bg-foreground/5 rounded blur-[3px]" />
|
||||
}
|
||||
return <Sparkline trend={tld.price_change_1y || 0} />
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'buy_price',
|
||||
header: 'Buy (1y)',
|
||||
align: 'right',
|
||||
width: '100px',
|
||||
render: (tld, idx) => {
|
||||
const showData = isAuthenticated || (page === 0 && idx === 0)
|
||||
if (!showData) {
|
||||
return <span className="text-foreground-subtle">•••</span>
|
||||
}
|
||||
return <span className="font-semibold text-foreground tabular-nums">${tld.min_registration_price.toFixed(2)}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'renew_price',
|
||||
header: 'Renew (1y)',
|
||||
align: 'right',
|
||||
width: '120px',
|
||||
render: (tld, idx) => {
|
||||
const showData = isAuthenticated || (page === 0 && idx === 0)
|
||||
if (!showData) {
|
||||
return <span className="text-foreground-subtle blur-[3px]">$XX.XX</span>
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<span className="text-foreground-muted tabular-nums">
|
||||
${tld.min_renewal_price?.toFixed(2) || '—'}
|
||||
</span>
|
||||
{getRenewalTrap(tld)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'change_1y',
|
||||
header: '1y Change',
|
||||
align: 'right',
|
||||
width: '100px',
|
||||
hideOnMobile: true,
|
||||
render: (tld, idx) => {
|
||||
const showData = isAuthenticated || (page === 0 && idx === 0)
|
||||
if (!showData) {
|
||||
return <span className="text-foreground-subtle blur-[3px]">+X%</span>
|
||||
}
|
||||
const change = tld.price_change_1y || 0
|
||||
return (
|
||||
<span className={clsx(
|
||||
"font-medium tabular-nums",
|
||||
change > 0 ? "text-orange-400" : change < 0 ? "text-accent" : "text-foreground-muted"
|
||||
)}>
|
||||
{change > 0 ? '+' : ''}{change.toFixed(0)}%
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'change_3y',
|
||||
header: '3y Change',
|
||||
align: 'right',
|
||||
width: '100px',
|
||||
hideOnMobile: true,
|
||||
render: (tld, idx) => {
|
||||
const showData = isAuthenticated || (page === 0 && idx === 0)
|
||||
if (!showData) {
|
||||
return <span className="text-foreground-subtle blur-[3px]">+X%</span>
|
||||
}
|
||||
const change = tld.price_change_3y || 0
|
||||
return (
|
||||
<span className={clsx(
|
||||
"font-medium tabular-nums",
|
||||
change > 0 ? "text-orange-400" : change < 0 ? "text-accent" : "text-foreground-muted"
|
||||
)}>
|
||||
{change > 0 ? '+' : ''}{change.toFixed(0)}%
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'risk',
|
||||
header: 'Risk',
|
||||
align: 'center',
|
||||
width: '130px',
|
||||
render: (tld, idx) => {
|
||||
const showData = isAuthenticated || (page === 0 && idx === 0)
|
||||
if (!showData) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-foreground/5 blur-[3px]">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-foreground-subtle" />
|
||||
<span className="hidden sm:inline ml-1">Hidden</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return getRiskBadge(tld)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right',
|
||||
width: '80px',
|
||||
render: () => (
|
||||
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && pagination.total > pagination.limit && (
|
||||
<div className="flex items-center justify-center gap-4 pt-2">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
||||
bg-foreground/5 hover:bg-foreground/10 rounded-lg
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-foreground-muted tabular-nums">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={!pagination.has_more}
|
||||
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
||||
bg-foreground/5 hover:bg-foreground/10 rounded-lg
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{!loading && (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<p className="text-ui-sm text-foreground-subtle">
|
||||
{searchQuery
|
||||
? `Found ${pagination.total} TLDs matching "${searchQuery}"`
|
||||
: `${pagination.total} TLDs available • Sorted by ${sortField === 'popularity' ? 'popularity' : sortField === 'tld' ? 'name' : 'price'}`
|
||||
: `${pagination.total} TLDs available`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
400
frontend/src/components/AdminLayout.tsx
Normal file
400
frontend/src/components/AdminLayout.tsx
Normal file
@ -0,0 +1,400 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode, useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { KeyboardShortcutsProvider, useAdminShortcuts, ShortcutHint } from '@/hooks/useKeyboardShortcuts'
|
||||
import {
|
||||
Activity,
|
||||
Users,
|
||||
Bell,
|
||||
Mail,
|
||||
Globe,
|
||||
Gavel,
|
||||
BookOpen,
|
||||
Database,
|
||||
History,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
Shield,
|
||||
LayoutDashboard,
|
||||
Menu,
|
||||
X,
|
||||
Command,
|
||||
Settings,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// ============================================================================
|
||||
// ADMIN LAYOUT
|
||||
// ============================================================================
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: ReactNode
|
||||
title?: string
|
||||
subtitle?: string
|
||||
actions?: ReactNode
|
||||
activeTab?: string
|
||||
onTabChange?: (tab: string) => void
|
||||
}
|
||||
|
||||
export function AdminLayout({
|
||||
children,
|
||||
title = 'Admin Panel',
|
||||
subtitle,
|
||||
actions,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
}: AdminLayoutProps) {
|
||||
const router = useRouter()
|
||||
const { user, isAuthenticated, isLoading, checkAuth, logout } = useStore()
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [checkAuth])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router])
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('admin-sidebar-collapsed')
|
||||
if (saved) setSidebarCollapsed(saved === 'true')
|
||||
}, [])
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
const newState = !sidebarCollapsed
|
||||
setSidebarCollapsed(newState)
|
||||
localStorage.setItem('admin-sidebar-collapsed', String(newState))
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user?.is_admin) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<Shield className="w-16 h-16 text-red-400 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-semibold text-foreground mb-2">Access Denied</h1>
|
||||
<p className="text-foreground-muted mb-4">Admin privileges required</p>
|
||||
<button
|
||||
onClick={() => router.push('/command/dashboard')}
|
||||
className="px-4 py-2 bg-accent text-background rounded-lg font-medium"
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardShortcutsProvider>
|
||||
<AdminShortcutsWrapper />
|
||||
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-[-30%] left-[-10%] w-[800px] h-[800px] bg-red-500/[0.02] rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-[-20%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
{/* Admin Sidebar */}
|
||||
<AdminSidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onCollapse={toggleCollapsed}
|
||||
mobileOpen={mobileOpen}
|
||||
onMobileClose={() => setMobileOpen(false)}
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="lg:hidden fixed top-4 left-4 z-50 w-11 h-11 bg-background/80 backdrop-blur-xl border border-border
|
||||
rounded-xl flex items-center justify-center text-foreground-muted hover:text-foreground
|
||||
transition-all shadow-lg hover:shadow-xl hover:border-red-500/30"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Main Content */}
|
||||
<div
|
||||
className={clsx(
|
||||
"relative min-h-screen transition-all duration-300",
|
||||
"lg:ml-[280px]",
|
||||
sidebarCollapsed && "lg:ml-[80px]",
|
||||
"ml-0 pt-16 lg:pt-0"
|
||||
)}
|
||||
>
|
||||
{/* Top Bar */}
|
||||
<header className="sticky top-0 z-30 h-16 sm:h-20 bg-gradient-to-r from-background/90 via-background/80 to-background/90 backdrop-blur-xl border-b border-border/30">
|
||||
<div className="h-full px-4 sm:px-6 lg:px-8 flex items-center justify-between">
|
||||
<div className="ml-10 lg:ml-0">
|
||||
<h1 className="text-lg sm:text-xl lg:text-2xl font-semibold tracking-tight text-foreground">{title}</h1>
|
||||
{subtitle && <p className="text-xs sm:text-sm text-foreground-muted mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{actions}
|
||||
<button
|
||||
onClick={() => {}}
|
||||
className="hidden sm:flex items-center gap-2 px-3 py-1.5 text-xs text-foreground-subtle hover:text-foreground
|
||||
bg-foreground/5 rounded-lg border border-border/50 hover:border-border transition-all"
|
||||
title="Keyboard shortcuts"
|
||||
>
|
||||
<Command className="w-3 h-3" />
|
||||
<span>?</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="p-4 sm:p-6 lg:p-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</KeyboardShortcutsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ADMIN SIDEBAR
|
||||
// ============================================================================
|
||||
|
||||
interface AdminSidebarProps {
|
||||
collapsed: boolean
|
||||
onCollapse: () => void
|
||||
mobileOpen: boolean
|
||||
onMobileClose: () => void
|
||||
user: any
|
||||
onLogout: () => void
|
||||
activeTab?: string
|
||||
onTabChange?: (tab: string) => void
|
||||
}
|
||||
|
||||
function AdminSidebar({
|
||||
collapsed,
|
||||
onCollapse,
|
||||
mobileOpen,
|
||||
onMobileClose,
|
||||
user,
|
||||
onLogout,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
}: AdminSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
|
||||
const navItems = [
|
||||
{ id: 'overview', label: 'Overview', icon: Activity, shortcut: 'O' },
|
||||
{ id: 'users', label: 'Users', icon: Users, shortcut: 'U' },
|
||||
{ id: 'alerts', label: 'Price Alerts', icon: Bell },
|
||||
{ id: 'newsletter', label: 'Newsletter', icon: Mail },
|
||||
{ id: 'tld', label: 'TLD Data', icon: Globe },
|
||||
{ id: 'auctions', label: 'Auctions', icon: Gavel },
|
||||
{ id: 'blog', label: 'Blog', icon: BookOpen, shortcut: 'B' },
|
||||
{ id: 'system', label: 'System', icon: Database, shortcut: 'Y' },
|
||||
{ id: 'activity', label: 'Activity Log', icon: History },
|
||||
]
|
||||
|
||||
const SidebarContent = () => (
|
||||
<>
|
||||
{/* Logo */}
|
||||
<div className={clsx(
|
||||
"h-20 flex items-center border-b border-red-500/20",
|
||||
collapsed ? "justify-center px-2" : "px-5"
|
||||
)}>
|
||||
<Link href="/admin" className="flex items-center gap-3 group">
|
||||
<div className={clsx(
|
||||
"relative flex items-center justify-center",
|
||||
collapsed ? "w-10 h-10" : "w-11 h-11"
|
||||
)}>
|
||||
<div className="absolute inset-0 bg-red-500/20 blur-xl rounded-full scale-150 opacity-50 group-hover:opacity-80 transition-opacity" />
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex items-center justify-center shadow-lg shadow-red-500/20">
|
||||
<Shield className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<span className="text-lg font-bold tracking-wide text-foreground">Admin</span>
|
||||
<span className="block text-[10px] text-red-400 uppercase tracking-wider">Control Panel</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-6 px-3 space-y-1 overflow-y-auto">
|
||||
{!collapsed && (
|
||||
<p className="px-3 mb-3 text-[10px] font-semibold text-foreground-subtle/60 uppercase tracking-[0.15em]">
|
||||
Management
|
||||
</p>
|
||||
)}
|
||||
|
||||
{navItems.map((item) => {
|
||||
const isActive = activeTab === item.id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onTabChange?.(item.id)}
|
||||
className={clsx(
|
||||
"w-full group relative flex items-center gap-3 px-3 py-3 rounded-xl transition-all duration-300",
|
||||
isActive
|
||||
? "bg-gradient-to-r from-red-500/20 to-red-500/5 text-foreground border border-red-500/20"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5 border border-transparent"
|
||||
)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-red-500 rounded-r-full" />
|
||||
)}
|
||||
|
||||
<item.icon className={clsx(
|
||||
"w-5 h-5 transition-colors",
|
||||
isActive ? "text-red-400" : "group-hover:text-foreground"
|
||||
)} />
|
||||
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-left text-sm font-medium">{item.label}</span>
|
||||
{item.shortcut && <ShortcutHint shortcut={item.shortcut} />}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom Section */}
|
||||
<div className="border-t border-border/30 py-4 px-3 space-y-2">
|
||||
{/* Back to User Dashboard */}
|
||||
<Link
|
||||
href="/command/dashboard"
|
||||
className={clsx(
|
||||
"flex items-center gap-3 px-3 py-3 rounded-xl transition-all duration-300",
|
||||
"text-accent hover:bg-accent/10 border border-transparent hover:border-accent/20"
|
||||
)}
|
||||
title={collapsed ? "Back to Dashboard" : undefined}
|
||||
>
|
||||
<LayoutDashboard className="w-5 h-5" />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-sm font-medium">User Dashboard</span>
|
||||
<ShortcutHint shortcut="D" />
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* User Info */}
|
||||
{!collapsed && (
|
||||
<div className="p-4 bg-red-500/5 border border-red-500/20 rounded-xl">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 bg-red-500/20 rounded-lg flex items-center justify-center">
|
||||
<Shield className="w-4 h-4 text-red-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{user?.name || user?.email?.split('@')[0]}</p>
|
||||
<p className="text-xs text-red-400">Administrator</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-3 py-3 rounded-xl transition-all duration-300",
|
||||
"text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
)}
|
||||
title={collapsed ? "Sign out" : undefined}
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
{!collapsed && <span className="text-sm font-medium">Sign out</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Collapse Toggle */}
|
||||
<button
|
||||
onClick={onCollapse}
|
||||
className={clsx(
|
||||
"hidden lg:flex absolute -right-3 top-24 w-6 h-6 bg-background border border-border rounded-full",
|
||||
"items-center justify-center text-foreground-muted hover:text-foreground",
|
||||
"hover:bg-red-500/10 hover:border-red-500/30 transition-all duration-300 shadow-lg"
|
||||
)}
|
||||
>
|
||||
{collapsed ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Overlay */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-40 bg-background/80 backdrop-blur-sm"
|
||||
onClick={onMobileClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Sidebar */}
|
||||
<aside
|
||||
className={clsx(
|
||||
"lg:hidden fixed left-0 top-0 bottom-0 z-50 w-[280px] flex flex-col",
|
||||
"bg-background/95 backdrop-blur-xl border-r border-red-500/20",
|
||||
"transition-transform duration-300 ease-out",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={onMobileClose}
|
||||
className="absolute top-5 right-4 w-8 h-8 flex items-center justify-center text-foreground-muted hover:text-foreground"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Desktop Sidebar */}
|
||||
<aside
|
||||
className={clsx(
|
||||
"hidden lg:flex fixed left-0 top-0 bottom-0 z-40 flex-col",
|
||||
"bg-gradient-to-b from-background/95 via-background/90 to-background/95 backdrop-blur-xl",
|
||||
"border-r border-red-500/20",
|
||||
"transition-all duration-300 ease-out",
|
||||
collapsed ? "w-[80px]" : "w-[280px]"
|
||||
)}
|
||||
>
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SHORTCUTS WRAPPER
|
||||
// ============================================================================
|
||||
|
||||
function AdminShortcutsWrapper() {
|
||||
useAdminShortcuts()
|
||||
return null
|
||||
}
|
||||
|
||||
301
frontend/src/components/CommandCenterLayout.tsx
Executable file
301
frontend/src/components/CommandCenterLayout.tsx
Executable file
@ -0,0 +1,301 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { KeyboardShortcutsProvider, useUserShortcuts } from '@/hooks/useKeyboardShortcuts'
|
||||
import { Bell, Search, X, Command } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface CommandCenterLayoutProps {
|
||||
children: React.ReactNode
|
||||
title?: string
|
||||
subtitle?: string
|
||||
actions?: React.ReactNode
|
||||
}
|
||||
|
||||
export function CommandCenterLayout({
|
||||
children,
|
||||
title,
|
||||
subtitle,
|
||||
actions
|
||||
}: CommandCenterLayoutProps) {
|
||||
const router = useRouter()
|
||||
const { isAuthenticated, isLoading, checkAuth, domains } = useStore()
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const authCheckedRef = useRef(false)
|
||||
|
||||
// Ensure component is mounted before rendering
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Load sidebar state from localStorage
|
||||
useEffect(() => {
|
||||
if (mounted) {
|
||||
const saved = localStorage.getItem('sidebar-collapsed')
|
||||
if (saved) {
|
||||
setSidebarCollapsed(saved === 'true')
|
||||
}
|
||||
}
|
||||
}, [mounted])
|
||||
|
||||
// Check auth only once on mount
|
||||
useEffect(() => {
|
||||
if (!authCheckedRef.current) {
|
||||
authCheckedRef.current = true
|
||||
checkAuth()
|
||||
}
|
||||
}, [checkAuth])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router])
|
||||
|
||||
// Available domains for notifications
|
||||
const availableDomains = domains?.filter(d => d.is_available) || []
|
||||
const hasNotifications = availableDomains.length > 0
|
||||
|
||||
// Show loading only if we're still checking auth
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-foreground-muted">Loading Command Center...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardShortcutsProvider>
|
||||
<UserShortcutsWrapper />
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Background Effects */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.02] rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.015] rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onCollapsedChange={setSidebarCollapsed}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div
|
||||
className={clsx(
|
||||
"relative min-h-screen transition-all duration-300",
|
||||
// Desktop: adjust for sidebar
|
||||
"lg:ml-[260px]",
|
||||
sidebarCollapsed && "lg:ml-[72px]",
|
||||
// Mobile: no margin, just padding for menu button
|
||||
"ml-0 pt-16 lg:pt-0"
|
||||
)}
|
||||
>
|
||||
{/* Top Bar */}
|
||||
<header className="sticky top-0 z-30 bg-gradient-to-r from-background/95 via-background/90 to-background/95 backdrop-blur-xl border-b border-border/30">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-5 sm:py-6 flex items-center justify-between">
|
||||
{/* Left: Title */}
|
||||
<div className="ml-10 lg:ml-0 min-w-0 flex-1">
|
||||
{title && (
|
||||
<h1 className="text-xl sm:text-2xl font-semibold tracking-tight text-foreground truncate">{title}</h1>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-sm text-foreground-muted mt-0.5 hidden sm:block truncate">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2 sm:gap-3 shrink-0 ml-4">
|
||||
{/* Quick Search */}
|
||||
<button
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="hidden md:flex items-center gap-2 h-9 px-3 bg-foreground/5 hover:bg-foreground/8
|
||||
border border-border/40 rounded-lg text-sm text-foreground-muted
|
||||
hover:text-foreground transition-all duration-200 hover:border-border/60"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
<span className="hidden lg:inline">Search</span>
|
||||
<kbd className="hidden xl:inline-flex items-center h-5 px-1.5 bg-background border border-border/60
|
||||
rounded text-[10px] text-foreground-subtle font-mono">⌘K</kbd>
|
||||
</button>
|
||||
|
||||
{/* Mobile Search */}
|
||||
<button
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="md:hidden flex items-center justify-center w-9 h-9 text-foreground-muted
|
||||
hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setNotificationsOpen(!notificationsOpen)}
|
||||
className={clsx(
|
||||
"relative flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-200",
|
||||
notificationsOpen
|
||||
? "bg-foreground/10 text-foreground"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
)}
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{hasNotifications && (
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-accent rounded-full">
|
||||
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-50" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Notifications Dropdown */}
|
||||
{notificationsOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-80 bg-background-secondary border border-border
|
||||
rounded-xl shadow-2xl overflow-hidden">
|
||||
<div className="p-4 border-b border-border flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">Notifications</h3>
|
||||
<button
|
||||
onClick={() => setNotificationsOpen(false)}
|
||||
className="text-foreground-muted hover:text-foreground"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{availableDomains.length > 0 ? (
|
||||
<div className="p-2">
|
||||
{availableDomains.slice(0, 5).map((domain) => (
|
||||
<Link
|
||||
key={domain.id}
|
||||
href="/command/watchlist"
|
||||
onClick={() => setNotificationsOpen(false)}
|
||||
className="flex items-start gap-3 p-3 hover:bg-foreground/5 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 bg-accent/10 rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="w-2 h-2 bg-accent rounded-full animate-pulse" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{domain.name}</p>
|
||||
<p className="text-xs text-accent">Available now!</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center">
|
||||
<Bell className="w-8 h-8 text-foreground-subtle mx-auto mb-3" />
|
||||
<p className="text-sm text-foreground-muted">No notifications</p>
|
||||
<p className="text-xs text-foreground-subtle mt-1">
|
||||
We'll notify you when domains become available
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts Hint */}
|
||||
<button
|
||||
onClick={() => {}}
|
||||
className="hidden sm:flex items-center gap-1.5 px-2 py-1.5 text-xs text-foreground-subtle hover:text-foreground
|
||||
bg-foreground/5 rounded-lg border border-border/40 hover:border-border/60 transition-all"
|
||||
title="Keyboard shortcuts (?)"
|
||||
>
|
||||
<Command className="w-3.5 h-3.5" />
|
||||
<span>?</span>
|
||||
</button>
|
||||
|
||||
{/* Custom Actions */}
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="relative">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Quick Search Modal */}
|
||||
{searchOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] bg-background/80 backdrop-blur-sm flex items-start justify-center pt-[15vh] sm:pt-[20vh] px-4"
|
||||
onClick={() => setSearchOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-xl bg-background-secondary border border-border rounded-2xl shadow-2xl overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 p-4 border-b border-border">
|
||||
<Search className="w-5 h-5 text-foreground-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search domains, TLDs, auctions..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="flex-1 bg-transparent text-foreground placeholder:text-foreground-subtle
|
||||
outline-none text-lg"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => setSearchOpen(false)}
|
||||
className="flex items-center h-6 px-2 bg-background border border-border
|
||||
rounded text-xs text-foreground-subtle font-mono hover:text-foreground transition-colors"
|
||||
>
|
||||
ESC
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 text-center text-foreground-muted text-sm">
|
||||
Start typing to search...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard shortcut for search */}
|
||||
<KeyboardShortcut onTrigger={() => setSearchOpen(true)} keys={['Meta', 'k']} />
|
||||
</div>
|
||||
</KeyboardShortcutsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Keyboard shortcut component
|
||||
function KeyboardShortcut({ onTrigger, keys }: { onTrigger: () => void, keys: string[] }) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (keys.includes('Meta') && e.metaKey && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
onTrigger()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [onTrigger, keys])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// User shortcuts wrapper
|
||||
function UserShortcutsWrapper() {
|
||||
useUserShortcuts()
|
||||
return null
|
||||
}
|
||||
224
frontend/src/components/DataTable.tsx
Normal file
224
frontend/src/components/DataTable.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface Column<T> {
|
||||
key: string
|
||||
header: string
|
||||
render?: (item: T, index: number) => ReactNode
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
hideOnMobile?: boolean
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[]
|
||||
columns: Column<T>[]
|
||||
keyExtractor: (item: T) => string | number
|
||||
onRowClick?: (item: T) => void
|
||||
emptyState?: ReactNode
|
||||
loading?: boolean
|
||||
selectable?: boolean
|
||||
selectedIds?: (string | number)[]
|
||||
onSelectionChange?: (ids: (string | number)[]) => void
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
data,
|
||||
columns,
|
||||
keyExtractor,
|
||||
onRowClick,
|
||||
emptyState,
|
||||
loading,
|
||||
selectable,
|
||||
selectedIds = [],
|
||||
onSelectionChange,
|
||||
}: DataTableProps<T>) {
|
||||
const toggleSelection = (id: string | number) => {
|
||||
if (!onSelectionChange) return
|
||||
if (selectedIds.includes(id)) {
|
||||
onSelectionChange(selectedIds.filter(i => i !== id))
|
||||
} else {
|
||||
onSelectionChange([...selectedIds, id])
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (!onSelectionChange) return
|
||||
if (selectedIds.length === data.length) {
|
||||
onSelectionChange([])
|
||||
} else {
|
||||
onSelectionChange(data.map(keyExtractor))
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/50 bg-gradient-to-b from-background-secondary/50 to-background-secondary/20">
|
||||
<div className="divide-y divide-border/30">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="px-6 py-5 flex gap-4">
|
||||
<div className="h-5 w-32 bg-foreground/5 rounded-lg animate-pulse" />
|
||||
<div className="h-5 w-24 bg-foreground/5 rounded-lg animate-pulse" />
|
||||
<div className="h-5 w-20 bg-foreground/5 rounded-lg animate-pulse ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/50 bg-gradient-to-b from-background-secondary/50 to-background-secondary/20">
|
||||
<div className="px-8 py-16 text-center">
|
||||
{emptyState || (
|
||||
<p className="text-foreground-muted">No data available</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/50 bg-gradient-to-b from-background-secondary/50 to-background-secondary/20 shadow-[0_4px_24px_-4px_rgba(0,0,0,0.1)]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50">
|
||||
{selectable && (
|
||||
<th className="w-12 px-4 py-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.length === data.length && data.length > 0}
|
||||
onChange={toggleSelectAll}
|
||||
className="w-4 h-4 rounded border-border/50 bg-background-secondary text-accent
|
||||
focus:ring-accent/20 focus:ring-offset-0 cursor-pointer"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={clsx(
|
||||
"text-left px-6 py-4 text-xs font-semibold text-foreground-subtle/80 uppercase tracking-wider",
|
||||
col.hideOnMobile && "hidden md:table-cell",
|
||||
col.headerClassName
|
||||
)}
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/30">
|
||||
{data.map((item, index) => {
|
||||
const key = keyExtractor(item)
|
||||
const isSelected = selectedIds.includes(key)
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
className={clsx(
|
||||
"group transition-all duration-200",
|
||||
onRowClick && "cursor-pointer",
|
||||
isSelected
|
||||
? "bg-accent/5"
|
||||
: "hover:bg-foreground/[0.02]"
|
||||
)}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="w-12 px-4 py-4" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelection(key)}
|
||||
className="w-4 h-4 rounded border-border/50 bg-background-secondary text-accent
|
||||
focus:ring-accent/20 focus:ring-offset-0 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={clsx(
|
||||
"px-6 py-4 text-sm",
|
||||
col.hideOnMobile && "hidden md:table-cell",
|
||||
col.className
|
||||
)}
|
||||
>
|
||||
{col.render
|
||||
? col.render(item, index)
|
||||
: (item as Record<string, unknown>)[col.key] as ReactNode
|
||||
}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Status badge component for tables
|
||||
export function StatusBadge({
|
||||
status,
|
||||
variant = 'default'
|
||||
}: {
|
||||
status: string
|
||||
variant?: 'success' | 'warning' | 'error' | 'default' | 'accent'
|
||||
}) {
|
||||
return (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center px-2.5 py-1 text-xs font-medium rounded-lg",
|
||||
variant === 'success' && "bg-accent/10 text-accent border border-accent/20",
|
||||
variant === 'warning' && "bg-amber-500/10 text-amber-400 border border-amber-500/20",
|
||||
variant === 'error' && "bg-red-500/10 text-red-400 border border-red-500/20",
|
||||
variant === 'accent' && "bg-accent/10 text-accent border border-accent/20",
|
||||
variant === 'default' && "bg-foreground/5 text-foreground-muted border border-border/50"
|
||||
)}>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Action button for tables
|
||||
export function TableAction({
|
||||
icon: Icon,
|
||||
onClick,
|
||||
variant = 'default',
|
||||
title,
|
||||
disabled,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
onClick?: () => void
|
||||
variant?: 'default' | 'danger' | 'accent'
|
||||
title?: string
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClick?.()
|
||||
}}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={clsx(
|
||||
"p-2 rounded-lg transition-all duration-200",
|
||||
"disabled:opacity-30 disabled:cursor-not-allowed",
|
||||
variant === 'danger' && "bg-red-500/10 text-red-400 hover:bg-red-500/20 border border-red-500/20",
|
||||
variant === 'accent' && "bg-accent/10 text-accent hover:bg-accent/20 border border-accent/20",
|
||||
variant === 'default' && "bg-foreground/5 text-foreground-muted hover:text-foreground hover:bg-foreground/10 border border-border/50"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -62,21 +62,21 @@ export function DomainChecker() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-xl sm:max-w-2xl mx-auto px-4 sm:px-0">
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
{/* Search Form */}
|
||||
<form onSubmit={handleCheck} className="relative">
|
||||
{/* Glow effect container */}
|
||||
{/* Glow effect container - always visible, stronger on focus */}
|
||||
<div className={clsx(
|
||||
"absolute -inset-px rounded-xl sm:rounded-2xl transition-opacity duration-700",
|
||||
isFocused ? "opacity-100" : "opacity-0"
|
||||
"absolute -inset-1 rounded-2xl transition-opacity duration-500",
|
||||
isFocused ? "opacity-100" : "opacity-60"
|
||||
)}>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-accent/20 via-accent/10 to-accent/20 rounded-xl sm:rounded-2xl blur-xl" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-accent/30 via-accent/20 to-accent/30 rounded-2xl blur-xl" />
|
||||
</div>
|
||||
|
||||
{/* Input container */}
|
||||
<div className={clsx(
|
||||
"relative bg-background-secondary rounded-xl sm:rounded-2xl transition-all duration-500",
|
||||
isFocused ? "ring-1 ring-accent/60" : "ring-1 ring-accent/30"
|
||||
"relative bg-background-secondary rounded-2xl transition-all duration-300 shadow-2xl shadow-accent/10",
|
||||
isFocused ? "ring-2 ring-accent/50" : "ring-1 ring-accent/30"
|
||||
)}>
|
||||
<input
|
||||
type="text"
|
||||
@ -85,30 +85,30 @@ export function DomainChecker() {
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder="Hunt any domain..."
|
||||
className="w-full px-4 sm:px-6 py-4 sm:py-5 pr-28 sm:pr-36 bg-transparent rounded-xl sm:rounded-2xl
|
||||
text-body-sm sm:text-body-lg text-foreground placeholder:text-foreground-subtle
|
||||
className="w-full px-5 sm:px-7 py-5 sm:py-6 pr-32 sm:pr-40 bg-transparent rounded-2xl
|
||||
text-base sm:text-lg text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !domain.trim()}
|
||||
className="absolute right-2 sm:right-3 top-1/2 -translate-y-1/2
|
||||
px-4 sm:px-6 py-2.5 sm:py-3 bg-foreground text-background text-ui-sm sm:text-ui font-medium rounded-lg sm:rounded-xl
|
||||
hover:bg-foreground/90 active:scale-[0.98]
|
||||
className="absolute right-2.5 sm:right-3 top-1/2 -translate-y-1/2
|
||||
px-5 sm:px-7 py-3 sm:py-3.5 bg-accent text-background text-sm sm:text-base font-semibold rounded-xl
|
||||
hover:bg-accent-hover active:scale-[0.98] shadow-lg shadow-accent/25
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
transition-all duration-300 flex items-center gap-2 sm:gap-2.5"
|
||||
transition-all duration-300 flex items-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-4 h-4" />
|
||||
<Search className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Check</span>
|
||||
<span>Hunt</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 sm:mt-5 text-center text-ui-sm sm:text-ui text-foreground-subtle">
|
||||
Try <span className="text-foreground-muted">dream.com</span>, <span className="text-foreground-muted">startup.io</span>, or <span className="text-foreground-muted">next.co</span>
|
||||
<p className="mt-3 sm:mt-4 text-center text-xs sm:text-sm text-foreground-subtle">
|
||||
Try <span className="text-accent/70">dream.com</span>, <span className="text-accent/70">startup.io</span>, or <span className="text-accent/70">next.ai</span>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
@ -152,7 +152,7 @@ export function DomainChecker() {
|
||||
Grab it now or track it in your watchlist.
|
||||
</p>
|
||||
<Link
|
||||
href={isAuthenticated ? '/dashboard' : '/register'}
|
||||
href={isAuthenticated ? '/command/dashboard' : '/register'}
|
||||
className="shrink-0 flex items-center justify-center sm:justify-start gap-2 px-5 py-2.5
|
||||
bg-accent text-background text-ui font-medium rounded-lg
|
||||
hover:bg-accent-hover transition-all duration-300"
|
||||
@ -268,7 +268,7 @@ export function DomainChecker() {
|
||||
<span className="text-left">We'll alert you the moment it drops.</span>
|
||||
</div>
|
||||
<Link
|
||||
href={isAuthenticated ? '/dashboard' : '/register'}
|
||||
href={isAuthenticated ? '/command/dashboard' : '/register'}
|
||||
className="shrink-0 flex items-center justify-center sm:justify-start gap-2 px-4 py-2.5
|
||||
bg-background-tertiary text-foreground text-ui font-medium rounded-lg
|
||||
border border-border hover:border-border-hover transition-all duration-300"
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { Github, Twitter, Mail } from 'lucide-react'
|
||||
import { Twitter, Mail, Linkedin } from 'lucide-react'
|
||||
import { useStore } from '@/lib/store'
|
||||
|
||||
export function Footer() {
|
||||
@ -16,40 +15,42 @@ export function Footer() {
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="mb-4">
|
||||
<Link href="/" className="inline-block">
|
||||
<Image
|
||||
src="/pounce-logo.png"
|
||||
alt="pounce"
|
||||
width={120}
|
||||
height={60}
|
||||
className="w-28 h-auto"
|
||||
/>
|
||||
<span
|
||||
className="text-xl font-bold tracking-[0.1em] text-foreground"
|
||||
style={{ fontFamily: 'var(--font-display), Playfair Display, Georgia, serif' }}
|
||||
>
|
||||
POUNCE
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-body-sm text-foreground-muted mb-4 max-w-xs">
|
||||
Domain intelligence for hunters. Track. Alert. Pounce.
|
||||
<p className="text-body-sm text-foreground-muted mb-2">
|
||||
Don't guess. Know.
|
||||
</p>
|
||||
<p className="text-body-xs text-foreground-subtle mb-4">
|
||||
Domain intelligence for serious investors and founders.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="https://github.com"
|
||||
href="https://twitter.com/pounce_domains"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg bg-background-tertiary hover:bg-background-secondary transition-colors"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<Github className="w-4 h-4 text-foreground-muted" />
|
||||
</a>
|
||||
<a
|
||||
href="https://twitter.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg bg-background-tertiary hover:bg-background-secondary transition-colors"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-colors"
|
||||
aria-label="Twitter"
|
||||
>
|
||||
<Twitter className="w-4 h-4 text-foreground-muted" />
|
||||
</a>
|
||||
<a
|
||||
href="mailto:support@pounce.dev"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg bg-background-tertiary hover:bg-background-secondary transition-colors"
|
||||
href="https://linkedin.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-colors"
|
||||
aria-label="LinkedIn"
|
||||
>
|
||||
<Linkedin className="w-4 h-4 text-foreground-muted" />
|
||||
</a>
|
||||
<a
|
||||
href="mailto:hello@pounce.ch"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-colors"
|
||||
aria-label="Email"
|
||||
>
|
||||
<Mail className="w-4 h-4 text-foreground-muted" />
|
||||
@ -57,47 +58,53 @@ export function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product - Matches Header nav */}
|
||||
{/* Product - Matches new navigation */}
|
||||
<div>
|
||||
<h3 className="text-ui font-medium text-foreground mb-4">Product</h3>
|
||||
<h3 className="text-ui font-semibold text-foreground mb-4">Product</h3>
|
||||
<ul className="space-y-3">
|
||||
<li>
|
||||
<Link href="/tld-pricing" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
TLD Prices
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/auctions" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
Auctions
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/tld-pricing" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
TLD Pricing
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/pricing" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
Pricing
|
||||
</Link>
|
||||
</li>
|
||||
{isAuthenticated && (
|
||||
{isAuthenticated ? (
|
||||
<li>
|
||||
<Link href="/dashboard" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
<Link href="/command/dashboard" className="text-body-sm text-accent hover:text-accent-hover transition-colors">
|
||||
Command Center
|
||||
</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li>
|
||||
<Link href="/register" className="text-body-sm text-accent hover:text-accent-hover transition-colors">
|
||||
Get Started Free
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Resources */}
|
||||
<div>
|
||||
<h3 className="text-ui font-medium text-foreground mb-4">Resources</h3>
|
||||
<h3 className="text-ui font-semibold text-foreground mb-4">Resources</h3>
|
||||
<ul className="space-y-3">
|
||||
<li>
|
||||
<Link href="/blog" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
Blog
|
||||
Briefings
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/about" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
About
|
||||
About Us
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
@ -110,7 +117,7 @@ export function Footer() {
|
||||
|
||||
{/* Legal */}
|
||||
<div>
|
||||
<h3 className="text-ui font-medium text-foreground mb-4">Legal</h3>
|
||||
<h3 className="text-ui font-semibold text-foreground mb-4">Legal</h3>
|
||||
<ul className="space-y-3">
|
||||
<li>
|
||||
<Link href="/privacy" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
@ -122,11 +129,6 @@ export function Footer() {
|
||||
Terms of Service
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/cookies" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
Cookie Policy
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/imprint" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
Imprint
|
||||
@ -139,7 +141,7 @@ export function Footer() {
|
||||
{/* Bottom */}
|
||||
<div className="pt-8 border-t border-border flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<p className="text-ui-sm text-foreground-subtle">
|
||||
© {new Date().getFullYear()} pounce. All rights reserved.
|
||||
© {new Date().getFullYear()} pounce.ch — All rights reserved.
|
||||
</p>
|
||||
<div className="flex items-center gap-6">
|
||||
<Link href="/privacy" className="text-ui-sm text-foreground-subtle hover:text-foreground transition-colors">
|
||||
@ -148,9 +150,6 @@ export function Footer() {
|
||||
<Link href="/terms" className="text-ui-sm text-foreground-subtle hover:text-foreground transition-colors">
|
||||
Terms
|
||||
</Link>
|
||||
<Link href="/contact" className="text-ui-sm text-foreground-subtle hover:text-foreground transition-colors">
|
||||
Contact
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -4,61 +4,44 @@ import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useStore } from '@/lib/store'
|
||||
import {
|
||||
LogOut,
|
||||
LayoutDashboard,
|
||||
Menu,
|
||||
X,
|
||||
Settings,
|
||||
Bell,
|
||||
User,
|
||||
ChevronDown,
|
||||
TrendingUp,
|
||||
Gavel,
|
||||
CreditCard,
|
||||
Search,
|
||||
Shield,
|
||||
LayoutDashboard,
|
||||
Tag,
|
||||
} from 'lucide-react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
/**
|
||||
* Public Header Component
|
||||
*
|
||||
* Used for:
|
||||
* - Landing page (/)
|
||||
* - Public pages (pricing, about, contact, blog, etc.)
|
||||
* - Auth pages (login, register)
|
||||
*
|
||||
* For logged-in users in the Command Center, use CommandCenterLayout instead.
|
||||
*/
|
||||
export function Header() {
|
||||
const pathname = usePathname()
|
||||
const { isAuthenticated, user, logout, domains, subscription } = useStore()
|
||||
const { isAuthenticated, user, logout, subscription } = useStore()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(false)
|
||||
const userMenuRef = useRef<HTMLDivElement>(null)
|
||||
const notificationsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
|
||||
setUserMenuOpen(false)
|
||||
}
|
||||
if (notificationsRef.current && !notificationsRef.current.contains(event.target as Node)) {
|
||||
setNotificationsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
// Count notifications (available domains, etc.)
|
||||
const availableDomains = domains?.filter(d => d.is_available) || []
|
||||
const hasNotifications = availableDomains.length > 0
|
||||
|
||||
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||
|
||||
// Navigation items - consistent for logged in/out
|
||||
const navItems = [
|
||||
{ href: '/tld-pricing', label: 'TLD Prices', icon: TrendingUp },
|
||||
// Public navigation - same for all visitors
|
||||
const publicNavItems = [
|
||||
{ href: '/auctions', label: 'Auctions', icon: Gavel },
|
||||
{ href: '/buy', label: 'Marketplace', icon: Tag },
|
||||
{ href: '/tld-pricing', label: 'TLD Pricing', icon: TrendingUp },
|
||||
{ href: '/pricing', label: 'Pricing', icon: CreditCard },
|
||||
]
|
||||
|
||||
@ -67,6 +50,14 @@ export function Header() {
|
||||
return pathname.startsWith(href)
|
||||
}
|
||||
|
||||
// Check if we're on a Command Center page (should use Sidebar instead)
|
||||
const isCommandCenterPage = pathname.startsWith('/command') || pathname.startsWith('/admin')
|
||||
|
||||
// If logged in and on Command Center page, don't render this header
|
||||
if (isAuthenticated && isCommandCenterPage) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-xl border-b border-border-subtle">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 h-16 sm:h-20 flex items-center justify-between">
|
||||
@ -87,7 +78,7 @@ export function Header() {
|
||||
|
||||
{/* Main Nav Links (Desktop) */}
|
||||
<nav className="hidden md:flex items-center h-full gap-1">
|
||||
{navItems.map((item) => (
|
||||
{publicNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
@ -108,158 +99,15 @@ export function Header() {
|
||||
<nav className="hidden sm:flex items-center h-full gap-2">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
{/* Command Center Link - Primary CTA when logged in */}
|
||||
{/* Go to Command Center */}
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className={clsx(
|
||||
"flex items-center gap-2 h-9 px-4 text-[0.8125rem] font-medium rounded-lg transition-all duration-200",
|
||||
isActive('/dashboard')
|
||||
? "bg-foreground text-background"
|
||||
: "text-foreground bg-foreground/5 hover:bg-foreground/10"
|
||||
)}
|
||||
href="/command/dashboard"
|
||||
className="flex items-center gap-2 h-9 px-4 text-[0.8125rem] bg-accent text-background
|
||||
rounded-lg font-medium hover:bg-accent-hover transition-all duration-200"
|
||||
>
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
<span>Command Center</span>
|
||||
Command Center
|
||||
</Link>
|
||||
|
||||
{/* Notifications */}
|
||||
<div ref={notificationsRef} className="relative">
|
||||
<button
|
||||
onClick={() => setNotificationsOpen(!notificationsOpen)}
|
||||
className={clsx(
|
||||
"relative flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-200",
|
||||
notificationsOpen
|
||||
? "bg-foreground/10 text-foreground"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
)}
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
{hasNotifications && (
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-accent rounded-full">
|
||||
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-50" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Notifications Dropdown */}
|
||||
{notificationsOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-80 bg-background-secondary border border-border rounded-xl shadow-2xl overflow-hidden">
|
||||
<div className="p-4 border-b border-border">
|
||||
<h3 className="text-body-sm font-medium text-foreground">Notifications</h3>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{availableDomains.length > 0 ? (
|
||||
<div className="p-2">
|
||||
{availableDomains.slice(0, 5).map((domain) => (
|
||||
<Link
|
||||
key={domain.id}
|
||||
href="/dashboard"
|
||||
onClick={() => setNotificationsOpen(false)}
|
||||
className="flex items-start gap-3 p-3 hover:bg-foreground/5 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 bg-accent/10 rounded-lg flex items-center justify-center shrink-0">
|
||||
<Search className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-body-sm font-medium text-foreground truncate">{domain.name}</p>
|
||||
<p className="text-body-xs text-accent">Available now!</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{availableDomains.length > 5 && (
|
||||
<p className="px-3 py-2 text-body-xs text-foreground-muted">
|
||||
+{availableDomains.length - 5} more available
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center">
|
||||
<Bell className="w-8 h-8 text-foreground-subtle mx-auto mb-3" />
|
||||
<p className="text-body-sm text-foreground-muted">No notifications</p>
|
||||
<p className="text-body-xs text-foreground-subtle mt-1">We'll notify you when domains become available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={() => setNotificationsOpen(false)}
|
||||
className="block p-3 text-center text-body-xs text-foreground-muted hover:text-foreground hover:bg-foreground/5 border-t border-border transition-colors"
|
||||
>
|
||||
Notification settings
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Menu */}
|
||||
<div ref={userMenuRef} className="relative">
|
||||
<button
|
||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 h-9 pl-3 pr-2 rounded-lg transition-all duration-200",
|
||||
userMenuOpen ? "bg-foreground/10" : "hover:bg-foreground/5"
|
||||
)}
|
||||
>
|
||||
<div className="w-6 h-6 bg-accent/10 rounded-full flex items-center justify-center">
|
||||
<User className="w-3.5 h-3.5 text-accent" />
|
||||
</div>
|
||||
<ChevronDown className={clsx(
|
||||
"w-3.5 h-3.5 text-foreground-muted transition-transform duration-200",
|
||||
userMenuOpen && "rotate-180"
|
||||
)} />
|
||||
</button>
|
||||
|
||||
{/* User Dropdown */}
|
||||
{userMenuOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-64 bg-background-secondary border border-border rounded-xl shadow-2xl overflow-hidden">
|
||||
{/* User Info */}
|
||||
<div className="p-4 border-b border-border">
|
||||
<p className="text-body-sm font-medium text-foreground truncate">{user?.name || user?.email}</p>
|
||||
<p className="text-body-xs text-foreground-muted truncate">{user?.email}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-ui-xs px-2 py-0.5 bg-accent/10 text-accent rounded-full font-medium">{tierName}</span>
|
||||
<span className="text-ui-xs text-foreground-subtle">{subscription?.domains_used || 0}/{subscription?.domain_limit || 5} domains</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="p-2">
|
||||
{user?.is_admin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-3 px-3 py-2.5 text-body-sm text-accent hover:bg-accent/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
Admin Panel
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-3 px-3 py-2.5 text-body-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="p-2 border-t border-border">
|
||||
<button
|
||||
onClick={() => {
|
||||
logout()
|
||||
setUserMenuOpen(false)
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-3 py-2.5 text-body-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@ -294,37 +142,8 @@ export function Header() {
|
||||
{mobileMenuOpen && (
|
||||
<div className="sm:hidden border-t border-border bg-background/95 backdrop-blur-xl">
|
||||
<nav className="px-4 py-4 space-y-1">
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
{/* User Info on Mobile */}
|
||||
<div className="px-4 py-3 mb-3 bg-foreground/5 rounded-xl">
|
||||
<p className="text-body-sm font-medium text-foreground truncate">{user?.name || user?.email}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-ui-xs px-2 py-0.5 bg-accent/10 text-accent rounded-full font-medium">{tierName}</span>
|
||||
<span className="text-ui-xs text-foreground-subtle">{subscription?.domains_used || 0}/{subscription?.domain_limit || 5} domains</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className={clsx(
|
||||
"flex items-center gap-3 px-4 py-3 text-body-sm rounded-xl transition-all duration-200",
|
||||
isActive('/dashboard')
|
||||
? "bg-foreground text-background font-medium"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
)}
|
||||
>
|
||||
<LayoutDashboard className="w-5 h-5" />
|
||||
<span>Command Center</span>
|
||||
{hasNotifications && (
|
||||
<span className="ml-auto w-2 h-2 bg-accent rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Main Nav */}
|
||||
{navItems.map((item) => (
|
||||
{publicNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
@ -340,43 +159,21 @@ export function Header() {
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<div className="my-3 border-t border-border" />
|
||||
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<div className="my-3 border-t border-border" />
|
||||
{user?.is_admin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-3 px-4 py-3 text-body-sm text-accent
|
||||
hover:bg-accent/10 rounded-xl transition-all duration-200"
|
||||
>
|
||||
<Shield className="w-5 h-5" />
|
||||
<span>Admin Panel</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/settings"
|
||||
className="flex items-center gap-3 px-4 py-3 text-body-sm text-foreground-muted
|
||||
hover:text-foreground hover:bg-foreground/5 rounded-xl transition-all duration-200"
|
||||
href="/command/dashboard"
|
||||
className="flex items-center gap-3 px-4 py-3 text-body-sm text-center bg-accent text-background
|
||||
rounded-xl font-medium hover:bg-accent-hover transition-all duration-200"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span>Settings</span>
|
||||
<LayoutDashboard className="w-5 h-5" />
|
||||
<span>Command Center</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
logout()
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 text-body-sm text-foreground-muted
|
||||
hover:text-foreground hover:bg-foreground/5 rounded-xl transition-all duration-200"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="my-3 border-t border-border" />
|
||||
<Link
|
||||
href="/login"
|
||||
className="block px-4 py-3 text-body-sm text-foreground-muted
|
||||
|
||||
630
frontend/src/components/PremiumTable.tsx
Executable file
630
frontend/src/components/PremiumTable.tsx
Executable file
@ -0,0 +1,630 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Loader2 } from 'lucide-react'
|
||||
|
||||
// ============================================================================
|
||||
// PREMIUM TABLE - Elegant, consistent styling for all tables
|
||||
// ============================================================================
|
||||
|
||||
interface Column<T> {
|
||||
key: string
|
||||
header: string | ReactNode
|
||||
render?: (item: T, index: number) => ReactNode
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
hideOnMobile?: boolean
|
||||
hideOnTablet?: boolean
|
||||
sortable?: boolean
|
||||
align?: 'left' | 'center' | 'right'
|
||||
width?: string
|
||||
}
|
||||
|
||||
interface PremiumTableProps<T> {
|
||||
data: T[]
|
||||
columns: Column<T>[]
|
||||
keyExtractor: (item: T) => string | number
|
||||
onRowClick?: (item: T) => void
|
||||
emptyState?: ReactNode
|
||||
emptyIcon?: ReactNode
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
loading?: boolean
|
||||
sortBy?: string
|
||||
sortDirection?: 'asc' | 'desc'
|
||||
onSort?: (key: string) => void
|
||||
compact?: boolean
|
||||
striped?: boolean
|
||||
hoverable?: boolean
|
||||
}
|
||||
|
||||
export function PremiumTable<T>({
|
||||
data,
|
||||
columns,
|
||||
keyExtractor,
|
||||
onRowClick,
|
||||
emptyState,
|
||||
emptyIcon,
|
||||
emptyTitle = 'No data',
|
||||
emptyDescription,
|
||||
loading,
|
||||
sortBy,
|
||||
sortDirection = 'asc',
|
||||
onSort,
|
||||
compact = false,
|
||||
striped = false,
|
||||
hoverable = true,
|
||||
}: PremiumTableProps<T>) {
|
||||
const cellPadding = compact ? 'px-4 py-3' : 'px-6 py-4'
|
||||
const headerPadding = compact ? 'px-4 py-3' : 'px-6 py-4'
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm">
|
||||
<div className="divide-y divide-border/20">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className={clsx("flex gap-4 items-center", cellPadding)} style={{ animationDelay: `${i * 50}ms` }}>
|
||||
<div className="h-5 w-32 bg-foreground/5 rounded-lg animate-pulse" />
|
||||
<div className="h-5 w-24 bg-foreground/5 rounded-lg animate-pulse hidden sm:block" />
|
||||
<div className="h-5 w-20 bg-foreground/5 rounded-lg animate-pulse ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm">
|
||||
<div className="px-8 py-16 text-center">
|
||||
{emptyState || (
|
||||
<>
|
||||
{emptyIcon && <div className="flex justify-center mb-4">{emptyIcon}</div>}
|
||||
<p className="text-foreground-muted font-medium">{emptyTitle}</p>
|
||||
{emptyDescription && <p className="text-sm text-foreground-subtle mt-1">{emptyDescription}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm shadow-[0_4px_24px_-4px_rgba(0,0,0,0.08)]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-fixed">
|
||||
<thead>
|
||||
<tr className="border-b border-border/40 bg-background-secondary/30">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={clsx(
|
||||
headerPadding,
|
||||
"text-[11px] font-semibold text-foreground-subtle/70 uppercase tracking-wider whitespace-nowrap",
|
||||
col.hideOnMobile && "hidden md:table-cell",
|
||||
col.hideOnTablet && "hidden lg:table-cell",
|
||||
col.align === 'right' && "text-right",
|
||||
col.align === 'center' && "text-center",
|
||||
!col.align && "text-left",
|
||||
col.headerClassName
|
||||
)}
|
||||
style={col.width ? { width: col.width, minWidth: col.width } : undefined}
|
||||
>
|
||||
{col.sortable && onSort ? (
|
||||
<button
|
||||
onClick={() => onSort(col.key)}
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1.5 hover:text-foreground transition-colors group",
|
||||
col.align === 'right' && "justify-end w-full",
|
||||
col.align === 'center' && "justify-center w-full"
|
||||
)}
|
||||
>
|
||||
{col.header}
|
||||
<SortIndicator
|
||||
active={sortBy === col.key}
|
||||
direction={sortBy === col.key ? sortDirection : undefined}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
col.header
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/20">
|
||||
{data.map((item, index) => {
|
||||
const key = keyExtractor(item)
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
className={clsx(
|
||||
"group transition-all duration-200",
|
||||
onRowClick && "cursor-pointer",
|
||||
hoverable && "hover:bg-foreground/[0.02]",
|
||||
striped && index % 2 === 1 && "bg-foreground/[0.01]"
|
||||
)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={clsx(
|
||||
cellPadding,
|
||||
"text-sm align-middle",
|
||||
col.hideOnMobile && "hidden md:table-cell",
|
||||
col.hideOnTablet && "hidden lg:table-cell",
|
||||
col.align === 'right' && "text-right",
|
||||
col.align === 'center' && "text-center",
|
||||
!col.align && "text-left",
|
||||
col.className
|
||||
)}
|
||||
>
|
||||
{col.render
|
||||
? col.render(item, index)
|
||||
: (item as Record<string, unknown>)[col.key] as ReactNode
|
||||
}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SORT INDICATOR
|
||||
// ============================================================================
|
||||
|
||||
function SortIndicator({ active, direction }: { active: boolean; direction?: 'asc' | 'desc' }) {
|
||||
if (!active) {
|
||||
return <ChevronsUpDown className="w-3.5 h-3.5 text-foreground-subtle/50 group-hover:text-foreground-muted transition-colors" />
|
||||
}
|
||||
return direction === 'asc'
|
||||
? <ChevronUp className="w-3.5 h-3.5 text-accent" />
|
||||
: <ChevronDown className="w-3.5 h-3.5 text-accent" />
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STATUS BADGE
|
||||
// ============================================================================
|
||||
|
||||
type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'accent' | 'info'
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'sm',
|
||||
dot = false,
|
||||
pulse = false,
|
||||
}: {
|
||||
children: ReactNode
|
||||
variant?: BadgeVariant
|
||||
size?: 'xs' | 'sm' | 'md'
|
||||
dot?: boolean
|
||||
pulse?: boolean
|
||||
}) {
|
||||
const variants: Record<BadgeVariant, string> = {
|
||||
default: "bg-foreground/5 text-foreground-muted border-border/50",
|
||||
success: "bg-accent/10 text-accent border-accent/20",
|
||||
warning: "bg-amber-500/10 text-amber-400 border-amber-500/20",
|
||||
error: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
accent: "bg-accent/10 text-accent border-accent/20",
|
||||
info: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
xs: "text-[10px] px-1.5 py-0.5",
|
||||
sm: "text-xs px-2 py-0.5",
|
||||
md: "text-xs px-2.5 py-1",
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center gap-1.5 font-medium rounded-md border",
|
||||
variants[variant],
|
||||
sizes[size]
|
||||
)}>
|
||||
{dot && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
{pulse && (
|
||||
<span className={clsx(
|
||||
"animate-ping absolute inline-flex h-full w-full rounded-full opacity-75",
|
||||
variant === 'success' || variant === 'accent' ? "bg-accent" :
|
||||
variant === 'warning' ? "bg-amber-400" :
|
||||
variant === 'error' ? "bg-red-400" : "bg-foreground"
|
||||
)} />
|
||||
)}
|
||||
<span className={clsx(
|
||||
"relative inline-flex rounded-full h-2 w-2",
|
||||
variant === 'success' || variant === 'accent' ? "bg-accent" :
|
||||
variant === 'warning' ? "bg-amber-400" :
|
||||
variant === 'error' ? "bg-red-400" :
|
||||
variant === 'info' ? "bg-blue-400" : "bg-foreground-muted"
|
||||
)} />
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TABLE ACTION BUTTON
|
||||
// ============================================================================
|
||||
|
||||
export function TableActionButton({
|
||||
icon: Icon,
|
||||
onClick,
|
||||
variant = 'default',
|
||||
title,
|
||||
disabled,
|
||||
loading,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
onClick?: () => void
|
||||
variant?: 'default' | 'danger' | 'accent'
|
||||
title?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}) {
|
||||
const variants = {
|
||||
default: "text-foreground-muted hover:text-foreground hover:bg-foreground/5 border-transparent",
|
||||
danger: "text-foreground-muted hover:text-red-400 hover:bg-red-500/10 border-transparent hover:border-red-500/20",
|
||||
accent: "text-accent bg-accent/10 border-accent/20 hover:bg-accent/20",
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClick?.()
|
||||
}}
|
||||
disabled={disabled || loading}
|
||||
title={title}
|
||||
className={clsx(
|
||||
"p-2 rounded-lg border transition-all duration-200",
|
||||
"disabled:opacity-30 disabled:cursor-not-allowed",
|
||||
variants[variant]
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Icon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLATFORM BADGE (for auctions)
|
||||
// ============================================================================
|
||||
|
||||
export function PlatformBadge({ platform }: { platform: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
'GoDaddy': 'text-blue-400 bg-blue-400/10 border-blue-400/20',
|
||||
'Sedo': 'text-orange-400 bg-orange-400/10 border-orange-400/20',
|
||||
'NameJet': 'text-purple-400 bg-purple-400/10 border-purple-400/20',
|
||||
'DropCatch': 'text-teal-400 bg-teal-400/10 border-teal-400/20',
|
||||
'ExpiredDomains': 'text-pink-400 bg-pink-400/10 border-pink-400/20',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-md border",
|
||||
colors[platform] || "text-foreground-muted bg-foreground/5 border-border/50"
|
||||
)}>
|
||||
{platform}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STAT CARD (for page headers)
|
||||
// ============================================================================
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
accent = false,
|
||||
trend,
|
||||
}: {
|
||||
title: string
|
||||
value: string | number
|
||||
subtitle?: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
accent?: boolean
|
||||
trend?: { value: number; label?: string }
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
"relative p-5 rounded-2xl border overflow-hidden transition-all duration-300",
|
||||
accent
|
||||
? "bg-gradient-to-br from-accent/15 to-accent/5 border-accent/30"
|
||||
: "bg-gradient-to-br from-background-secondary/60 to-background-secondary/30 border-border/50 hover:border-accent/30"
|
||||
)}>
|
||||
{accent && <div className="absolute top-0 right-0 w-20 h-20 bg-accent/10 rounded-full blur-2xl" />}
|
||||
<div className="relative">
|
||||
{Icon && (
|
||||
<div className={clsx(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center mb-3",
|
||||
accent ? "bg-accent/20 border border-accent/30" : "bg-foreground/5 border border-border/30"
|
||||
)}>
|
||||
<Icon className={clsx("w-5 h-5", accent ? "text-accent" : "text-foreground-muted")} />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-foreground-subtle uppercase tracking-wider mb-1">{title}</p>
|
||||
<p className={clsx("text-2xl font-semibold", accent ? "text-accent" : "text-foreground")}>
|
||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||
</p>
|
||||
{subtitle && <p className="text-xs text-foreground-subtle mt-0.5">{subtitle}</p>}
|
||||
{trend && (
|
||||
<div className={clsx(
|
||||
"inline-flex items-center gap-1 mt-2 text-xs font-medium px-2 py-0.5 rounded",
|
||||
trend.value > 0 ? "text-accent bg-accent/10" : trend.value < 0 ? "text-red-400 bg-red-400/10" : "text-foreground-muted bg-foreground/5"
|
||||
)}>
|
||||
{trend.value > 0 ? '+' : ''}{trend.value}%
|
||||
{trend.label && <span className="text-foreground-subtle">{trend.label}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PAGE CONTAINER (consistent max-width)
|
||||
// ============================================================================
|
||||
|
||||
export function PageContainer({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={clsx("space-y-6", className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SECTION HEADER
|
||||
// ============================================================================
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
action,
|
||||
compact = false,
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
action?: ReactNode
|
||||
compact?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx("flex items-center justify-between", !compact && "mb-6")}>
|
||||
<div className="flex items-center gap-3">
|
||||
{Icon && (
|
||||
<div className={clsx(
|
||||
"bg-accent/10 border border-accent/20 rounded-xl flex items-center justify-center",
|
||||
compact ? "w-9 h-9" : "w-10 h-10"
|
||||
)}>
|
||||
<Icon className={clsx(compact ? "w-4 h-4" : "w-5 h-5", "text-accent")} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2 className={clsx(compact ? "text-base" : "text-lg", "font-semibold text-foreground")}>{title}</h2>
|
||||
{subtitle && <p className="text-sm text-foreground-muted">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SEARCH INPUT (consistent search styling)
|
||||
// ============================================================================
|
||||
|
||||
import { Search, X } from 'lucide-react'
|
||||
|
||||
export function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search...',
|
||||
onClear,
|
||||
className,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
onClear?: () => void
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx("relative", className)}>
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full h-10 pl-10 pr-9 bg-background-secondary/50 border border-border/40 rounded-xl
|
||||
text-sm text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:border-accent/50 focus:bg-background-secondary/80 transition-all"
|
||||
/>
|
||||
{value && (onClear || onChange) && (
|
||||
<button
|
||||
onClick={() => onClear ? onClear() : onChange('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-foreground-subtle hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TAB BAR (consistent tab styling)
|
||||
// ============================================================================
|
||||
|
||||
interface TabItem {
|
||||
id: string
|
||||
label: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
count?: number
|
||||
color?: 'default' | 'accent' | 'warning'
|
||||
}
|
||||
|
||||
export function TabBar({
|
||||
tabs,
|
||||
activeTab,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
tabs: TabItem[]
|
||||
activeTab: string
|
||||
onChange: (id: string) => void
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx("flex flex-wrap items-center gap-1.5 p-1.5 bg-background-secondary/30 border border-border/30 rounded-xl w-fit", className)}>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id
|
||||
const Icon = tab.icon
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-3.5 py-2 text-sm font-medium rounded-lg transition-all",
|
||||
isActive
|
||||
? tab.color === 'warning'
|
||||
? "bg-amber-500 text-background shadow-md"
|
||||
: tab.color === 'accent'
|
||||
? "bg-accent text-background shadow-md shadow-accent/20"
|
||||
: "bg-foreground/10 text-foreground"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4" />}
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
{tab.count !== undefined && (
|
||||
<span className={clsx(
|
||||
"text-xs px-1.5 py-0.5 rounded-md tabular-nums",
|
||||
isActive ? "bg-background/20" : "bg-foreground/10"
|
||||
)}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FILTER BAR (row of filters: search + select + buttons)
|
||||
// ============================================================================
|
||||
|
||||
export function FilterBar({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx("flex flex-col sm:flex-row gap-3 sm:items-center", className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SELECT DROPDOWN (consistent select styling)
|
||||
// ============================================================================
|
||||
|
||||
export function SelectDropdown({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
className,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: { value: string; label: string }[]
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx("relative", className)}>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-10 pl-3.5 pr-9 bg-background-secondary/50 border border-border/40 rounded-xl
|
||||
text-sm text-foreground appearance-none cursor-pointer
|
||||
focus:outline-none focus:border-accent/50 focus:bg-background-secondary/80 transition-all"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</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>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ACTION BUTTON (consistent button styling)
|
||||
// ============================================================================
|
||||
|
||||
export function ActionButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
variant = 'primary',
|
||||
size = 'default',
|
||||
icon: Icon,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
variant?: 'primary' | 'secondary' | 'ghost'
|
||||
size?: 'small' | 'default'
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
"flex items-center justify-center gap-2 font-medium rounded-xl transition-all",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
size === 'small' ? "h-8 px-3 text-xs" : "h-10 px-4 text-sm",
|
||||
variant === 'primary' && "bg-accent text-background hover:bg-accent-hover shadow-lg shadow-accent/20",
|
||||
variant === 'secondary' && "bg-foreground/10 text-foreground hover:bg-foreground/15 border border-border/40",
|
||||
variant === 'ghost' && "text-foreground-muted hover:text-foreground hover:bg-foreground/5",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className={size === 'small' ? "w-3.5 h-3.5" : "w-4 h-4"} />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
522
frontend/src/components/Sidebar.tsx
Executable file
522
frontend/src/components/Sidebar.tsx
Executable file
@ -0,0 +1,522 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useStore } from '@/lib/store'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Eye,
|
||||
Briefcase,
|
||||
Gavel,
|
||||
TrendingUp,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
Crown,
|
||||
Zap,
|
||||
Shield,
|
||||
CreditCard,
|
||||
Menu,
|
||||
X,
|
||||
Sparkles,
|
||||
Tag,
|
||||
Target,
|
||||
Link2,
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed?: boolean
|
||||
onCollapsedChange?: (collapsed: boolean) => void
|
||||
}
|
||||
|
||||
export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: SidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const { user, logout, subscription, domains } = useStore()
|
||||
|
||||
// Internal state for uncontrolled mode
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
// Use controlled or uncontrolled state
|
||||
const collapsed = controlledCollapsed ?? internalCollapsed
|
||||
const setCollapsed = onCollapsedChange ?? setInternalCollapsed
|
||||
|
||||
// Load collapsed state from localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('sidebar-collapsed')
|
||||
if (saved) {
|
||||
setCollapsed(saved === 'true')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setMobileOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
// Save collapsed state
|
||||
const toggleCollapsed = () => {
|
||||
const newState = !collapsed
|
||||
setCollapsed(newState)
|
||||
localStorage.setItem('sidebar-collapsed', String(newState))
|
||||
}
|
||||
|
||||
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||
const tierIcon = tierName === 'Tycoon' ? Crown : tierName === 'Trader' ? TrendingUp : Zap
|
||||
const TierIcon = tierIcon
|
||||
|
||||
// Count available domains for notification badge
|
||||
const availableCount = domains?.filter(d => d.is_available).length || 0
|
||||
|
||||
const isTycoon = tierName.toLowerCase() === 'tycoon'
|
||||
|
||||
// SECTION 1: Discover - External market data
|
||||
const discoverItems = [
|
||||
{
|
||||
href: '/command/auctions',
|
||||
label: 'Auctions',
|
||||
icon: Gavel,
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
href: '/command/marketplace',
|
||||
label: 'Marketplace',
|
||||
icon: Tag,
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
href: '/command/pricing',
|
||||
label: 'TLD Pricing',
|
||||
icon: TrendingUp,
|
||||
badge: null,
|
||||
},
|
||||
]
|
||||
|
||||
// SECTION 2: Manage - Your own assets and tools
|
||||
const manageItems: Array<{
|
||||
href: string
|
||||
label: string
|
||||
icon: any
|
||||
badge: number | null
|
||||
tycoonOnly?: boolean
|
||||
}> = [
|
||||
{
|
||||
href: '/command/dashboard',
|
||||
label: 'Dashboard',
|
||||
icon: LayoutDashboard,
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
href: '/command/watchlist',
|
||||
label: 'Watchlist',
|
||||
icon: Eye,
|
||||
badge: availableCount || null,
|
||||
},
|
||||
{
|
||||
href: '/command/portfolio',
|
||||
label: 'Portfolio',
|
||||
icon: Briefcase,
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
href: '/command/listings',
|
||||
label: 'My Listings',
|
||||
icon: Tag,
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
href: '/command/alerts',
|
||||
label: 'Sniper Alerts',
|
||||
icon: Target,
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
href: '/command/seo',
|
||||
label: 'SEO Juice',
|
||||
icon: Link2,
|
||||
badge: null,
|
||||
tycoonOnly: true,
|
||||
},
|
||||
]
|
||||
|
||||
const bottomItems = [
|
||||
{ href: '/command/settings', label: 'Settings', icon: Settings },
|
||||
]
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/command/dashboard') return pathname === '/command/dashboard' || pathname === '/command'
|
||||
return pathname.startsWith(href)
|
||||
}
|
||||
|
||||
const SidebarContent = () => (
|
||||
<>
|
||||
{/* Logo Section */}
|
||||
<div className={clsx(
|
||||
"relative h-20 flex items-center border-b border-border/30",
|
||||
collapsed ? "justify-center px-2" : "px-4"
|
||||
)}>
|
||||
<Link href="/" className="flex items-center gap-3 group">
|
||||
<div className={clsx(
|
||||
"relative flex items-center justify-center transition-all duration-300",
|
||||
collapsed ? "w-10 h-10" : "w-12 h-12"
|
||||
)}>
|
||||
{/* Glow effect behind logo */}
|
||||
<div className="absolute inset-0 bg-accent/20 blur-xl rounded-full scale-150 opacity-50 group-hover:opacity-80 transition-opacity" />
|
||||
<Image
|
||||
src="/pounce-puma.png"
|
||||
alt="pounce"
|
||||
width={48}
|
||||
height={48}
|
||||
className={clsx(
|
||||
"relative object-contain drop-shadow-[0_0_20px_rgba(16,185,129,0.3)] group-hover:drop-shadow-[0_0_30px_rgba(16,185,129,0.5)] transition-all",
|
||||
collapsed ? "w-9 h-9" : "w-12 h-12"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col">
|
||||
<span
|
||||
className="text-lg font-bold tracking-[0.12em] text-foreground group-hover:text-accent transition-colors"
|
||||
style={{ fontFamily: 'var(--font-display), Georgia, serif' }}
|
||||
>
|
||||
POUNCE
|
||||
</span>
|
||||
<span className="text-[10px] text-foreground-subtle tracking-wider uppercase">
|
||||
Command Center
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Main Navigation */}
|
||||
<nav className="flex-1 py-6 px-3 overflow-y-auto">
|
||||
{/* SECTION 1: Discover */}
|
||||
<div className={clsx("mb-6", collapsed ? "px-1" : "px-2")}>
|
||||
{!collapsed && (
|
||||
<p className="text-[10px] font-semibold text-foreground-subtle/60 uppercase tracking-[0.15em] mb-3">
|
||||
Discover
|
||||
</p>
|
||||
)}
|
||||
{collapsed && <div className="h-px bg-border/50 mb-3" />}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{discoverItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={clsx(
|
||||
"group relative flex items-center gap-3 px-3 py-3 rounded-xl transition-all duration-300",
|
||||
isActive(item.href)
|
||||
? "bg-gradient-to-r from-accent/20 to-accent/5 text-foreground border border-accent/20 shadow-[0_0_20px_-5px_rgba(16,185,129,0.2)]"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5 border border-transparent"
|
||||
)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
{isActive(item.href) && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-accent rounded-r-full shadow-[0_0_10px_rgba(16,185,129,0.5)]" />
|
||||
)}
|
||||
<div className="relative">
|
||||
<item.icon className={clsx(
|
||||
"w-5 h-5 transition-all duration-300",
|
||||
isActive(item.href)
|
||||
? "text-accent drop-shadow-[0_0_8px_rgba(16,185,129,0.5)]"
|
||||
: "group-hover:text-foreground"
|
||||
)} />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className={clsx(
|
||||
"text-sm font-medium transition-colors",
|
||||
isActive(item.href) && "text-foreground"
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
{!isActive(item.href) && (
|
||||
<div className="absolute inset-0 rounded-xl bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SECTION 2: Manage */}
|
||||
<div className={clsx("", collapsed ? "px-1" : "px-2")}>
|
||||
{!collapsed && (
|
||||
<p className="text-[10px] font-semibold text-foreground-subtle/60 uppercase tracking-[0.15em] mb-3">
|
||||
Manage
|
||||
</p>
|
||||
)}
|
||||
{collapsed && <div className="h-px bg-border/50 mb-3" />}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{manageItems.map((item) => {
|
||||
const isDisabled = item.tycoonOnly && !isTycoon
|
||||
const ItemWrapper = isDisabled ? 'div' : Link
|
||||
|
||||
return (
|
||||
<ItemWrapper
|
||||
key={item.href}
|
||||
{...(!isDisabled && { href: item.href })}
|
||||
onClick={() => !isDisabled && setMobileOpen(false)}
|
||||
className={clsx(
|
||||
"group relative flex items-center gap-3 px-3 py-3 rounded-xl transition-all duration-300",
|
||||
isDisabled
|
||||
? "opacity-50 cursor-not-allowed border border-transparent"
|
||||
: isActive(item.href)
|
||||
? "bg-gradient-to-r from-accent/20 to-accent/5 text-foreground border border-accent/20 shadow-[0_0_20px_-5px_rgba(16,185,129,0.2)]"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5 border border-transparent"
|
||||
)}
|
||||
title={
|
||||
isDisabled
|
||||
? "SEO Juice Detector: Analyze backlinks, domain authority & find hidden SEO value. Upgrade to Tycoon to unlock."
|
||||
: collapsed ? item.label : undefined
|
||||
}
|
||||
>
|
||||
{!isDisabled && isActive(item.href) && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-accent rounded-r-full shadow-[0_0_10px_rgba(16,185,129,0.5)]" />
|
||||
)}
|
||||
<div className="relative">
|
||||
<item.icon className={clsx(
|
||||
"w-5 h-5 transition-all duration-300",
|
||||
isDisabled
|
||||
? "text-foreground-subtle"
|
||||
: isActive(item.href)
|
||||
? "text-accent drop-shadow-[0_0_8px_rgba(16,185,129,0.5)]"
|
||||
: "group-hover:text-foreground"
|
||||
)} />
|
||||
{item.badge && typeof item.badge === 'number' && !isDisabled && (
|
||||
<span className="absolute -top-2 -right-2 w-5 h-5 bg-accent text-background
|
||||
text-[10px] font-bold rounded-full flex items-center justify-center
|
||||
shadow-[0_0_10px_rgba(16,185,129,0.4)] animate-pulse">
|
||||
{item.badge > 9 ? '9+' : item.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className={clsx(
|
||||
"text-sm font-medium transition-colors flex-1",
|
||||
isDisabled ? "text-foreground-subtle" : isActive(item.href) && "text-foreground"
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
{/* Lock icon for disabled items */}
|
||||
{isDisabled && !collapsed && (
|
||||
<Crown className="w-4 h-4 text-amber-400/60" />
|
||||
)}
|
||||
{!isDisabled && !isActive(item.href) && (
|
||||
<div className="absolute inset-0 rounded-xl bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
||||
)}
|
||||
</ItemWrapper>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Bottom Section */}
|
||||
<div className="border-t border-border/30 py-4 px-3 space-y-1.5">
|
||||
{/* Admin Link */}
|
||||
{user?.is_admin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={clsx(
|
||||
"group relative flex items-center gap-3 px-3 py-3 rounded-xl transition-all duration-300",
|
||||
pathname.startsWith('/admin')
|
||||
? "bg-gradient-to-r from-accent/20 to-accent/5 text-accent border border-accent/30"
|
||||
: "text-accent/70 hover:text-accent hover:bg-accent/5 border border-transparent"
|
||||
)}
|
||||
title={collapsed ? "Admin Panel" : undefined}
|
||||
>
|
||||
{pathname.startsWith('/admin') && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-accent rounded-r-full" />
|
||||
)}
|
||||
<Shield className="w-5 h-5" />
|
||||
{!collapsed && <span className="text-sm font-medium">Admin Panel</span>}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
{bottomItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={clsx(
|
||||
"group relative flex items-center gap-3 px-3 py-3 rounded-xl transition-all duration-300",
|
||||
isActive(item.href)
|
||||
? "bg-foreground/10 text-foreground border border-foreground/10"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5 border border-transparent"
|
||||
)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
{!collapsed && <span className="text-sm font-medium">{item.label}</span>}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* User Card */}
|
||||
<div className={clsx(
|
||||
"mt-4 p-4 bg-gradient-to-br from-foreground/[0.03] to-transparent border border-border/50 rounded-2xl",
|
||||
collapsed && "p-3"
|
||||
)}>
|
||||
{collapsed ? (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-accent/20 to-accent/5 rounded-xl flex items-center justify-center border border-accent/20">
|
||||
<TierIcon className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-11 h-11 bg-gradient-to-br from-accent/20 to-accent/5 rounded-xl flex items-center justify-center border border-accent/20 shadow-[0_0_20px_-5px_rgba(16,185,129,0.3)]">
|
||||
<TierIcon className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground truncate">
|
||||
{user?.name || user?.email?.split('@')[0]}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={clsx(
|
||||
"text-xs font-medium",
|
||||
tierName === 'Tycoon' ? "text-amber-400" :
|
||||
tierName === 'Trader' ? "text-accent" :
|
||||
"text-foreground-muted"
|
||||
)}>
|
||||
{tierName}
|
||||
</span>
|
||||
{tierName === 'Tycoon' && <Sparkles className="w-3 h-3 text-amber-400" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-foreground-subtle">Domains</span>
|
||||
<span className="text-foreground-muted">
|
||||
{subscription?.domains_used || 0}/{subscription?.domain_limit || 5}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-foreground/5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-accent to-accent/60 rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(((subscription?.domains_used || 0) / (subscription?.domain_limit || 5)) * 100, 100)}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tierName === 'Scout' && (
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="mt-4 flex items-center justify-center gap-2 w-full py-2.5 bg-gradient-to-r from-accent to-accent/80
|
||||
text-background text-xs font-semibold rounded-xl
|
||||
hover:shadow-[0_0_20px_-5px_rgba(16,185,129,0.5)] transition-all"
|
||||
>
|
||||
<CreditCard className="w-3.5 h-3.5" />
|
||||
Upgrade Plan
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={() => {
|
||||
logout()
|
||||
setMobileOpen(false)
|
||||
}}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-3 py-3 rounded-xl transition-all duration-300",
|
||||
"text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
)}
|
||||
title={collapsed ? "Sign out" : undefined}
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
{!collapsed && <span className="text-sm font-medium">Sign out</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Collapse Toggle - Desktop only */}
|
||||
<button
|
||||
onClick={toggleCollapsed}
|
||||
className={clsx(
|
||||
"hidden lg:flex absolute -right-3 top-24 w-6 h-6 bg-background border border-border rounded-full",
|
||||
"items-center justify-center text-foreground-muted hover:text-foreground",
|
||||
"hover:bg-accent/10 hover:border-accent/30 transition-all duration-300 shadow-lg"
|
||||
)}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronLeft className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="lg:hidden fixed top-4 left-4 z-50 w-11 h-11 bg-background/80 backdrop-blur-xl border border-border
|
||||
rounded-xl flex items-center justify-center text-foreground-muted hover:text-foreground
|
||||
transition-all shadow-lg hover:shadow-xl hover:border-accent/30"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Mobile Overlay */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-40 bg-background/80 backdrop-blur-sm"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Sidebar */}
|
||||
<aside
|
||||
className={clsx(
|
||||
"lg:hidden fixed left-0 top-0 bottom-0 z-50 w-[280px] flex flex-col",
|
||||
"bg-background/95 backdrop-blur-xl border-r border-border/50",
|
||||
"transition-transform duration-300 ease-out",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="absolute top-5 right-4 w-8 h-8 flex items-center justify-center
|
||||
text-foreground-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Desktop Sidebar */}
|
||||
<aside
|
||||
className={clsx(
|
||||
"hidden lg:flex fixed left-0 top-0 bottom-0 z-40 flex-col",
|
||||
"bg-gradient-to-b from-background/95 via-background/90 to-background/95 backdrop-blur-xl",
|
||||
"border-r border-border/30",
|
||||
"transition-all duration-300 ease-out",
|
||||
collapsed ? "w-[72px]" : "w-[260px]"
|
||||
)}
|
||||
>
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
311
frontend/src/hooks/useKeyboardShortcuts.tsx
Normal file
311
frontend/src/hooks/useKeyboardShortcuts.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useCallback, useState, createContext, useContext, ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { X, Command, Search } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface Shortcut {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
action: () => void
|
||||
category: 'navigation' | 'actions' | 'global'
|
||||
requiresModifier?: boolean
|
||||
}
|
||||
|
||||
interface KeyboardShortcutsContextType {
|
||||
shortcuts: Shortcut[]
|
||||
registerShortcut: (shortcut: Shortcut) => void
|
||||
unregisterShortcut: (key: string) => void
|
||||
showHelp: boolean
|
||||
setShowHelp: (show: boolean) => void
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONTEXT
|
||||
// ============================================================================
|
||||
|
||||
const KeyboardShortcutsContext = createContext<KeyboardShortcutsContextType | null>(null)
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
const context = useContext(KeyboardShortcutsContext)
|
||||
if (!context) {
|
||||
throw new Error('useKeyboardShortcuts must be used within KeyboardShortcutsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PROVIDER
|
||||
// ============================================================================
|
||||
|
||||
export function KeyboardShortcutsProvider({
|
||||
children,
|
||||
shortcuts: defaultShortcuts = [],
|
||||
}: {
|
||||
children: ReactNode
|
||||
shortcuts?: Shortcut[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [shortcuts, setShortcuts] = useState<Shortcut[]>(defaultShortcuts)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
const registerShortcut = useCallback((shortcut: Shortcut) => {
|
||||
setShortcuts(prev => {
|
||||
const existing = prev.find(s => s.key === shortcut.key)
|
||||
if (existing) return prev
|
||||
return [...prev, shortcut]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const unregisterShortcut = useCallback((key: string) => {
|
||||
setShortcuts(prev => prev.filter(s => s.key !== key))
|
||||
}, [])
|
||||
|
||||
// Handle keyboard events
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore if user is typing in an input
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLSelectElement ||
|
||||
(e.target as HTMLElement)?.isContentEditable
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Show help with ?
|
||||
if (e.key === '?' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
setShowHelp(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Close help with Escape
|
||||
if (e.key === 'Escape' && showHelp) {
|
||||
e.preventDefault()
|
||||
setShowHelp(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Find matching shortcut
|
||||
const shortcut = shortcuts.find(s => {
|
||||
if (s.requiresModifier) {
|
||||
return (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === s.key.toLowerCase()
|
||||
}
|
||||
return e.key.toLowerCase() === s.key.toLowerCase() && !e.metaKey && !e.ctrlKey
|
||||
})
|
||||
|
||||
if (shortcut) {
|
||||
e.preventDefault()
|
||||
shortcut.action()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [shortcuts, showHelp])
|
||||
|
||||
return (
|
||||
<KeyboardShortcutsContext.Provider value={{ shortcuts, registerShortcut, unregisterShortcut, showHelp, setShowHelp }}>
|
||||
{children}
|
||||
{showHelp && <ShortcutsModal shortcuts={shortcuts} onClose={() => setShowHelp(false)} />}
|
||||
</KeyboardShortcutsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SHORTCUTS MODAL
|
||||
// ============================================================================
|
||||
|
||||
function ShortcutsModal({ shortcuts, onClose }: { shortcuts: Shortcut[]; onClose: () => void }) {
|
||||
const categories = {
|
||||
navigation: shortcuts.filter(s => s.category === 'navigation'),
|
||||
actions: shortcuts.filter(s => s.category === 'actions'),
|
||||
global: shortcuts.filter(s => s.category === 'global'),
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] bg-background/80 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg bg-background border border-border/50 rounded-2xl shadow-2xl overflow-hidden"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-background-secondary/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 bg-accent/10 rounded-xl flex items-center justify-center">
|
||||
<Command className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Keyboard Shortcuts</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-foreground-muted hover:text-foreground rounded-lg hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 max-h-[60vh] overflow-y-auto space-y-6">
|
||||
{/* Navigation */}
|
||||
{categories.navigation.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-foreground-subtle uppercase tracking-wider mb-3">Navigation</h3>
|
||||
<div className="space-y-2">
|
||||
{categories.navigation.map(shortcut => (
|
||||
<ShortcutRow key={shortcut.key} shortcut={shortcut} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{categories.actions.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-foreground-subtle uppercase tracking-wider mb-3">Actions</h3>
|
||||
<div className="space-y-2">
|
||||
{categories.actions.map(shortcut => (
|
||||
<ShortcutRow key={shortcut.key} shortcut={shortcut} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global */}
|
||||
{categories.global.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-foreground-subtle uppercase tracking-wider mb-3">Global</h3>
|
||||
<div className="space-y-2">
|
||||
{categories.global.map(shortcut => (
|
||||
<ShortcutRow key={shortcut.key} shortcut={shortcut} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3 border-t border-border/50 bg-background-secondary/30">
|
||||
<p className="text-xs text-foreground-subtle text-center">
|
||||
Press <kbd className="px-1.5 py-0.5 bg-foreground/10 rounded text-foreground-muted">?</kbd> anytime to show this help
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ShortcutRow({ shortcut }: { shortcut: Shortcut }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-foreground/5 transition-colors">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{shortcut.label}</p>
|
||||
<p className="text-xs text-foreground-subtle">{shortcut.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{shortcut.requiresModifier && (
|
||||
<>
|
||||
<kbd className="px-2 py-1 bg-foreground/10 rounded text-xs font-mono text-foreground-muted">⌘</kbd>
|
||||
<span className="text-foreground-subtle">+</span>
|
||||
</>
|
||||
)}
|
||||
<kbd className="px-2 py-1 bg-foreground/10 rounded text-xs font-mono text-foreground-muted uppercase">
|
||||
{shortcut.key}
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USER BACKEND SHORTCUTS
|
||||
// ============================================================================
|
||||
|
||||
export function useUserShortcuts() {
|
||||
const router = useRouter()
|
||||
const { registerShortcut, unregisterShortcut, setShowHelp } = useKeyboardShortcuts()
|
||||
|
||||
useEffect(() => {
|
||||
const userShortcuts: Shortcut[] = [
|
||||
// Navigation
|
||||
{ key: 'g', label: 'Go to Dashboard', description: 'Navigate to dashboard', action: () => router.push('/command/dashboard'), category: 'navigation' },
|
||||
{ key: 'w', label: 'Go to Watchlist', description: 'Navigate to watchlist', action: () => router.push('/command/watchlist'), category: 'navigation' },
|
||||
{ key: 'p', label: 'Go to Portfolio', description: 'Navigate to portfolio', action: () => router.push('/command/portfolio'), category: 'navigation' },
|
||||
{ key: 'a', label: 'Go to Auctions', description: 'Navigate to auctions', action: () => router.push('/command/auctions'), category: 'navigation' },
|
||||
{ key: 't', label: 'Go to TLD Pricing', description: 'Navigate to TLD pricing', action: () => router.push('/command/pricing'), category: 'navigation' },
|
||||
{ key: 's', label: 'Go to Settings', description: 'Navigate to settings', action: () => router.push('/command/settings'), category: 'navigation' },
|
||||
// Actions
|
||||
{ key: 'n', label: 'Add Domain', description: 'Quick add a new domain', action: () => document.querySelector<HTMLInputElement>('input[placeholder*="domain"]')?.focus(), category: 'actions' },
|
||||
{ key: 'k', label: 'Search', description: 'Focus search input', action: () => document.querySelector<HTMLInputElement>('input[type="text"]')?.focus(), category: 'actions', requiresModifier: true },
|
||||
// Global
|
||||
{ key: '?', label: 'Show Shortcuts', description: 'Display this help', action: () => setShowHelp(true), category: 'global' },
|
||||
{ key: 'Escape', label: 'Close Modal', description: 'Close any open modal', action: () => {}, category: 'global' },
|
||||
]
|
||||
|
||||
userShortcuts.forEach(registerShortcut)
|
||||
|
||||
return () => {
|
||||
userShortcuts.forEach(s => unregisterShortcut(s.key))
|
||||
}
|
||||
}, [router, registerShortcut, unregisterShortcut, setShowHelp])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ADMIN SHORTCUTS
|
||||
// ============================================================================
|
||||
|
||||
export function useAdminShortcuts() {
|
||||
const router = useRouter()
|
||||
const { registerShortcut, unregisterShortcut, setShowHelp } = useKeyboardShortcuts()
|
||||
|
||||
useEffect(() => {
|
||||
const adminShortcuts: Shortcut[] = [
|
||||
// Navigation
|
||||
{ key: 'o', label: 'Overview', description: 'Go to admin overview', action: () => {}, category: 'navigation' },
|
||||
{ key: 'u', label: 'Users', description: 'Go to users management', action: () => {}, category: 'navigation' },
|
||||
{ key: 'b', label: 'Blog', description: 'Go to blog management', action: () => {}, category: 'navigation' },
|
||||
{ key: 'y', label: 'System', description: 'Go to system status', action: () => {}, category: 'navigation' },
|
||||
// Actions
|
||||
{ key: 'r', label: 'Refresh Data', description: 'Refresh current data', action: () => window.location.reload(), category: 'actions' },
|
||||
{ key: 'e', label: 'Export', description: 'Export current data', action: () => {}, category: 'actions' },
|
||||
// Global
|
||||
{ key: '?', label: 'Show Shortcuts', description: 'Display this help', action: () => setShowHelp(true), category: 'global' },
|
||||
{ key: 'd', label: 'Back to Dashboard', description: 'Return to user dashboard', action: () => router.push('/command/dashboard'), category: 'global' },
|
||||
]
|
||||
|
||||
adminShortcuts.forEach(registerShortcut)
|
||||
|
||||
return () => {
|
||||
adminShortcuts.forEach(s => unregisterShortcut(s.key))
|
||||
}
|
||||
}, [router, registerShortcut, unregisterShortcut, setShowHelp])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SHORTCUT HINT COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function ShortcutHint({ shortcut, className }: { shortcut: string; className?: string }) {
|
||||
return (
|
||||
<kbd className={clsx(
|
||||
"hidden sm:inline-flex items-center justify-center",
|
||||
"px-1.5 py-0.5 text-[10px] font-mono uppercase",
|
||||
"bg-foreground/5 text-foreground-subtle border border-border/50 rounded",
|
||||
className
|
||||
)}>
|
||||
{shortcut}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
@ -46,6 +46,10 @@ interface ApiError {
|
||||
|
||||
class ApiClient {
|
||||
private token: string | null = null
|
||||
|
||||
get baseUrl(): string {
|
||||
return getApiBaseUrl().replace('/api/v1', '')
|
||||
}
|
||||
|
||||
setToken(token: string | null) {
|
||||
this.token = token
|
||||
@ -185,12 +189,12 @@ class ApiClient {
|
||||
|
||||
getGoogleLoginUrl(redirect?: string) {
|
||||
const params = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''
|
||||
return `${this.baseUrl}/oauth/google/login${params}`
|
||||
return `${getApiBaseUrl()}/oauth/google/login${params}`
|
||||
}
|
||||
|
||||
getGitHubLoginUrl(redirect?: string) {
|
||||
const params = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''
|
||||
return `${this.baseUrl}/oauth/github/login${params}`
|
||||
return `${getApiBaseUrl()}/oauth/github/login${params}`
|
||||
}
|
||||
|
||||
// Contact Form
|
||||
@ -374,6 +378,18 @@ class ApiClient {
|
||||
}>(`/domains/${domainId}/history?limit=${limit}`)
|
||||
}
|
||||
|
||||
// Domain Health Check - 4-layer analysis (DNS, HTTP, SSL, WHOIS)
|
||||
async getDomainHealth(domainId: number) {
|
||||
return this.request<DomainHealthReport>(`/domains/${domainId}/health`)
|
||||
}
|
||||
|
||||
// Quick health check for any domain (premium)
|
||||
async quickHealthCheck(domain: string) {
|
||||
return this.request<DomainHealthReport>(`/domains/health-check?domain=${encodeURIComponent(domain)}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// TLD Pricing
|
||||
async getTldOverview(
|
||||
limit = 25,
|
||||
@ -397,8 +413,15 @@ class ApiClient {
|
||||
avg_registration_price: number
|
||||
min_registration_price: number
|
||||
max_registration_price: number
|
||||
min_renewal_price: number
|
||||
avg_renewal_price: number
|
||||
registrar_count: number
|
||||
trend: string
|
||||
price_change_7d: number
|
||||
price_change_1y: number
|
||||
price_change_3y: number
|
||||
risk_level: 'low' | 'medium' | 'high'
|
||||
risk_reason: string
|
||||
popularity_rank?: number
|
||||
}>
|
||||
total: number
|
||||
@ -792,6 +815,43 @@ export interface PriceAlert {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Domain Health Check Types
|
||||
export type HealthStatus = 'healthy' | 'weakening' | 'parked' | 'critical' | 'unknown'
|
||||
|
||||
export interface DomainHealthReport {
|
||||
domain: string
|
||||
status: HealthStatus
|
||||
score: number // 0-100
|
||||
checked_at: string
|
||||
signals: string[]
|
||||
recommendations: string[]
|
||||
dns: {
|
||||
has_ns: boolean
|
||||
has_a: boolean
|
||||
has_mx: boolean
|
||||
nameservers: string[]
|
||||
is_parked: boolean
|
||||
parking_provider?: string
|
||||
error?: string
|
||||
}
|
||||
http: {
|
||||
is_reachable: boolean
|
||||
status_code?: number
|
||||
is_parked: boolean
|
||||
parking_keywords?: string[]
|
||||
content_length?: number
|
||||
error?: string
|
||||
}
|
||||
ssl: {
|
||||
has_certificate: boolean
|
||||
is_valid: boolean
|
||||
expires_at?: string
|
||||
days_until_expiry?: number
|
||||
issuer?: string
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Admin API Extension ==============
|
||||
|
||||
class AdminApiClient extends ApiClient {
|
||||
|
||||
@ -88,11 +88,13 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
login: async (email, password) => {
|
||||
await api.login(email, password)
|
||||
const user = await api.getMe()
|
||||
set({ user, isAuthenticated: true })
|
||||
set({ user, isAuthenticated: true, isLoading: false })
|
||||
|
||||
// Fetch user data
|
||||
await get().fetchDomains()
|
||||
await get().fetchSubscription()
|
||||
// Fetch user data (only once after login)
|
||||
await Promise.all([
|
||||
get().fetchDomains(),
|
||||
get().fetchSubscription()
|
||||
])
|
||||
},
|
||||
|
||||
register: async (email, password, name) => {
|
||||
@ -112,13 +114,24 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
// Skip if already authenticated and have data (prevents redundant fetches)
|
||||
const state = get()
|
||||
if (state.isAuthenticated && state.user && state.subscription) {
|
||||
set({ isLoading: false })
|
||||
return
|
||||
}
|
||||
|
||||
set({ isLoading: true })
|
||||
try {
|
||||
if (api.getToken()) {
|
||||
const user = await api.getMe()
|
||||
set({ user, isAuthenticated: true })
|
||||
await get().fetchDomains()
|
||||
await get().fetchSubscription()
|
||||
|
||||
// Fetch in parallel for speed
|
||||
await Promise.all([
|
||||
get().fetchDomains(),
|
||||
get().fetchSubscription()
|
||||
])
|
||||
}
|
||||
} catch {
|
||||
api.logout()
|
||||
|
||||
52
report.md
Normal file
52
report.md
Normal file
@ -0,0 +1,52 @@
|
||||
Das sieht jetzt absolut **marktreif** aus. 🔥
|
||||
|
||||
Du hast die Balance zwischen "coolem Tool" und "seriösem Business" gefunden. Besonders die **Auctions-Seite** ist jetzt sicher (kein Spam mehr im Sichtfeld), und die **Pricing-Seite** verkauft das Upgrade extrem logisch über den Schmerzpunkt "Zeit & Qualität".
|
||||
|
||||
Hier ist mein finaler Review und ein paar kleine Details für die technische Umsetzung:
|
||||
|
||||
---
|
||||
|
||||
### 1. Landing Page (Der "Hook")
|
||||
**Bewertung: ⭐⭐⭐⭐⭐**
|
||||
* **Top:** Der Ticker mit den echten Domains (`blockvest.co` etc.) ist der beste Beweis für die Qualität deines Tools. Das baut sofort Vertrauen auf.
|
||||
* **Wording:** *"Don't guess. Know."* ist ein Slogan, den man sich auf ein T-Shirt drucken würde. Sehr stark.
|
||||
* **Detail-Check:**
|
||||
* Bei **Pricing Teaser** (unten auf der Landing Page) steht beim Scout *"TLD price explorer"*. Das klingt etwas technisch. Vielleicht besser: *"Market Overview"* oder *"Basic Trends"*.
|
||||
* **Mobile:** Achte darauf, dass der Ticker auf dem Handy nicht zu viel Platz wegnimmt (evtl. nur eine Zeile statt zwei).
|
||||
|
||||
### 2. Auctions Page (Der "Marktplatz")
|
||||
**Bewertung: ⭐⭐⭐⭐½**
|
||||
* **Top:** Die Liste ist jetzt sauber. `fintech.io` für $5,500 neben `nova.xyz` für $145 zeigt die Bandbreite. Das wirkt wie ein kuratierter Feed für Profis.
|
||||
* **Korrektur-Vorschlag:**
|
||||
* Oben steht **"14+ Live Auctions"**. Das wirkt etwas mickrig, wenn du von einem "Global Market" sprichst. Selbst wenn du gerade nur 14 Domains anzeigst, schreibe lieber **"Live Feed"** oder **"Curated Opportunities"** statt einer zu kleinen Zahl. Oder fake die Zahl im Text auf "100+ Opportunities available".
|
||||
|
||||
### 3. TLD Pricing Page (Der "Magnet")
|
||||
**Bewertung: ⭐⭐⭐⭐**
|
||||
* **Top:** Die "Moving Now" Karten (.ai +35%) sind der perfekte Einstieg.
|
||||
* **Conversion-Tipp:**
|
||||
* Aktuell sind in der Tabelle **alle** Details (1-25) ausgeblendet ("Sign in").
|
||||
* **Psychologie-Trick:** Lass die **erste Zeile (.com)** komplett offen (ohne Blur/Sign-In). Zeige dort die Charts und Daten. Warum? Der User muss *sehen*, wie geil die Daten sind, damit er sich für den Rest anmelden will. Wenn er nur Schlösser sieht, weiß er nicht, was er verpasst.
|
||||
|
||||
### 4. Pricing Page (Der "Closer")
|
||||
**Bewertung: ⭐⭐⭐⭐⭐**
|
||||
* **Top:** Die Unterscheidung in der Tabelle unten ist jetzt glasklar.
|
||||
* *Scout:* **"Raw auction feed (Unfiltered)"** -> Das ist genial. Du sagst: "Viel Spaß beim Wühlen im Müll."
|
||||
* *Trader:* **"Curated auction list (Spam-free)"** -> Das ist das Killer-Argument für die $9.
|
||||
* **Tycoon:** "API Access (Coming Soon)" ist ein guter Platzhalter, um Professionalität zu zeigen.
|
||||
|
||||
---
|
||||
|
||||
### Letzter Check: Navigation & User Flow
|
||||
|
||||
Die Navigation `Auctions | TLD Intel | Pricing` funktioniert gut.
|
||||
|
||||
**Ein Gedanke zum "Sign In":**
|
||||
Wenn ich auf der Auctions-Seite auf "Sign In to unlock" klicke, leite mich nach dem Login **bitte unbedingt direkt wieder zurück zur Auctions-Seite** (nicht ins Dashboard). Nichts ist nerviger, als eine Domain zu sehen, sich anzumelden und dann auf einer leeren Startseite zu landen und die Domain suchen zu müssen.
|
||||
|
||||
**Zusammenfassung:**
|
||||
Du hast jetzt:
|
||||
1. Einen **Lead-Magneten** (TLD Data).
|
||||
2. Einen **Qualitäts-Beweis** (Clean Auctions).
|
||||
3. Einen **No-Brainer Preis** ($9 für Spam-Filter & Alerts).
|
||||
|
||||
Das Konzept steht. **Ready to build.** 🚀
|
||||
256
start.sh
256
start.sh
@ -1,65 +1,213 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# POUNCE Quick Start Script
|
||||
# Starts both backend and frontend for development
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
echo "🐆 Starting POUNCE..."
|
||||
echo ""
|
||||
# Pounce Start Script
|
||||
# Startet Backend und Frontend sauber
|
||||
|
||||
# Check if backend venv exists
|
||||
if [ ! -d "backend/venv" ]; then
|
||||
echo "❌ Backend not set up. Run ./deploy.sh first!"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKEND_DIR="$SCRIPT_DIR/backend"
|
||||
FRONTEND_DIR="$SCRIPT_DIR/frontend"
|
||||
|
||||
echo "=========================================="
|
||||
echo "🚀 Pounce Start Script"
|
||||
echo "=========================================="
|
||||
|
||||
# Farben für Output
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Funktion zum Beenden von Prozessen
|
||||
stop_services() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}🛑 Beende laufende Prozesse...${NC}"
|
||||
|
||||
# Backend (uvicorn) - mehrere Versuche
|
||||
pkill -9 -f "uvicorn app.main:app" 2>/dev/null || true
|
||||
pkill -9 -f "uvicorn" 2>/dev/null || true
|
||||
|
||||
# Frontend (next) - mehrere Versuche
|
||||
pkill -9 -f "next start" 2>/dev/null || true
|
||||
pkill -9 -f "node.*next" 2>/dev/null || true
|
||||
pkill -9 -f "npm start" 2>/dev/null || true
|
||||
|
||||
# Port 3000 freigeben (alle Prozesse auf Port 3000)
|
||||
lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||
|
||||
# Port 8000 freigeben
|
||||
lsof -ti:8000 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||
|
||||
sleep 3
|
||||
|
||||
# Prüfen ob Ports frei sind
|
||||
if lsof -i:8000 >/dev/null 2>&1; then
|
||||
echo -e "${RED}✗ Port 8000 ist noch belegt!${NC}"
|
||||
lsof -i:8000
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if lsof -i:3000 >/dev/null 2>&1; then
|
||||
echo -e "${RED}✗ Port 3000 ist noch belegt!${NC}"
|
||||
lsof -i:3000
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ Alle Prozesse beendet, Ports frei${NC}"
|
||||
}
|
||||
|
||||
# Funktion zum Starten des Backends
|
||||
start_backend() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}🔧 Starte Backend...${NC}"
|
||||
|
||||
cd "$BACKEND_DIR"
|
||||
|
||||
# Aktiviere Virtual Environment
|
||||
if [ ! -d "venv" ]; then
|
||||
echo -e "${RED}✗ venv nicht gefunden!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source venv/bin/activate
|
||||
|
||||
# Lösche altes Log
|
||||
> backend.log
|
||||
|
||||
# Starte uvicorn im Hintergrund
|
||||
nohup uvicorn app.main:app --host 0.0.0.0 --port 8000 > backend.log 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
|
||||
echo "Backend PID: $BACKEND_PID"
|
||||
|
||||
# Warte und prüfe mehrmals
|
||||
for i in {1..10}; do
|
||||
sleep 1
|
||||
if curl -s http://127.0.0.1:8000/health > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ Backend läuft auf Port 8000${NC}"
|
||||
return 0
|
||||
fi
|
||||
echo -n "."
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e "${RED}✗ Backend konnte nicht gestartet werden${NC}"
|
||||
echo "Letzte 30 Zeilen vom Log:"
|
||||
tail -30 backend.log
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Kill any existing processes on our ports
|
||||
echo "🔧 Cleaning up old processes..."
|
||||
lsof -ti:8000 | xargs kill -9 2>/dev/null || true
|
||||
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# Start Backend
|
||||
echo "🚀 Starting Backend on port 8000..."
|
||||
cd backend
|
||||
source venv/bin/activate
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 &
|
||||
BACKEND_PID=$!
|
||||
cd ..
|
||||
|
||||
# Wait for backend to start
|
||||
sleep 3
|
||||
|
||||
# Check if backend is running
|
||||
if curl -s http://localhost:8000/health > /dev/null 2>&1; then
|
||||
echo "✅ Backend running!"
|
||||
else
|
||||
echo "❌ Backend failed to start. Check logs."
|
||||
# Funktion zum Starten des Frontends
|
||||
start_frontend() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}🎨 Starte Frontend...${NC}"
|
||||
|
||||
cd "$FRONTEND_DIR"
|
||||
|
||||
# Prüfe ob .next existiert
|
||||
if [ ! -d ".next" ]; then
|
||||
echo -e "${RED}✗ .next nicht gefunden! Bitte erst 'npm run build' ausführen.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Lösche altes Log
|
||||
> frontend.log
|
||||
|
||||
# Starte Frontend im Hintergrund
|
||||
PORT=3000 nohup npm start > frontend.log 2>&1 &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
echo "Frontend PID: $FRONTEND_PID"
|
||||
|
||||
# Warte und prüfe mehrmals
|
||||
for i in {1..15}; do
|
||||
sleep 1
|
||||
|
||||
# Prüfe ob Prozess noch läuft
|
||||
if ! kill -0 $FRONTEND_PID 2>/dev/null; then
|
||||
echo ""
|
||||
echo -e "${RED}✗ Frontend-Prozess wurde beendet${NC}"
|
||||
echo "Letzte 30 Zeilen vom Log:"
|
||||
tail -30 frontend.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Prüfe ob Port offen ist
|
||||
if curl -s http://127.0.0.1:3000 > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ Frontend läuft auf Port 3000${NC}"
|
||||
return 0
|
||||
fi
|
||||
echo -n "."
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e "${RED}✗ Frontend konnte nicht gestartet werden${NC}"
|
||||
echo "Letzte 30 Zeilen vom Log:"
|
||||
tail -30 frontend.log
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Start Frontend
|
||||
echo "🚀 Starting Frontend on port 3000..."
|
||||
cd frontend
|
||||
npm run dev &
|
||||
FRONTEND_PID=$!
|
||||
cd ..
|
||||
# Funktion für Status-Anzeige
|
||||
show_status() {
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo -e "${GREEN}✓ Pounce erfolgreich gestartet!${NC}"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "URLs:"
|
||||
echo " Backend: http://127.0.0.1:8000"
|
||||
echo " Frontend: http://127.0.0.1:3000"
|
||||
echo " Health: http://127.0.0.1:8000/health"
|
||||
echo ""
|
||||
echo "Logs:"
|
||||
echo " Backend: tail -f $BACKEND_DIR/backend.log"
|
||||
echo " Frontend: tail -f $FRONTEND_DIR/frontend.log"
|
||||
echo ""
|
||||
echo "Laufende Prozesse:"
|
||||
ps aux | grep -E "(uvicorn|next start)" | grep -v grep | awk '{print " PID " $2 ": " $11 " " $12 " " $13}'
|
||||
echo ""
|
||||
echo "Ports:"
|
||||
lsof -i:8000 -i:3000 2>/dev/null | grep LISTEN || echo " Keine Port-Info verfügbar"
|
||||
echo ""
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo " POUNCE is starting..."
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo " Backend: http://localhost:8000"
|
||||
echo " Frontend: http://localhost:3000"
|
||||
echo " API Docs: http://localhost:8000/docs"
|
||||
echo ""
|
||||
echo " Press Ctrl+C to stop all services"
|
||||
echo ""
|
||||
# Funktion zum Testen der Services
|
||||
test_services() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}🧪 Teste Services...${NC}"
|
||||
|
||||
# Test Backend Health
|
||||
HEALTH=$(curl -s http://127.0.0.1:8000/health | grep -o '"status":"healthy"' || echo "")
|
||||
if [ -n "$HEALTH" ]; then
|
||||
echo -e "${GREEN}✓ Backend Health Check OK${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Backend Health Check FAILED${NC}"
|
||||
fi
|
||||
|
||||
# Test Frontend
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000)
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo -e "${GREEN}✓ Frontend HTTP 200 OK${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Frontend HTTP $HTTP_CODE${NC}"
|
||||
fi
|
||||
|
||||
# Test OAuth Providers
|
||||
OAUTH=$(curl -s http://127.0.0.1:8000/api/v1/oauth/providers | grep -o '"google_enabled":true' || echo "")
|
||||
if [ -n "$OAUTH" ]; then
|
||||
echo -e "${GREEN}✓ OAuth Providers OK${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ OAuth Check konnte nicht durchgeführt werden${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Wait for both processes
|
||||
trap "kill $BACKEND_PID $FRONTEND_PID 2>/dev/null" EXIT
|
||||
wait
|
||||
# Main
|
||||
stop_services
|
||||
start_backend
|
||||
start_frontend
|
||||
test_services
|
||||
show_status
|
||||
|
||||
echo -e "${GREEN}🎉 Alles läuft!${NC}"
|
||||
echo ""
|
||||
echo "Zum Stoppen: pkill -f 'uvicorn' && pkill -f 'next start'"
|
||||
|
||||
Reference in New Issue
Block a user