feat: Make Auctions and Intelligence pages publicly accessible
CHANGES: - Auctions page now uses public layout (Header/Footer) instead of CommandCenterLayout - Intelligence page now uses public layout (Header/Footer) instead of CommandCenterLayout - Both pages accessible without login - Login CTA banner shown to non-authenticated users - Opportunities tab locked for non-authenticated users (shows ?) - Price alerts feature requires login - Consistent layout between both public pages: - Same hero section with title and refresh button - Same 4-column stats grid - Same CTA banner design - Same filter/search layout - Same table component - Same pagination design
This commit is contained in:
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.
|
||||||
@ -3,7 +3,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
import { Header } from '@/components/Header'
|
||||||
|
import { Footer } from '@/components/Footer'
|
||||||
import { PremiumTable, Badge, PlatformBadge, StatCard, PageContainer } from '@/components/PremiumTable'
|
import { PremiumTable, Badge, PlatformBadge, StatCard, PageContainer } from '@/components/PremiumTable'
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
@ -12,15 +13,13 @@ import {
|
|||||||
Flame,
|
Flame,
|
||||||
Timer,
|
Timer,
|
||||||
Gavel,
|
Gavel,
|
||||||
ChevronUp,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronsUpDown,
|
|
||||||
DollarSign,
|
DollarSign,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Target,
|
Target,
|
||||||
X,
|
X,
|
||||||
TrendingUp,
|
Lock,
|
||||||
Loader2,
|
Sparkles,
|
||||||
|
ArrowRight,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@ -66,7 +65,7 @@ const PLATFORMS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function AuctionsPage() {
|
export default function AuctionsPage() {
|
||||||
const { isAuthenticated, subscription } = useStore()
|
const { isAuthenticated } = useStore()
|
||||||
|
|
||||||
const [allAuctions, setAllAuctions] = useState<Auction[]>([])
|
const [allAuctions, setAllAuctions] = useState<Auction[]>([])
|
||||||
const [endingSoon, setEndingSoon] = useState<Auction[]>([])
|
const [endingSoon, setEndingSoon] = useState<Auction[]>([])
|
||||||
@ -202,224 +201,276 @@ export default function AuctionsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandCenterLayout
|
<div className="min-h-screen bg-background flex flex-col">
|
||||||
title="Auctions"
|
<Header />
|
||||||
subtitle={getSubtitle()}
|
|
||||||
actions={
|
{/* Hero Section */}
|
||||||
<button
|
<section className="relative pt-24 pb-12 overflow-hidden">
|
||||||
onClick={handleRefresh}
|
{/* Background Effects */}
|
||||||
disabled={refreshing}
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm text-foreground-muted hover:text-foreground
|
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
|
||||||
hover:bg-foreground/5 rounded-lg transition-all disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
|
||||||
<span className="hidden sm:inline">Refresh</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<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} accent />
|
|
||||||
<StatCard title="Hot Auctions" value={hotAuctions.length} subtitle="20+ bids" icon={Flame} />
|
|
||||||
<StatCard title="Opportunities" value={opportunities.length} icon={Target} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex flex-wrap items-center gap-2 p-1.5 bg-background-secondary/30 border border-border/30 rounded-2xl w-fit">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||||
{[
|
<div>
|
||||||
{ id: 'all' as const, label: 'All', icon: Gavel, count: allAuctions.length },
|
<h1 className="text-3xl sm:text-4xl font-display tracking-tight text-foreground">
|
||||||
{ id: 'ending' as const, label: 'Ending Soon', icon: Timer, count: endingSoon.length, color: 'warning' },
|
Domain Auctions
|
||||||
{ id: 'hot' as const, label: 'Hot', icon: Flame, count: hotAuctions.length },
|
</h1>
|
||||||
{ id: 'opportunities' as const, label: 'Opportunities', icon: Target, count: opportunities.length },
|
<p className="text-foreground-muted mt-2">{getSubtitle()}</p>
|
||||||
].map((tab) => (
|
</div>
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
onClick={handleRefresh}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
disabled={refreshing}
|
||||||
className={clsx(
|
className="flex items-center gap-2 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground
|
||||||
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium rounded-xl transition-all",
|
bg-foreground/5 hover:bg-foreground/10 border border-border/50 rounded-xl
|
||||||
activeTab === tab.id
|
transition-all disabled:opacity-50"
|
||||||
? tab.color === 'warning'
|
|
||||||
? "bg-amber-500 text-background"
|
|
||||||
: "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" />
|
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
||||||
<span className="hidden sm:inline">{tab.label}</span>
|
Refresh
|
||||||
<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>
|
</button>
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Stats */}
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
<StatCard title="All Auctions" value={allAuctions.length} icon={Gavel} />
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
<StatCard title="Ending Soon" value={endingSoon.length} icon={Timer} accent />
|
||||||
<input
|
<StatCard title="Hot Auctions" value={hotAuctions.length} subtitle="20+ bids" icon={Flame} />
|
||||||
type="text"
|
<StatCard
|
||||||
placeholder="Search domains..."
|
title="Opportunities"
|
||||||
value={searchQuery}
|
value={isAuthenticated ? opportunities.length : '—'}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
subtitle={isAuthenticated ? undefined : 'Login required'}
|
||||||
className="w-full pl-11 pr-10 py-3 bg-background-secondary/50 border border-border/50 rounded-xl
|
icon={Target}
|
||||||
text-sm text-foreground placeholder:text-foreground-subtle
|
|
||||||
focus:outline-none focus:border-accent/50 transition-all"
|
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
</div>
|
||||||
<button onClick={() => setSearchQuery('')} className="absolute right-4 top-1/2 -translate-y-1/2 text-foreground-subtle hover:text-foreground">
|
|
||||||
<X className="w-4 h-4" />
|
{/* Login Banner for Opportunities */}
|
||||||
|
{!isAuthenticated && (
|
||||||
|
<div className="mb-8 p-5 bg-gradient-to-r from-accent/10 via-accent/5 to-transparent border border-accent/20 rounded-2xl">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm: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">
|
||||||
|
<Sparkles className="w-6 h-6 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Unlock Smart Opportunities</h3>
|
||||||
|
<p className="text-sm text-foreground-muted">Get AI-powered auction analysis and personalized recommendations</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="flex items-center justify-center gap-2 px-6 py-3 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"
|
||||||
|
>
|
||||||
|
Sign In <ArrowRight className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 p-1.5 bg-background-secondary/30 border border-border/30 rounded-2xl w-fit mb-6">
|
||||||
|
{[
|
||||||
|
{ id: 'all' as const, label: 'All', icon: Gavel, count: allAuctions.length },
|
||||||
|
{ id: 'ending' as const, label: 'Ending Soon', icon: Timer, count: endingSoon.length, color: 'warning' },
|
||||||
|
{ id: 'hot' as const, label: 'Hot', icon: Flame, count: hotAuctions.length },
|
||||||
|
{ id: 'opportunities' as const, label: 'Opportunities', icon: Target, count: opportunities.length, locked: !isAuthenticated },
|
||||||
|
].map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (tab.locked) return
|
||||||
|
setActiveTab(tab.id)
|
||||||
|
}}
|
||||||
|
disabled={tab.locked}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium rounded-xl transition-all",
|
||||||
|
tab.locked && "opacity-50 cursor-not-allowed",
|
||||||
|
activeTab === tab.id
|
||||||
|
? tab.color === 'warning'
|
||||||
|
? "bg-amber-500 text-background"
|
||||||
|
: "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.locked ? <Lock className="w-4 h-4" /> : <tab.icon className="w-4 h-4" />}
|
||||||
|
<span className="hidden sm:inline">{tab.label}</span>
|
||||||
|
<span className={clsx(
|
||||||
|
"text-xs px-1.5 py-0.5 rounded tabular-nums",
|
||||||
|
activeTab === tab.id ? "bg-background/20" : "bg-foreground/10"
|
||||||
|
)}>{tab.locked ? '?' : tab.count}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<select
|
|
||||||
value={selectedPlatform}
|
|
||||||
onChange={(e) => setSelectedPlatform(e.target.value)}
|
|
||||||
className="px-4 py-3 bg-background-secondary/50 border border-border/50 rounded-xl
|
|
||||||
text-sm text-foreground cursor-pointer focus:outline-none focus:border-accent/50"
|
|
||||||
>
|
|
||||||
{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-4 h-4 text-foreground-subtle" />
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
placeholder="Max bid"
|
|
||||||
value={maxBid}
|
|
||||||
onChange={(e) => setMaxBid(e.target.value)}
|
|
||||||
className="w-32 pl-10 pr-4 py-3 bg-background-secondary/50 border border-border/50 rounded-xl
|
|
||||||
text-sm text-foreground placeholder:text-foreground-subtle
|
|
||||||
focus:outline-none focus:border-accent/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table */}
|
{/* Filters */}
|
||||||
<PremiumTable
|
<div className="flex flex-wrap gap-3 mb-6">
|
||||||
data={sortedAuctions}
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
keyExtractor={(a) => `${a.domain}-${a.platform}`}
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||||
loading={loading}
|
<input
|
||||||
sortBy={sortBy}
|
type="text"
|
||||||
sortDirection={sortDirection}
|
placeholder="Search domains..."
|
||||||
onSort={(key) => handleSort(key as SortField)}
|
value={searchQuery}
|
||||||
emptyIcon={<Gavel className="w-12 h-12 text-foreground-subtle" />}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
emptyTitle={searchQuery ? `No auctions matching "${searchQuery}"` : "No auctions found"}
|
className="w-full pl-11 pr-10 py-3 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||||
emptyDescription="Try adjusting your filters or check back later"
|
text-sm text-foreground placeholder:text-foreground-subtle
|
||||||
columns={[
|
focus:outline-none focus:border-accent/50 transition-all"
|
||||||
{
|
/>
|
||||||
key: 'domain',
|
{searchQuery && (
|
||||||
header: 'Domain',
|
<button onClick={() => setSearchQuery('')} className="absolute right-4 top-1/2 -translate-y-1/2 text-foreground-subtle hover:text-foreground">
|
||||||
sortable: true,
|
<X className="w-4 h-4" />
|
||||||
render: (a) => (
|
</button>
|
||||||
<div>
|
)}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={selectedPlatform}
|
||||||
|
onChange={(e) => setSelectedPlatform(e.target.value)}
|
||||||
|
className="px-4 py-3 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||||
|
text-sm text-foreground cursor-pointer focus:outline-none focus:border-accent/50"
|
||||||
|
>
|
||||||
|
{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-4 h-4 text-foreground-subtle" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Max bid"
|
||||||
|
value={maxBid}
|
||||||
|
onChange={(e) => setMaxBid(e.target.value)}
|
||||||
|
className="w-32 pl-10 pr-4 py-3 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||||
|
text-sm text-foreground placeholder:text-foreground-subtle
|
||||||
|
focus:outline-none focus:border-accent/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<PremiumTable
|
||||||
|
data={sortedAuctions}
|
||||||
|
keyExtractor={(a) => `${a.domain}-${a.platform}`}
|
||||||
|
loading={loading}
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSort={(key) => handleSort(key as SortField)}
|
||||||
|
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={[
|
||||||
|
{
|
||||||
|
key: 'domain',
|
||||||
|
header: 'Domain',
|
||||||
|
sortable: true,
|
||||||
|
render: (a) => (
|
||||||
|
<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) => (
|
||||||
|
<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',
|
||||||
|
render: (a) => (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground tabular-nums">{formatCurrency(a.current_bid)}</span>
|
||||||
|
{a.buy_now_price && (
|
||||||
|
<p className="text-xs text-accent">Buy: {formatCurrency(a.buy_now_price)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bids',
|
||||||
|
header: 'Bids',
|
||||||
|
sortable: true,
|
||||||
|
align: 'right',
|
||||||
|
hideOnMobile: true,
|
||||||
|
render: (a) => (
|
||||||
|
<span className={clsx(
|
||||||
|
"font-medium flex items-center justify-end gap-1 tabular-nums",
|
||||||
|
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',
|
||||||
|
hideOnMobile: true,
|
||||||
|
render: (a) => (
|
||||||
|
<span className={clsx("font-medium", getTimeColor(a.time_remaining))}>
|
||||||
|
{a.time_remaining}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(activeTab === 'opportunities' ? [{
|
||||||
|
key: 'score',
|
||||||
|
header: 'Score',
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (a: Auction) => {
|
||||||
|
const oppData = getOpportunityData(a.domain)
|
||||||
|
if (!oppData) return null
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}] : []),
|
||||||
|
{
|
||||||
|
key: 'action',
|
||||||
|
header: '',
|
||||||
|
align: 'right',
|
||||||
|
render: (a) => (
|
||||||
<a
|
<a
|
||||||
href={a.affiliate_url}
|
href={a.affiliate_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-mono font-medium text-foreground hover:text-accent transition-colors"
|
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 opacity-70 group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
{a.domain}
|
Bid <ExternalLink className="w-3 h-3" />
|
||||||
</a>
|
</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) => (
|
|
||||||
<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',
|
|
||||||
render: (a) => (
|
|
||||||
<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: 'bids',
|
|
||||||
header: 'Bids',
|
|
||||||
sortable: true,
|
|
||||||
align: 'right',
|
|
||||||
hideOnMobile: true,
|
|
||||||
render: (a) => (
|
|
||||||
<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',
|
|
||||||
hideOnMobile: true,
|
|
||||||
render: (a) => (
|
|
||||||
<span className={clsx("font-medium", getTimeColor(a.time_remaining))}>
|
|
||||||
{a.time_remaining}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
...(activeTab === 'opportunities' ? [{
|
|
||||||
key: 'score',
|
|
||||||
header: 'Score',
|
|
||||||
align: 'center' as const,
|
|
||||||
render: (a: Auction) => {
|
|
||||||
const oppData = getOpportunityData(a.domain)
|
|
||||||
if (!oppData) return null
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
}] : []),
|
]}
|
||||||
{
|
/>
|
||||||
key: 'action',
|
</div>
|
||||||
header: '',
|
</section>
|
||||||
align: 'right',
|
|
||||||
render: (a) => (
|
<div className="flex-1" />
|
||||||
<a
|
<Footer />
|
||||||
href={a.affiliate_url}
|
</div>
|
||||||
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 opacity-70 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
Bid <ExternalLink className="w-3 h-3" />
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</PageContainer>
|
|
||||||
</CommandCenterLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
import { Header } from '@/components/Header'
|
||||||
|
import { Footer } from '@/components/Footer'
|
||||||
import { PremiumTable, Badge, StatCard, PageContainer } from '@/components/PremiumTable'
|
import { PremiumTable, Badge, StatCard, PageContainer } from '@/components/PremiumTable'
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
@ -18,6 +19,8 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Bell,
|
Bell,
|
||||||
X,
|
X,
|
||||||
|
Sparkles,
|
||||||
|
ArrowRight,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
@ -34,7 +37,7 @@ interface TLDData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function IntelligencePage() {
|
export default function IntelligencePage() {
|
||||||
const { subscription } = useStore()
|
const { isAuthenticated } = useStore()
|
||||||
|
|
||||||
const [tldData, setTldData] = useState<TLDData[]>([])
|
const [tldData, setTldData] = useState<TLDData[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@ -96,203 +99,245 @@ export default function IntelligencePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandCenterLayout
|
<div className="min-h-screen bg-background flex flex-col">
|
||||||
title="TLD Intelligence"
|
<Header />
|
||||||
subtitle={getSubtitle()}
|
|
||||||
actions={
|
{/* Hero Section */}
|
||||||
<button
|
<section className="relative pt-24 pb-12 overflow-hidden">
|
||||||
onClick={handleRefresh}
|
{/* Background Effects */}
|
||||||
disabled={refreshing}
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm text-foreground-muted hover:text-foreground
|
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
|
||||||
hover:bg-foreground/5 rounded-lg transition-all disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
|
||||||
<span className="hidden sm:inline">Refresh</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<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}
|
|
||||||
accent
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Lowest Price"
|
|
||||||
value={total > 0 ? `$${lowestPrice.toFixed(2)}` : '—'}
|
|
||||||
icon={DollarSign}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Hottest TLD"
|
|
||||||
value={total > 0 ? `.${hottestTld}` : '—'}
|
|
||||||
subtitle="rising prices"
|
|
||||||
icon={TrendingUp}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Update Freq"
|
|
||||||
value="24h"
|
|
||||||
subtitle="automatic"
|
|
||||||
icon={BarChart3}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||||
<div className="relative flex-1 max-w-md">
|
<div>
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted" />
|
<h1 className="text-3xl sm:text-4xl font-display tracking-tight text-foreground">
|
||||||
<input
|
TLD Intelligence
|
||||||
type="text"
|
</h1>
|
||||||
value={searchQuery}
|
<p className="text-foreground-muted mt-2">{getSubtitle()}</p>
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
</div>
|
||||||
placeholder="Search TLDs (e.g. com, io, dev)..."
|
<button
|
||||||
className="w-full h-11 pl-11 pr-10 bg-background-secondary/50 border border-border/50 rounded-xl
|
onClick={handleRefresh}
|
||||||
text-sm text-foreground placeholder:text-foreground-subtle
|
disabled={refreshing}
|
||||||
focus:outline-none focus:border-accent/50 transition-all"
|
className="flex items-center gap-2 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground
|
||||||
|
bg-foreground/5 hover:bg-foreground/10 border border-border/50 rounded-xl
|
||||||
|
transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Overview */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
<StatCard
|
||||||
|
title="TLDs Tracked"
|
||||||
|
value={total > 0 ? total.toLocaleString() : '—'}
|
||||||
|
subtitle="updated daily"
|
||||||
|
icon={Globe}
|
||||||
|
accent
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
<StatCard
|
||||||
<button
|
title="Lowest Price"
|
||||||
onClick={() => setSearchQuery('')}
|
value={total > 0 ? `$${lowestPrice.toFixed(2)}` : '—'}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-foreground-subtle hover:text-foreground"
|
icon={DollarSign}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Hottest TLD"
|
||||||
|
value={total > 0 ? `.${hottestTld}` : '—'}
|
||||||
|
subtitle="rising prices"
|
||||||
|
icon={TrendingUp}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Update Freq"
|
||||||
|
value="24h"
|
||||||
|
subtitle="automatic"
|
||||||
|
icon={BarChart3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA Banner for non-authenticated users */}
|
||||||
|
{!isAuthenticated && (
|
||||||
|
<div className="mb-8 p-5 bg-gradient-to-r from-accent/10 via-accent/5 to-transparent border border-accent/20 rounded-2xl">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm: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">
|
||||||
|
<Bell className="w-6 h-6 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Set Price Alerts</h3>
|
||||||
|
<p className="text-sm text-foreground-muted">Get notified when TLD prices drop to your target</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="flex items-center justify-center gap-2 px-6 py-3 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"
|
||||||
|
>
|
||||||
|
Sign In <ArrowRight className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-4 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 (e.g. com, io, dev)..."
|
||||||
|
className="w-full h-11 pl-11 pr-10 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||||
|
text-sm text-foreground placeholder:text-foreground-subtle
|
||||||
|
focus:outline-none focus:border-accent/50 transition-all"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-foreground-subtle hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as any)}
|
||||||
|
className="h-11 pl-4 pr-10 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||||
|
text-sm text-foreground appearance-none cursor-pointer
|
||||||
|
focus:outline-none focus:border-accent/50"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<option value="popularity">By Popularity</option>
|
||||||
</button>
|
<option value="price_asc">Price: Low → High</option>
|
||||||
)}
|
<option value="price_desc">Price: High → Low</option>
|
||||||
|
<option value="change">By Price 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>
|
</div>
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
value={sortBy}
|
|
||||||
onChange={(e) => setSortBy(e.target.value as any)}
|
|
||||||
className="h-11 pl-4 pr-10 bg-background-secondary/50 border border-border/50 rounded-xl
|
|
||||||
text-sm text-foreground appearance-none cursor-pointer
|
|
||||||
focus:outline-none focus:border-accent/50"
|
|
||||||
>
|
|
||||||
<option value="popularity">By Popularity</option>
|
|
||||||
<option value="price_asc">Price: Low → High</option>
|
|
||||||
<option value="price_desc">Price: High → Low</option>
|
|
||||||
<option value="change">By Price 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 */}
|
{/* TLD Table */}
|
||||||
<PremiumTable
|
<PremiumTable
|
||||||
data={filteredData}
|
data={filteredData}
|
||||||
keyExtractor={(tld) => tld.tld}
|
keyExtractor={(tld) => tld.tld}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onRowClick={(tld) => window.location.href = `/tld-pricing/${tld.tld}`}
|
onRowClick={(tld) => window.location.href = `/tld-pricing/${tld.tld}`}
|
||||||
emptyIcon={<Globe className="w-12 h-12 text-foreground-subtle" />}
|
emptyIcon={<Globe className="w-12 h-12 text-foreground-subtle" />}
|
||||||
emptyTitle="No TLDs found"
|
emptyTitle="No TLDs found"
|
||||||
emptyDescription={searchQuery ? `No TLDs matching "${searchQuery}"` : "Check back later for TLD data"}
|
emptyDescription={searchQuery ? `No TLDs matching "${searchQuery}"` : "Check back later for TLD data"}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
key: 'tld',
|
key: 'tld',
|
||||||
header: 'TLD',
|
header: 'TLD',
|
||||||
width: '120px',
|
width: '120px',
|
||||||
render: (tld) => (
|
render: (tld) => (
|
||||||
<span className="font-mono text-lg font-semibold text-foreground group-hover:text-accent transition-colors">
|
<span className="font-mono text-lg font-semibold text-foreground group-hover:text-accent transition-colors">
|
||||||
.{tld.tld}
|
.{tld.tld}
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'min_price',
|
|
||||||
header: 'Min Price',
|
|
||||||
align: 'right',
|
|
||||||
width: '100px',
|
|
||||||
render: (tld) => (
|
|
||||||
<span className="font-medium text-foreground tabular-nums">${tld.min_price.toFixed(2)}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'avg_price',
|
|
||||||
header: 'Avg Price',
|
|
||||||
align: 'right',
|
|
||||||
width: '100px',
|
|
||||||
hideOnMobile: true,
|
|
||||||
render: (tld) => (
|
|
||||||
<span className="text-foreground-muted tabular-nums">${tld.avg_price.toFixed(2)}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'change',
|
|
||||||
header: '7d Change',
|
|
||||||
align: 'right',
|
|
||||||
width: '120px',
|
|
||||||
render: (tld) => (
|
|
||||||
<div className="flex items-center gap-2 justify-end">
|
|
||||||
{getTrendIcon(tld.price_change_7d)}
|
|
||||||
<span className={clsx(
|
|
||||||
"font-medium tabular-nums",
|
|
||||||
(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>
|
</span>
|
||||||
</div>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: 'min_price',
|
||||||
key: 'registrar',
|
header: 'Min Price',
|
||||||
header: 'Cheapest At',
|
align: 'right',
|
||||||
hideOnMobile: true,
|
width: '100px',
|
||||||
render: (tld) => (
|
render: (tld) => (
|
||||||
<span className="text-foreground-muted text-sm truncate max-w-[150px] block">{tld.cheapest_registrar}</span>
|
<span className="font-medium text-foreground tabular-nums">${tld.min_price.toFixed(2)}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'avg_price',
|
||||||
header: '',
|
header: 'Avg Price',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
width: '80px',
|
width: '100px',
|
||||||
render: (tld) => (
|
hideOnMobile: true,
|
||||||
<div className="flex items-center gap-1 justify-end">
|
render: (tld) => (
|
||||||
<Link
|
<span className="text-foreground-muted tabular-nums">${tld.avg_price.toFixed(2)}</span>
|
||||||
href={`/tld-pricing/${tld.tld}`}
|
),
|
||||||
className="p-2 text-foreground-subtle hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
|
},
|
||||||
onClick={(e) => e.stopPropagation()}
|
{
|
||||||
title="Set price alert"
|
key: 'change',
|
||||||
>
|
header: '7d Change',
|
||||||
<Bell className="w-4 h-4" />
|
align: 'right',
|
||||||
</Link>
|
width: '120px',
|
||||||
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />
|
render: (tld) => (
|
||||||
</div>
|
<div className="flex items-center gap-2 justify-end">
|
||||||
),
|
{getTrendIcon(tld.price_change_7d)}
|
||||||
},
|
<span className={clsx(
|
||||||
]}
|
"font-medium tabular-nums",
|
||||||
/>
|
(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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'registrar',
|
||||||
|
header: 'Cheapest At',
|
||||||
|
hideOnMobile: true,
|
||||||
|
render: (tld) => (
|
||||||
|
<span className="text-foreground-muted text-sm truncate max-w-[150px] block">{tld.cheapest_registrar}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
align: 'right',
|
||||||
|
width: '80px',
|
||||||
|
render: (tld) => (
|
||||||
|
<div className="flex items-center gap-1 justify-end">
|
||||||
|
<Link
|
||||||
|
href={`/tld-pricing/${tld.tld}`}
|
||||||
|
className="p-2 text-foreground-subtle hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
title="View details"
|
||||||
|
>
|
||||||
|
<Bell className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{total > 50 && (
|
{total > 50 && (
|
||||||
<div className="flex items-center justify-center gap-4 pt-2">
|
<div className="flex items-center justify-center gap-4 pt-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage(Math.max(0, page - 1))}
|
onClick={() => setPage(Math.max(0, page - 1))}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
||||||
bg-foreground/5 hover:bg-foreground/10 rounded-lg
|
bg-foreground/5 hover:bg-foreground/10 rounded-lg
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm text-foreground-muted tabular-nums">
|
<span className="text-sm text-foreground-muted tabular-nums">
|
||||||
Page {page + 1} of {Math.ceil(total / 50)}
|
Page {page + 1} of {Math.ceil(total / 50)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage(page + 1)}
|
onClick={() => setPage(page + 1)}
|
||||||
disabled={(page + 1) * 50 >= total}
|
disabled={(page + 1) * 50 >= total}
|
||||||
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
||||||
bg-foreground/5 hover:bg-foreground/10 rounded-lg
|
bg-foreground/5 hover:bg-foreground/10 rounded-lg
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</PageContainer>
|
</div>
|
||||||
</CommandCenterLayout>
|
</section>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
0
frontend/src/app/watchlist/page.tsx
Normal file → Executable file
0
frontend/src/app/watchlist/page.tsx
Normal file → Executable file
Reference in New Issue
Block a user