Compare commits
42 Commits
a58db843e0
...
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 |
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
|
||||
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**! 🚀
|
||||
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"])
|
||||
|
||||
|
||||
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.",
|
||||
}
|
||||
|
||||
@ -203,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)
|
||||
|
||||
@ -312,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}>"
|
||||
|
||||
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}>"
|
||||
|
||||
@ -60,6 +60,14 @@ class User(Base):
|
||||
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()
|
||||
|
||||
@ -316,6 +321,163 @@ 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,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
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">
|
||||
Curated Opportunities
|
||||
{/* 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"
|
||||
)}
|
||||
>
|
||||
Join the Hunt
|
||||
<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>
|
||||
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,419 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import { Toast, useToast } from '@/components/Toast'
|
||||
import {
|
||||
Eye,
|
||||
Briefcase,
|
||||
TrendingUp,
|
||||
Gavel,
|
||||
Clock,
|
||||
Bell,
|
||||
ArrowRight,
|
||||
ExternalLink,
|
||||
Sparkles,
|
||||
ChevronRight,
|
||||
Search,
|
||||
Plus,
|
||||
Zap,
|
||||
Crown,
|
||||
Activity,
|
||||
} 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 router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
checkAuth,
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [checkAuth])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router])
|
||||
|
||||
// Check for upgrade success
|
||||
useEffect(() => {
|
||||
if (searchParams.get('upgraded') === 'true') {
|
||||
showToast('Welcome to your upgraded plan! 🎉', 'success')
|
||||
window.history.replaceState({}, '', '/dashboard')
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// Load dashboard data
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadDashboardData()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
const loadDashboardData = 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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickAdd = 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)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
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
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title={`Welcome back${user?.name ? `, ${user.name.split(' ')[0]}` : ''}`}
|
||||
subtitle="Your domain command center"
|
||||
>
|
||||
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
||||
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
{/* Quick Add */}
|
||||
<div className="p-6 bg-gradient-to-r from-accent/10 to-transparent border border-accent/20 rounded-2xl">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<Search className="w-5 h-5 text-accent" />
|
||||
Quick Add to Watchlist
|
||||
</h2>
|
||||
<form onSubmit={handleQuickAdd} className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={quickDomain}
|
||||
onChange={(e) => setQuickDomain(e.target.value)}
|
||||
placeholder="Enter domain to track (e.g., dream.com)"
|
||||
className="flex-1 h-12 px-4 bg-background border border-border rounded-xl
|
||||
text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addingDomain || !quickDomain.trim()}
|
||||
className="flex items-center gap-2 h-12 px-6 bg-accent text-background rounded-xl
|
||||
font-medium hover:bg-accent-hover transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Link
|
||||
href="/watchlist"
|
||||
className="group p-5 bg-background-secondary/50 border border-border rounded-xl
|
||||
hover:border-foreground/20 transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="w-10 h-10 bg-foreground/5 rounded-xl flex items-center justify-center">
|
||||
<Eye className="w-5 h-5 text-foreground-muted" />
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-foreground
|
||||
group-hover:translate-x-0.5 transition-all" />
|
||||
</div>
|
||||
<p className="text-2xl font-display text-foreground">{totalDomains}</p>
|
||||
<p className="text-sm text-foreground-muted">Domains Watched</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/watchlist?filter=available"
|
||||
className={clsx(
|
||||
"group p-5 border rounded-xl transition-all",
|
||||
availableDomains.length > 0
|
||||
? "bg-accent/10 border-accent/20 hover:border-accent/40"
|
||||
: "bg-background-secondary/50 border-border hover:border-foreground/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className={clsx(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center",
|
||||
availableDomains.length > 0 ? "bg-accent/20" : "bg-foreground/5"
|
||||
)}>
|
||||
<Sparkles className={clsx(
|
||||
"w-5 h-5",
|
||||
availableDomains.length > 0 ? "text-accent" : "text-foreground-muted"
|
||||
)} />
|
||||
</div>
|
||||
{availableDomains.length > 0 && (
|
||||
<span className="px-2 py-0.5 bg-accent text-background text-xs font-semibold rounded-full animate-pulse">
|
||||
Action!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={clsx(
|
||||
"text-2xl font-display",
|
||||
availableDomains.length > 0 ? "text-accent" : "text-foreground"
|
||||
)}>
|
||||
{availableDomains.length}
|
||||
</p>
|
||||
<p className="text-sm text-foreground-muted">Available Now</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/portfolio"
|
||||
className="group p-5 bg-background-secondary/50 border border-border rounded-xl
|
||||
hover:border-foreground/20 transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="w-10 h-10 bg-foreground/5 rounded-xl flex items-center justify-center">
|
||||
<Briefcase className="w-5 h-5 text-foreground-muted" />
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-foreground
|
||||
group-hover:translate-x-0.5 transition-all" />
|
||||
</div>
|
||||
<p className="text-2xl font-display text-foreground">0</p>
|
||||
<p className="text-sm text-foreground-muted">Portfolio Domains</p>
|
||||
</Link>
|
||||
|
||||
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="w-10 h-10 bg-accent/10 rounded-xl flex items-center justify-center">
|
||||
<TierIcon className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-display text-foreground">{tierName}</p>
|
||||
<p className="text-sm text-foreground-muted">
|
||||
{subscription?.domains_used || 0}/{subscription?.domain_limit || 5} slots used
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity Feed + Market Pulse */}
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
{/* Activity Feed */}
|
||||
<div className="p-6 bg-background-secondary/50 border border-border rounded-2xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-accent" />
|
||||
Activity Feed
|
||||
</h2>
|
||||
<Link href="/watchlist" className="text-sm text-accent hover:underline">
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
|
||||
{/* Market Pulse */}
|
||||
<div className="p-6 bg-background-secondary/50 border border-border rounded-2xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Gavel className="w-5 h-5 text-accent" />
|
||||
Market Pulse
|
||||
</h2>
|
||||
<Link href="/market" className="text-sm text-accent hover:underline">
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
|
||||
{/* Trending TLDs */}
|
||||
<div className="p-6 bg-background-secondary/50 border border-border rounded-2xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-accent" />
|
||||
Trending TLDs
|
||||
</h2>
|
||||
<Link href="/intelligence" className="text-sm text-accent hover:underline">
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
) : (
|
||||
<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 p-4 bg-background border border-border rounded-xl
|
||||
hover:border-foreground/20 transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-mono text-xl font-semibold text-foreground">.{tld.tld}</span>
|
||||
<span className={clsx(
|
||||
"text-xs font-semibold px-2 py-0.5 rounded-full",
|
||||
(tld.price_change || 0) > 0
|
||||
? "text-orange-400 bg-orange-400/10"
|
||||
: "text-accent bg-accent/10"
|
||||
)}>
|
||||
{(tld.price_change || 0) > 0 ? '+' : ''}{(tld.price_change || 0).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-muted truncate">{tld.reason}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
261
frontend/src/app/intelligence/page.tsx
Executable file → Normal file
261
frontend/src/app/intelligence/page.tsx
Executable file → Normal file
@ -1,257 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import {
|
||||
Search,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
ArrowUpDown,
|
||||
ExternalLink,
|
||||
BarChart3,
|
||||
DollarSign,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface TLDData {
|
||||
tld: string
|
||||
min_price: number
|
||||
avg_price: number
|
||||
max_price: number
|
||||
cheapest_registrar: string
|
||||
cheapest_registrar_url?: string
|
||||
price_change_7d?: number
|
||||
popularity_rank?: number
|
||||
}
|
||||
|
||||
export default function IntelligencePage() {
|
||||
const { subscription } = useStore()
|
||||
|
||||
const [tldData, setTldData] = useState<TLDData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState<'popularity' | 'price_asc' | 'price_desc' | 'change'>('popularity')
|
||||
const [page, setPage] = useState(0)
|
||||
const [total, setTotal] = useState(0)
|
||||
/**
|
||||
* Redirect /intelligence to /tld-pricing
|
||||
* This page is kept for backwards compatibility
|
||||
*/
|
||||
export default function IntelligenceRedirect() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
loadTLDData()
|
||||
}, [page, sortBy])
|
||||
|
||||
const loadTLDData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await api.getTldPrices({
|
||||
limit: 50,
|
||||
offset: page * 50,
|
||||
sort_by: sortBy,
|
||||
})
|
||||
setTldData(response.tlds || [])
|
||||
setTotal(response.total || 0)
|
||||
} catch (error) {
|
||||
console.error('Failed to load TLD data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
const filteredData = tldData.filter(tld =>
|
||||
tld.tld.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const getTrendIcon = (change: number | undefined) => {
|
||||
if (!change) return <Minus className="w-4 h-4 text-foreground-muted" />
|
||||
if (change > 0) return <TrendingUp className="w-4 h-4 text-orange-400" />
|
||||
return <TrendingDown className="w-4 h-4 text-accent" />
|
||||
}
|
||||
router.replace('/tld-pricing')
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="TLD Intelligence"
|
||||
subtitle={`Real-time pricing data for ${total}+ TLDs`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-accent/10 rounded-xl flex items-center justify-center">
|
||||
<Globe className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-display text-foreground">{total}+</p>
|
||||
<p className="text-sm text-foreground-muted">TLDs Tracked</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-accent/10 rounded-xl flex items-center justify-center">
|
||||
<DollarSign className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-display text-foreground">$0.99</p>
|
||||
<p className="text-sm text-foreground-muted">Lowest Price</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-orange-400/10 rounded-xl flex items-center justify-center">
|
||||
<TrendingUp className="w-5 h-5 text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-display text-foreground">.ai</p>
|
||||
<p className="text-sm text-foreground-muted">Hottest TLD</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-foreground/5 rounded-xl flex items-center justify-center">
|
||||
<BarChart3 className="w-5 h-5 text-foreground-muted" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-display text-foreground">24h</p>
|
||||
<p className="text-sm text-foreground-muted">Update Frequency</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<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 TLDs..."
|
||||
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>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
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="popularity">By Popularity</option>
|
||||
<option value="price_asc">Price: Low to High</option>
|
||||
<option value="price_desc">Price: High to Low</option>
|
||||
<option value="change">By Change %</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>
|
||||
|
||||
{/* TLD Table */}
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<div key={i} className="h-16 bg-background-secondary/50 border border-border rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden border border-border rounded-xl">
|
||||
{/* Table Header */}
|
||||
<div className="hidden lg:grid lg:grid-cols-12 gap-4 p-4 bg-background-secondary/50 border-b border-border text-sm text-foreground-muted font-medium">
|
||||
<div className="col-span-2">TLD</div>
|
||||
<div className="col-span-2">Min Price</div>
|
||||
<div className="col-span-2">Avg Price</div>
|
||||
<div className="col-span-2">Change</div>
|
||||
<div className="col-span-3">Cheapest Registrar</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
|
||||
{/* Table Rows */}
|
||||
<div className="divide-y divide-border">
|
||||
{filteredData.map((tld) => (
|
||||
<Link
|
||||
key={tld.tld}
|
||||
href={`/tld-pricing/${tld.tld}`}
|
||||
className="block lg:grid lg:grid-cols-12 gap-4 p-4 hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
{/* TLD */}
|
||||
<div className="col-span-2 flex items-center gap-3 mb-3 lg:mb-0">
|
||||
<span className="font-mono text-xl font-semibold text-foreground">.{tld.tld}</span>
|
||||
</div>
|
||||
|
||||
{/* Min Price */}
|
||||
<div className="col-span-2 flex items-center">
|
||||
<span className="text-foreground font-medium">${tld.min_price.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
{/* Avg Price */}
|
||||
<div className="col-span-2 flex items-center">
|
||||
<span className="text-foreground-muted">${tld.avg_price.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
{/* Change */}
|
||||
<div className="col-span-2 flex items-center gap-2">
|
||||
{getTrendIcon(tld.price_change_7d)}
|
||||
<span className={clsx(
|
||||
"font-medium",
|
||||
(tld.price_change_7d || 0) > 0 ? "text-orange-400" :
|
||||
(tld.price_change_7d || 0) < 0 ? "text-accent" : "text-foreground-muted"
|
||||
)}>
|
||||
{(tld.price_change_7d || 0) > 0 ? '+' : ''}{(tld.price_change_7d || 0).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Registrar */}
|
||||
<div className="col-span-3 flex items-center">
|
||||
<span className="text-foreground-muted truncate">{tld.cheapest_registrar}</span>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="col-span-1 flex items-center justify-end">
|
||||
<ChevronRight className="w-5 h-5 text-foreground-subtle" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{total > 50 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-4 py-2 text-sm text-foreground-muted hover:text-foreground
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-foreground-muted">
|
||||
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 text-foreground-muted hover:text-foreground
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-foreground-muted">Redirecting to TLD Pricing...</p>
|
||||
</div>
|
||||
</CommandCenterLayout>
|
||||
</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(() => {
|
||||
@ -88,6 +97,9 @@ function LoginForm() {
|
||||
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) {
|
||||
@ -113,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'
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -30,6 +30,9 @@ import {
|
||||
Lock,
|
||||
Filter,
|
||||
Crosshair,
|
||||
Tag,
|
||||
AlertTriangle,
|
||||
Briefcase,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
@ -181,18 +184,18 @@ export default function HomePage() {
|
||||
<Header />
|
||||
|
||||
{/* Hero Section - "Bloomberg meets Apple" */}
|
||||
<section className="relative pt-32 sm:pt-40 md:pt-48 pb-16 sm:pb-20 px-4 sm:px-6">
|
||||
<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 */}
|
||||
@ -200,49 +203,53 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Headline - Konzept: "Der Markt schläft nie. Du schon." */}
|
||||
{/* Main Headline - kompakter */}
|
||||
<h1 className="animate-slide-up">
|
||||
<span className="block font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] lg:text-[5.5rem] xl:text-[6.5rem] leading-[0.95] tracking-[-0.04em] text-foreground">
|
||||
<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-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] lg:text-[5.5rem] xl:text-[6.5rem] leading-[0.95] tracking-[-0.04em] text-foreground/30 mt-1">
|
||||
<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 - Konzept Versprechen */}
|
||||
<p className="mt-8 sm:mt-10 text-lg sm:text-xl md:text-2xl text-foreground-muted max-w-2xl mx-auto animate-slide-up delay-100 leading-relaxed">
|
||||
{/* 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>
|
||||
|
||||
{/* Tagline */}
|
||||
<p className="mt-4 text-base sm:text-lg text-accent font-medium animate-slide-up delay-150">
|
||||
<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 */}
|
||||
<div className="mt-10 sm:mt-12 animate-slide-up delay-200">
|
||||
<DomainChecker />
|
||||
{/* 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-10 sm:mt-12 flex flex-wrap items-center justify-center gap-6 sm:gap-10 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</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">
|
||||
<Gavel className="w-4 h-4 text-accent" />
|
||||
<span className="text-sm font-medium">Live Auctions</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-2">
|
||||
<LineChart className="w-4 h-4 text-accent" />
|
||||
<span className="text-sm font-medium">Price Intel</span>
|
||||
<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>
|
||||
@ -315,21 +322,21 @@ export default function HomePage() {
|
||||
</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. We monitor 24/7 so you don't have to.
|
||||
<span className="text-foreground"> Know the second it drops.</span>
|
||||
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>Daily status checks</span>
|
||||
<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>Email & SMS alerts</span>
|
||||
<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>Pre-drop warnings</span>
|
||||
<span>Parked & pre-drop detection</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -369,25 +376,216 @@ export default function HomePage() {
|
||||
</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 bg-background-secondary/30">
|
||||
<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>
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">TLD Intelligence</span>
|
||||
<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">
|
||||
Market movers.
|
||||
The <span className="text-accent">real</span> price tag.
|
||||
</h2>
|
||||
<p className="mt-3 text-foreground-muted max-w-lg">
|
||||
Real-time pricing data across 886+ extensions. Know where the value is.
|
||||
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>
|
||||
@ -574,7 +772,7 @@ export default function HomePage() {
|
||||
<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"}
|
||||
@ -595,7 +793,7 @@ export default function HomePage() {
|
||||
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)]"
|
||||
|
||||
@ -1,529 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api, PortfolioDomain, PortfolioSummary, DomainValuation } from '@/lib/api'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import { Toast, useToast } from '@/components/Toast'
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Edit2,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
Building,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Tag,
|
||||
ExternalLink,
|
||||
Sparkles,
|
||||
ArrowUpRight,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
|
||||
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)
|
||||
|
||||
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: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadPortfolio()
|
||||
}, [])
|
||||
|
||||
const loadPortfolio = 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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddDomain = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!addForm.domain.trim()) return
|
||||
|
||||
setAddingDomain(true)
|
||||
try {
|
||||
await api.addToPortfolio({
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditDomain = 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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSellDomain = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedDomain || !sellForm.sale_price) return
|
||||
|
||||
setProcessingSale(true)
|
||||
try {
|
||||
await api.markAsSold(selectedDomain.id, {
|
||||
sale_date: sellForm.sale_date,
|
||||
sale_price: 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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleValuate = async (domain: PortfolioDomain) => {
|
||||
setValuatingDomain(domain.domain)
|
||||
setShowValuationModal(true)
|
||||
try {
|
||||
const result = await api.getValuation(domain.domain)
|
||||
setValuation(result)
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Failed to get valuation', 'error')
|
||||
setShowValuationModal(false)
|
||||
} finally {
|
||||
setValuatingDomain('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = async (domain: PortfolioDomain) => {
|
||||
setRefreshingId(domain.id)
|
||||
try {
|
||||
await api.refreshPortfolioValuation(domain.id)
|
||||
showToast('Valuation refreshed', 'success')
|
||||
loadPortfolio()
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Failed to refresh', 'error')
|
||||
} finally {
|
||||
setRefreshingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (domain: PortfolioDomain) => {
|
||||
if (!confirm(`Remove ${domain.domain} from your portfolio?`)) return
|
||||
|
||||
try {
|
||||
await api.removeFromPortfolio(domain.id)
|
||||
showToast(`Removed ${domain.domain}`, 'success')
|
||||
loadPortfolio()
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Failed to remove', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const openEditModal = (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 = (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
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="Portfolio"
|
||||
subtitle={`Track your domain investments`}
|
||||
actions={
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
disabled={!canAddMore}
|
||||
className="flex items-center gap-2 h-9 px-4 bg-accent text-background rounded-lg
|
||||
font-medium text-sm hover:bg-accent-hover transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Domain
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
||||
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Summary Stats */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<p className="text-sm text-foreground-muted mb-1">Total Domains</p>
|
||||
<p className="text-2xl font-display text-foreground">{summary.total_domains}</p>
|
||||
</div>
|
||||
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<p className="text-sm text-foreground-muted mb-1">Total Invested</p>
|
||||
<p className="text-2xl font-display text-foreground">${summary.total_invested?.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<p className="text-sm text-foreground-muted mb-1">Est. Value</p>
|
||||
<p className="text-2xl font-display text-foreground">${summary.total_value?.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className={clsx(
|
||||
"p-5 border rounded-xl",
|
||||
(summary.total_profit || 0) >= 0
|
||||
? "bg-accent/5 border-accent/20"
|
||||
: "bg-red-500/5 border-red-500/20"
|
||||
)}>
|
||||
<p className="text-sm text-foreground-muted mb-1">Profit/Loss</p>
|
||||
<p className={clsx(
|
||||
"text-2xl font-display",
|
||||
(summary.total_profit || 0) >= 0 ? "text-accent" : "text-red-400"
|
||||
)}>
|
||||
{(summary.total_profit || 0) >= 0 ? '+' : ''}${summary.total_profit?.toLocaleString() || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<p className="text-sm text-foreground-muted mb-1">Sold</p>
|
||||
<p className="text-2xl font-display text-foreground">{summary.sold_domains || 0}</p>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Domain List */}
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-24 bg-background-secondary/50 border border-border rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : portfolio.length === 0 ? (
|
||||
<div className="text-center py-16 bg-background-secondary/30 border border-border rounded-xl">
|
||||
<div className="w-16 h-16 bg-foreground/5 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Plus className="w-8 h-8 text-foreground-subtle" />
|
||||
</div>
|
||||
<p className="text-foreground-muted mb-2">Your portfolio is empty</p>
|
||||
<p className="text-sm text-foreground-subtle mb-4">Add your first domain to start tracking investments</p>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-accent text-background rounded-lg font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Domain
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{portfolio.map((domain) => (
|
||||
<div
|
||||
key={domain.id}
|
||||
className="group p-5 bg-background-secondary/50 border border-border rounded-xl
|
||||
hover:border-foreground/20 transition-all"
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-4">
|
||||
{/* Domain Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">{domain.domain}</h3>
|
||||
<div className="flex flex-wrap gap-4 text-sm text-foreground-muted">
|
||||
{domain.purchase_price && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<DollarSign className="w-3.5 h-3.5" />
|
||||
Bought: ${domain.purchase_price}
|
||||
</span>
|
||||
)}
|
||||
{domain.registrar && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Building className="w-3.5 h-3.5" />
|
||||
{domain.registrar}
|
||||
</span>
|
||||
)}
|
||||
{domain.renewal_date && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
Renews: {new Date(domain.renewal_date).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Valuation */}
|
||||
{domain.current_valuation && (
|
||||
<div className="text-right">
|
||||
<p className="text-xl font-semibold text-foreground">
|
||||
${domain.current_valuation.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-foreground-subtle">Est. Value</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleValuate(domain)}
|
||||
className="p-2 text-foreground-muted hover:text-accent hover:bg-accent/10 rounded-lg transition-colors"
|
||||
title="Get valuation"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRefresh(domain)}
|
||||
disabled={refreshingId === domain.id}
|
||||
className="p-2 text-foreground-muted hover:bg-foreground/5 rounded-lg transition-colors"
|
||||
title="Refresh valuation"
|
||||
>
|
||||
<RefreshCw className={clsx("w-4 h-4", refreshingId === domain.id && "animate-spin")} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(domain)}
|
||||
className="p-2 text-foreground-muted hover:bg-foreground/5 rounded-lg transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openSellModal(domain)}
|
||||
className="px-3 py-2 text-sm font-medium text-accent hover:bg-accent/10 rounded-lg transition-colors"
|
||||
>
|
||||
Sell
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(domain)}
|
||||
className="p-2 text-foreground-muted hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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">Domain *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={addForm.domain}
|
||||
onChange={(e) => setAddForm({ ...addForm, domain: e.target.value })}
|
||||
placeholder="example.com"
|
||||
className="w-full h-10 px-3 bg-background border border-border rounded-lg text-foreground"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">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-10 px-3 bg-background border border-border rounded-lg text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Purchase Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={addForm.purchase_date}
|
||||
onChange={(e) => setAddForm({ ...addForm, purchase_date: e.target.value })}
|
||||
className="w-full h-10 px-3 bg-background border border-border rounded-lg text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-foreground-muted mb-1">Registrar</label>
|
||||
<input
|
||||
type="text"
|
||||
value={addForm.registrar}
|
||||
onChange={(e) => setAddForm({ ...addForm, registrar: e.target.value })}
|
||||
placeholder="Namecheap"
|
||||
className="w-full h-10 px-3 bg-background border border-border rounded-lg text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="px-4 py-2 text-foreground-muted hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addingDomain || !addForm.domain.trim()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-background rounded-lg font-medium
|
||||
disabled:opacity-50"
|
||||
>
|
||||
{addingDomain && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Add Domain
|
||||
</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-8">
|
||||
<Loader2 className="w-6 h-6 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-display text-accent">${valuation.estimated_value.toLocaleString()}</p>
|
||||
<p className="text-sm text-foreground-muted mt-1">Estimated Value</p>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground-muted">Confidence</span>
|
||||
<span className="text-foreground capitalize">{valuation.confidence}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground-muted">Formula</span>
|
||||
<span className="text-foreground font-mono text-xs">{valuation.valuation_formula}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
)}
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Simple 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 rounded-2xl shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
<button onClick={onClose} className="text-foreground-muted hover:text-foreground">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</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'
|
||||
|
||||
@ -23,8 +23,9 @@ const tiers = [
|
||||
{ text: 'Daily availability scans', highlight: false, available: true },
|
||||
{ text: 'Email alerts', highlight: false, available: true },
|
||||
{ text: 'Raw auction feed', highlight: false, available: true, sublabel: 'Unfiltered' },
|
||||
{ text: 'Curated auction list', highlight: false, available: false },
|
||||
{ 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: 'Start Free',
|
||||
highlighted: false,
|
||||
@ -43,8 +44,9 @@ const tiers = [
|
||||
{ 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: '90-day price history', highlight: false, available: true },
|
||||
{ text: 'Expiry date tracking', highlight: true, available: true },
|
||||
],
|
||||
cta: 'Upgrade to Trader',
|
||||
@ -62,10 +64,11 @@ const tiers = [
|
||||
features: [
|
||||
{ text: '500 domains to track', highlight: true, available: true },
|
||||
{ text: 'Real-time scans', highlight: true, available: true, sublabel: 'Every 10 min' },
|
||||
{ text: 'Priority alerts', highlight: true, available: true },
|
||||
{ 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',
|
||||
@ -80,8 +83,10 @@ const comparisonFeatures = [
|
||||
{ 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: 'Advanced' },
|
||||
{ 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' },
|
||||
]
|
||||
@ -114,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) => {
|
||||
@ -126,7 +142,7 @@ export default function PricingPage() {
|
||||
}
|
||||
|
||||
if (!isPaid) {
|
||||
router.push('/dashboard')
|
||||
router.push('/command/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
@ -134,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) {
|
||||
@ -163,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>
|
||||
@ -359,7 +395,7 @@ 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 ? "Command Center" : "Join the Hunt"}
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -1,719 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
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 (
|
||||
<CommandCenterLayout
|
||||
title="Settings"
|
||||
subtitle="Manage your account"
|
||||
>
|
||||
|
||||
<main className="max-w-5xl mx-auto">
|
||||
<div className="space-y-8">
|
||||
|
||||
{/* 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>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -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,246 +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) => {
|
||||
// Show full data for authenticated users OR for the first row (idx 0 on first page)
|
||||
// This lets visitors see how good the data is for .com before signing up
|
||||
const showFullData = isAuthenticated || (pagination.offset === 0 && idx === 0)
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={tld.tld}
|
||||
className={clsx(
|
||||
"hover:bg-background-secondary/50 transition-colors group",
|
||||
!isAuthenticated && idx === 0 && pagination.offset === 0 && "bg-accent/5"
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
{!isAuthenticated && idx === 0 && pagination.offset === 0 && (
|
||||
<span className="ml-2 text-xs text-accent">Preview</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={showFullData} />
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
{showFullData ? (
|
||||
<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">
|
||||
{showFullData ? (
|
||||
<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">
|
||||
{showFullData ? 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}` : `/login?redirect=/tld-pricing/${tld.tld}`}
|
||||
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>
|
||||
|
||||
@ -1,494 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import { Toast, useToast } from '@/components/Toast'
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Bell,
|
||||
BellOff,
|
||||
History,
|
||||
ExternalLink,
|
||||
MoreVertical,
|
||||
Search,
|
||||
Filter,
|
||||
ArrowUpRight,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface DomainHistory {
|
||||
id: number
|
||||
status: string
|
||||
is_available: boolean
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
// Status indicator component with traffic light system
|
||||
function StatusIndicator({ domain }: { domain: any }) {
|
||||
// Determine status based on domain data
|
||||
let status: 'available' | 'watching' | 'stable' = 'stable'
|
||||
let label = 'Stable'
|
||||
let description = 'Domain is registered and active'
|
||||
|
||||
if (domain.is_available) {
|
||||
status = 'available'
|
||||
label = 'Available'
|
||||
description = 'Domain is available for registration!'
|
||||
} else if (domain.status === 'checking' || domain.status === 'pending') {
|
||||
status = 'watching'
|
||||
label = 'Watching'
|
||||
description = 'Monitoring for changes'
|
||||
}
|
||||
|
||||
const colors = {
|
||||
available: 'bg-accent text-accent',
|
||||
watching: 'bg-amber-400 text-amber-400',
|
||||
stable: 'bg-foreground-muted text-foreground-muted',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<span className={clsx(
|
||||
"block w-3 h-3 rounded-full",
|
||||
colors[status].split(' ')[0]
|
||||
)} />
|
||||
{status === 'available' && (
|
||||
<span className={clsx(
|
||||
"absolute inset-0 rounded-full animate-ping opacity-75",
|
||||
colors[status].split(' ')[0]
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className={clsx(
|
||||
"text-sm font-medium",
|
||||
status === 'available' ? 'text-accent' :
|
||||
status === 'watching' ? 'text-amber-400' : 'text-foreground-muted'
|
||||
)}>
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-xs text-foreground-subtle hidden sm:block">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 [selectedDomainId, setSelectedDomainId] = useState<number | null>(null)
|
||||
const [domainHistory, setDomainHistory] = useState<DomainHistory[] | null>(null)
|
||||
const [loadingHistory, setLoadingHistory] = useState(false)
|
||||
const [togglingNotifyId, setTogglingNotifyId] = useState<number | null>(null)
|
||||
const [filterStatus, setFilterStatus] = useState<'all' | 'available' | 'watching'>('all')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Filter domains
|
||||
const filteredDomains = domains?.filter(domain => {
|
||||
// Search filter
|
||||
if (searchQuery && !domain.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
// Status filter
|
||||
if (filterStatus === 'available' && !domain.is_available) return false
|
||||
if (filterStatus === 'watching' && domain.is_available) return false
|
||||
return true
|
||||
}) || []
|
||||
|
||||
// Stats
|
||||
const availableCount = domains?.filter(d => d.is_available).length || 0
|
||||
const watchingCount = domains?.filter(d => !d.is_available).length || 0
|
||||
|
||||
const handleAddDomain = 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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = 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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = 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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleNotify = 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)
|
||||
}
|
||||
}
|
||||
|
||||
const loadHistory = async (domainId: number) => {
|
||||
if (selectedDomainId === domainId) {
|
||||
setSelectedDomainId(null)
|
||||
setDomainHistory(null)
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedDomainId(domainId)
|
||||
setLoadingHistory(true)
|
||||
try {
|
||||
const history = await api.getDomainHistory(domainId)
|
||||
setDomainHistory(history)
|
||||
} catch (err) {
|
||||
setDomainHistory([])
|
||||
} finally {
|
||||
setLoadingHistory(false)
|
||||
}
|
||||
}
|
||||
|
||||
const domainLimit = subscription?.domain_limit || 5
|
||||
const domainsUsed = domains?.length || 0
|
||||
const canAddMore = domainsUsed < domainLimit
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="Watchlist"
|
||||
subtitle={`${domainsUsed}/${domainLimit} domains tracked`}
|
||||
>
|
||||
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
||||
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="p-4 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<p className="text-sm text-foreground-muted mb-1">Total Watched</p>
|
||||
<p className="text-2xl font-display text-foreground">{domainsUsed}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-accent/5 border border-accent/20 rounded-xl">
|
||||
<p className="text-sm text-foreground-muted mb-1">Available</p>
|
||||
<p className="text-2xl font-display text-accent">{availableCount}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<p className="text-sm text-foreground-muted mb-1">Watching</p>
|
||||
<p className="text-2xl font-display text-foreground">{watchingCount}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<p className="text-sm text-foreground-muted mb-1">Limit</p>
|
||||
<p className="text-2xl font-display text-foreground">{domainLimit === -1 ? '∞' : domainLimit}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Domain Form */}
|
||||
<form onSubmit={handleAddDomain} className="flex gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
placeholder="Enter domain to track (e.g., dream.com)"
|
||||
disabled={!canAddMore}
|
||||
className={clsx(
|
||||
"w-full h-12 px-4 bg-background-secondary border border-border rounded-xl",
|
||||
"text-foreground placeholder:text-foreground-subtle",
|
||||
"focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={adding || !newDomain.trim() || !canAddMore}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 h-12 px-6 rounded-xl font-medium transition-all",
|
||||
"bg-accent text-background hover:bg-accent-hover",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{adding ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4" />
|
||||
)}
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{!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 */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilterStatus('all')}
|
||||
className={clsx(
|
||||
"px-4 py-2 text-sm rounded-lg transition-colors",
|
||||
filterStatus === 'all'
|
||||
? "bg-foreground/10 text-foreground"
|
||||
: "text-foreground-muted hover:bg-foreground/5"
|
||||
)}
|
||||
>
|
||||
All ({domainsUsed})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterStatus('available')}
|
||||
className={clsx(
|
||||
"px-4 py-2 text-sm rounded-lg transition-colors flex items-center gap-2",
|
||||
filterStatus === 'available'
|
||||
? "bg-accent/10 text-accent"
|
||||
: "text-foreground-muted hover:bg-foreground/5"
|
||||
)}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-accent" />
|
||||
Available ({availableCount})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterStatus('watching')}
|
||||
className={clsx(
|
||||
"px-4 py-2 text-sm rounded-lg transition-colors flex items-center gap-2",
|
||||
filterStatus === 'watching'
|
||||
? "bg-foreground/10 text-foreground"
|
||||
: "text-foreground-muted hover:bg-foreground/5"
|
||||
)}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-foreground-muted" />
|
||||
Watching ({watchingCount})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<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 sm:w-64 h-10 pl-9 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>
|
||||
</div>
|
||||
|
||||
{/* Domain List */}
|
||||
<div className="space-y-3">
|
||||
{filteredDomains.length === 0 ? (
|
||||
<div className="text-center py-16 bg-background-secondary/30 border border-border rounded-xl">
|
||||
{domainsUsed === 0 ? (
|
||||
<>
|
||||
<div className="w-16 h-16 bg-foreground/5 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Plus className="w-8 h-8 text-foreground-subtle" />
|
||||
</div>
|
||||
<p className="text-foreground-muted mb-2">Your watchlist is empty</p>
|
||||
<p className="text-sm text-foreground-subtle">Add a domain above to start tracking</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Filter className="w-8 h-8 text-foreground-subtle mx-auto mb-4" />
|
||||
<p className="text-foreground-muted">No domains match your filters</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredDomains.map((domain) => (
|
||||
<div
|
||||
key={domain.id}
|
||||
className={clsx(
|
||||
"group p-4 sm:p-5 rounded-xl border transition-all duration-200",
|
||||
domain.is_available
|
||||
? "bg-accent/5 border-accent/20 hover:border-accent/40"
|
||||
: "bg-background-secondary/50 border-border hover:border-foreground/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
{/* Domain Name + Status */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-foreground truncate">
|
||||
{domain.name}
|
||||
</h3>
|
||||
{domain.is_available && (
|
||||
<span className="shrink-0 px-2 py-0.5 bg-accent/20 text-accent text-xs font-semibold rounded-full">
|
||||
GRAB IT!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<StatusIndicator domain={domain} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{/* Notify Toggle */}
|
||||
<button
|
||||
onClick={() => 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>
|
||||
|
||||
{/* History */}
|
||||
<button
|
||||
onClick={() => loadHistory(domain.id)}
|
||||
className={clsx(
|
||||
"p-2 rounded-lg transition-colors",
|
||||
selectedDomainId === domain.id
|
||||
? "bg-foreground/10 text-foreground"
|
||||
: "text-foreground-muted hover:bg-foreground/5"
|
||||
)}
|
||||
title="View history"
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Refresh */}
|
||||
<button
|
||||
onClick={() => handleRefresh(domain.id)}
|
||||
disabled={refreshingId === domain.id}
|
||||
className="p-2 rounded-lg text-foreground-muted hover:bg-foreground/5 transition-colors"
|
||||
title="Refresh status"
|
||||
>
|
||||
<RefreshCw className={clsx(
|
||||
"w-4 h-4",
|
||||
refreshingId === domain.id && "animate-spin"
|
||||
)} />
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
onClick={() => handleDelete(domain.id, domain.name)}
|
||||
disabled={deletingId === domain.id}
|
||||
className="p-2 rounded-lg text-foreground-muted hover:text-red-400 hover:bg-red-400/10 transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
{deletingId === domain.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* External Link (if available) */}
|
||||
{domain.is_available && (
|
||||
<a
|
||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${domain.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-background rounded-lg
|
||||
font-medium text-sm hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
Register
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History Panel */}
|
||||
{selectedDomainId === domain.id && (
|
||||
<div className="mt-4 pt-4 border-t border-border/50">
|
||||
<h4 className="text-sm font-medium text-foreground-muted mb-3">Status History</h4>
|
||||
{loadingHistory ? (
|
||||
<div className="flex items-center gap-2 text-foreground-muted">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">Acquiring targets...</span>
|
||||
</div>
|
||||
) : domainHistory && domainHistory.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{domainHistory.slice(0, 5).map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center gap-3 text-sm"
|
||||
>
|
||||
<span className={clsx(
|
||||
"w-2 h-2 rounded-full",
|
||||
entry.is_available ? "bg-accent" : "bg-foreground-muted"
|
||||
)} />
|
||||
<span className="text-foreground-muted">
|
||||
{new Date(entry.checked_at).toLocaleDateString()} at{' '}
|
||||
{new Date(entry.checked_at).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{entry.is_available ? 'Available' : 'Registered'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-foreground-subtle">No history available yet</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { Bell, Search, X } from 'lucide-react'
|
||||
import { KeyboardShortcutsProvider, useUserShortcuts } from '@/hooks/useKeyboardShortcuts'
|
||||
import { Bell, Search, X, Command } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@ -28,6 +29,7 @@ export function CommandCenterLayout({
|
||||
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(() => {
|
||||
@ -44,8 +46,12 @@ export function CommandCenterLayout({
|
||||
}
|
||||
}, [mounted])
|
||||
|
||||
// Check auth only once on mount
|
||||
useEffect(() => {
|
||||
if (!authCheckedRef.current) {
|
||||
authCheckedRef.current = true
|
||||
checkAuth()
|
||||
}
|
||||
}, [checkAuth])
|
||||
|
||||
useEffect(() => {
|
||||
@ -75,6 +81,8 @@ export function CommandCenterLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardShortcutsProvider>
|
||||
<UserShortcutsWrapper />
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Background Effects */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
@ -93,37 +101,37 @@ export function CommandCenterLayout({
|
||||
className={clsx(
|
||||
"relative min-h-screen transition-all duration-300",
|
||||
// Desktop: adjust for sidebar
|
||||
"lg:ml-[240px]",
|
||||
"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 h-16 sm:h-20 bg-background/80 backdrop-blur-xl border-b border-border/50">
|
||||
<div className="h-full px-4 sm:px-6 flex items-center justify-between">
|
||||
<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">
|
||||
<div className="ml-10 lg:ml-0 min-w-0 flex-1">
|
||||
{title && (
|
||||
<h1 className="text-lg sm:text-xl lg:text-2xl font-display text-foreground">{title}</h1>
|
||||
<h1 className="text-xl sm:text-2xl font-semibold tracking-tight text-foreground truncate">{title}</h1>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-xs sm:text-sm text-foreground-muted mt-0.5 hidden sm:block">{subtitle}</p>
|
||||
<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">
|
||||
<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-4 bg-foreground/5 hover:bg-foreground/10
|
||||
border border-border/50 rounded-lg text-sm text-foreground-muted
|
||||
hover:text-foreground transition-all duration-200"
|
||||
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>Search...</span>
|
||||
<kbd className="hidden lg:inline-flex items-center h-5 px-1.5 bg-background border border-border
|
||||
<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>
|
||||
|
||||
@ -133,7 +141,7 @@ export function CommandCenterLayout({
|
||||
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-4.5 h-4.5" />
|
||||
<Search className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Notifications */}
|
||||
@ -147,7 +155,7 @@ export function CommandCenterLayout({
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
)}
|
||||
>
|
||||
<Bell className="w-4.5 h-4.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" />
|
||||
@ -174,7 +182,7 @@ export function CommandCenterLayout({
|
||||
{availableDomains.slice(0, 5).map((domain) => (
|
||||
<Link
|
||||
key={domain.id}
|
||||
href="/watchlist"
|
||||
href="/command/watchlist"
|
||||
onClick={() => setNotificationsOpen(false)}
|
||||
className="flex items-start gap-3 p-3 hover:bg-foreground/5 rounded-lg transition-colors"
|
||||
>
|
||||
@ -202,6 +210,17 @@ export function CommandCenterLayout({
|
||||
)}
|
||||
</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>
|
||||
@ -209,8 +228,10 @@ export function CommandCenterLayout({
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="relative p-4 sm:p-6 lg:p-8">
|
||||
{children}
|
||||
<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>
|
||||
|
||||
@ -253,6 +274,7 @@ export function CommandCenterLayout({
|
||||
{/* Keyboard shortcut for search */}
|
||||
<KeyboardShortcut onTrigger={() => setSearchOpen(true)} keys={['Meta', 'k']} />
|
||||
</div>
|
||||
</KeyboardShortcutsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -271,3 +293,9 @@ function KeyboardShortcut({ onTrigger, keys }: { onTrigger: () => void, keys: st
|
||||
|
||||
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"
|
||||
|
||||
@ -69,7 +69,7 @@ export function Footer() {
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/tld-pricing" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
TLD Intel
|
||||
TLD Pricing
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
@ -79,7 +79,7 @@ export function Footer() {
|
||||
</li>
|
||||
{isAuthenticated ? (
|
||||
<li>
|
||||
<Link href="/dashboard" className="text-body-sm text-accent hover:text-accent-hover transition-colors">
|
||||
<Link href="/command/dashboard" className="text-body-sm text-accent hover:text-accent-hover transition-colors">
|
||||
Command Center
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
Gavel,
|
||||
CreditCard,
|
||||
LayoutDashboard,
|
||||
Tag,
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@ -39,7 +40,8 @@ export function Header() {
|
||||
// Public navigation - same for all visitors
|
||||
const publicNavItems = [
|
||||
{ href: '/auctions', label: 'Auctions', icon: Gavel },
|
||||
{ href: '/tld-pricing', label: 'TLD Intel', icon: TrendingUp },
|
||||
{ href: '/buy', label: 'Marketplace', icon: Tag },
|
||||
{ href: '/tld-pricing', label: 'TLD Pricing', icon: TrendingUp },
|
||||
{ href: '/pricing', label: 'Pricing', icon: CreditCard },
|
||||
]
|
||||
|
||||
@ -49,9 +51,7 @@ export function Header() {
|
||||
}
|
||||
|
||||
// Check if we're on a Command Center page (should use Sidebar instead)
|
||||
const isCommandCenterPage = ['/dashboard', '/watchlist', '/portfolio', '/market', '/intelligence', '/settings', '/admin'].some(
|
||||
path => pathname.startsWith(path)
|
||||
)
|
||||
const isCommandCenterPage = pathname.startsWith('/command') || pathname.startsWith('/admin')
|
||||
|
||||
// If logged in and on Command Center page, don't render this header
|
||||
if (isAuthenticated && isCommandCenterPage) {
|
||||
@ -101,7 +101,7 @@ export function Header() {
|
||||
<>
|
||||
{/* Go to Command Center */}
|
||||
<Link
|
||||
href="/dashboard"
|
||||
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"
|
||||
>
|
||||
@ -164,7 +164,7 @@ export function Header() {
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
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"
|
||||
>
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
343
frontend/src/components/Sidebar.tsx
Normal file → Executable file
343
frontend/src/components/Sidebar.tsx
Normal file → Executable file
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useStore } from '@/lib/store'
|
||||
import {
|
||||
@ -19,6 +20,10 @@ import {
|
||||
CreditCard,
|
||||
Menu,
|
||||
X,
|
||||
Sparkles,
|
||||
Tag,
|
||||
Target,
|
||||
Link2,
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@ -67,99 +72,162 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
// Count available domains for notification badge
|
||||
const availableCount = domains?.filter(d => d.is_available).length || 0
|
||||
|
||||
// Navigation items - renamed "Market" to "Auctions" per review
|
||||
const navItems = [
|
||||
const isTycoon = tierName.toLowerCase() === 'tycoon'
|
||||
|
||||
// SECTION 1: Discover - External market data
|
||||
const discoverItems = [
|
||||
{
|
||||
href: '/dashboard',
|
||||
label: 'Dashboard',
|
||||
icon: LayoutDashboard,
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
href: '/watchlist',
|
||||
label: 'Watchlist',
|
||||
icon: Eye,
|
||||
badge: availableCount || null,
|
||||
},
|
||||
{
|
||||
href: '/portfolio',
|
||||
label: 'Portfolio',
|
||||
icon: Briefcase,
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
href: '/auctions',
|
||||
href: '/command/auctions',
|
||||
label: 'Auctions',
|
||||
icon: Gavel,
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
href: '/intelligence',
|
||||
label: 'Intelligence',
|
||||
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: '/settings', label: 'Settings', icon: Settings },
|
||||
{ href: '/command/settings', label: 'Settings', icon: Settings },
|
||||
]
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/dashboard') return pathname === '/dashboard'
|
||||
if (href === '/command/dashboard') return pathname === '/command/dashboard' || pathname === '/command'
|
||||
return pathname.startsWith(href)
|
||||
}
|
||||
|
||||
const SidebarContent = () => (
|
||||
<>
|
||||
{/* Logo */}
|
||||
{/* Logo Section */}
|
||||
<div className={clsx(
|
||||
"h-16 sm:h-20 flex items-center border-b border-border/50",
|
||||
collapsed ? "justify-center px-2" : "px-5"
|
||||
"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="w-9 h-9 bg-accent/10 rounded-xl flex items-center justify-center border border-accent/20
|
||||
group-hover:bg-accent/20 transition-colors">
|
||||
<span className="font-display text-accent text-lg font-bold">P</span>
|
||||
<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.1em] text-foreground"
|
||||
style={{ fontFamily: 'var(--font-display), Playfair Display, Georgia, serif' }}
|
||||
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-4 px-3 space-y-1 overflow-y-auto">
|
||||
{navItems.map((item) => (
|
||||
<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 flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-200",
|
||||
"group relative flex items-center gap-3 px-3 py-3 rounded-xl transition-all duration-300",
|
||||
isActive(item.href)
|
||||
? "bg-accent/10 text-foreground"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
? "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-colors",
|
||||
isActive(item.href) ? "text-accent" : "group-hover:text-foreground"
|
||||
)} />
|
||||
{/* Badge for notifications */}
|
||||
{item.badge && (
|
||||
<span className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-accent text-background
|
||||
text-[10px] font-bold rounded-full flex items-center justify-center">
|
||||
{item.badge > 9 ? '9+' : item.badge}
|
||||
</span>
|
||||
)}
|
||||
"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(
|
||||
@ -169,25 +237,107 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
{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/50 py-4 px-3 space-y-1">
|
||||
<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 flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-200",
|
||||
"group relative flex items-center gap-3 px-3 py-3 rounded-xl transition-all duration-300",
|
||||
pathname.startsWith('/admin')
|
||||
? "bg-accent/10 text-accent"
|
||||
: "text-accent/70 hover:text-accent hover:bg-accent/5"
|
||||
? "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>
|
||||
@ -200,10 +350,10 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={clsx(
|
||||
"group flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-200",
|
||||
"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"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
? "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}
|
||||
>
|
||||
@ -212,42 +362,70 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* User Info */}
|
||||
{/* User Card */}
|
||||
<div className={clsx(
|
||||
"mt-4 p-3 bg-foreground/5 rounded-xl",
|
||||
collapsed && "p-2"
|
||||
"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-8 h-8 bg-accent/10 rounded-lg flex items-center justify-center">
|
||||
<TierIcon className="w-4 h-4 text-accent" />
|
||||
<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-3">
|
||||
<div className="w-9 h-9 bg-accent/10 rounded-lg flex items-center justify-center">
|
||||
<TierIcon className="w-4 h-4 text-accent" />
|
||||
<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-medium text-foreground truncate">
|
||||
<p className="text-sm font-semibold text-foreground truncate">
|
||||
{user?.name || user?.email?.split('@')[0]}
|
||||
</p>
|
||||
<p className="text-xs text-foreground-muted">{tierName}</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>
|
||||
<div className="flex items-center justify-between text-xs text-foreground-subtle">
|
||||
<span>{subscription?.domains_used || 0}/{subscription?.domain_limit || 5} domains</span>
|
||||
|
||||
{/* 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="text-accent hover:underline flex items-center gap-1"
|
||||
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 h-3" />
|
||||
Upgrade
|
||||
<CreditCard className="w-3.5 h-3.5" />
|
||||
Upgrade Plan
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@ -259,7 +437,7 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
setMobileOpen(false)
|
||||
}}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-200",
|
||||
"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}
|
||||
@ -273,9 +451,9 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
<button
|
||||
onClick={toggleCollapsed}
|
||||
className={clsx(
|
||||
"hidden lg:flex absolute -right-3 top-24 w-6 h-6 bg-background-secondary border border-border rounded-full",
|
||||
"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-foreground/5 transition-all duration-200 shadow-sm"
|
||||
"hover:bg-accent/10 hover:border-accent/30 transition-all duration-300 shadow-lg"
|
||||
)}
|
||||
>
|
||||
{collapsed ? (
|
||||
@ -292,9 +470,9 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="lg:hidden fixed top-4 left-4 z-50 w-10 h-10 bg-background-secondary border border-border
|
||||
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-colors shadow-lg"
|
||||
transition-all shadow-lg hover:shadow-xl hover:border-accent/30"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
@ -311,15 +489,15 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
<aside
|
||||
className={clsx(
|
||||
"lg:hidden fixed left-0 top-0 bottom-0 z-50 w-[280px] flex flex-col",
|
||||
"bg-background-secondary border-r border-border",
|
||||
"transition-transform duration-300 ease-in-out",
|
||||
"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-4 right-4 w-8 h-8 flex items-center justify-center
|
||||
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" />
|
||||
@ -331,9 +509,10 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
<aside
|
||||
className={clsx(
|
||||
"hidden lg:flex fixed left-0 top-0 bottom-0 z-40 flex-col",
|
||||
"bg-background-secondary/50 backdrop-blur-xl border-r border-border",
|
||||
"transition-all duration-300 ease-in-out",
|
||||
collapsed ? "w-[72px]" : "w-[240px]"
|
||||
"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 />
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -378,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,
|
||||
@ -401,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
|
||||
@ -796,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()
|
||||
|
||||
Reference in New Issue
Block a user