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 { useStore } from '@/lib/store'
|
||||
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 {
|
||||
Clock,
|
||||
@ -12,15 +13,13 @@ import {
|
||||
Flame,
|
||||
Timer,
|
||||
Gavel,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
DollarSign,
|
||||
RefreshCw,
|
||||
Target,
|
||||
X,
|
||||
TrendingUp,
|
||||
Loader2,
|
||||
Lock,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
@ -66,7 +65,7 @@ const PLATFORMS = [
|
||||
]
|
||||
|
||||
export default function AuctionsPage() {
|
||||
const { isAuthenticated, subscription } = useStore()
|
||||
const { isAuthenticated } = useStore()
|
||||
|
||||
const [allAuctions, setAllAuctions] = useState<Auction[]>([])
|
||||
const [endingSoon, setEndingSoon] = useState<Auction[]>([])
|
||||
@ -202,224 +201,276 @@ export default function AuctionsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="Auctions"
|
||||
subtitle={getSubtitle()}
|
||||
actions={
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-foreground-muted hover:text-foreground
|
||||
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 className="min-h-screen bg-background flex flex-col">
|
||||
<Header />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-24 pb-12 overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<div className="absolute 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>
|
||||
|
||||
{/* 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">
|
||||
{[
|
||||
{ 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 },
|
||||
].map((tab) => (
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl sm:text-4xl font-display tracking-tight text-foreground">
|
||||
Domain Auctions
|
||||
</h1>
|
||||
<p className="text-foreground-muted mt-2">{getSubtitle()}</p>
|
||||
</div>
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium rounded-xl transition-all",
|
||||
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"
|
||||
)}
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
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"
|
||||
>
|
||||
<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",
|
||||
activeTab === tab.id ? "bg-background/20" : "bg-foreground/10"
|
||||
)}>{tab.count}</span>
|
||||
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
||||
Refresh
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<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-4 h-4 text-foreground-subtle" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search domains..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-11 pr-10 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 transition-all"
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<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={isAuthenticated ? opportunities.length : '—'}
|
||||
subtitle={isAuthenticated ? undefined : 'Login required'}
|
||||
icon={Target}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
))}
|
||||
</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
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search domains..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-11 pr-10 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 transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<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" />
|
||||
</button>
|
||||
)}
|
||||
</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
|
||||
href={a.affiliate_url}
|
||||
target="_blank"
|
||||
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>
|
||||
<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',
|
||||
header: '',
|
||||
align: 'right',
|
||||
render: (a) => (
|
||||
<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 opacity-70 group-hover:opacity-100"
|
||||
>
|
||||
Bid <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex-1" />
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
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 {
|
||||
Search,
|
||||
@ -18,6 +19,8 @@ import {
|
||||
RefreshCw,
|
||||
Bell,
|
||||
X,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
@ -34,7 +37,7 @@ interface TLDData {
|
||||
}
|
||||
|
||||
export default function IntelligencePage() {
|
||||
const { subscription } = useStore()
|
||||
const { isAuthenticated } = useStore()
|
||||
|
||||
const [tldData, setTldData] = useState<TLDData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@ -96,203 +99,245 @@ export default function IntelligencePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="TLD Intelligence"
|
||||
subtitle={getSubtitle()}
|
||||
actions={
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-foreground-muted hover:text-foreground
|
||||
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 className="min-h-screen bg-background flex flex-col">
|
||||
<Header />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-24 pb-12 overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<div className="absolute 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>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<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"
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl sm:text-4xl font-display tracking-tight text-foreground">
|
||||
TLD Intelligence
|
||||
</h1>
|
||||
<p className="text-foreground-muted mt-2">{getSubtitle()}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
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 && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-foreground-subtle hover:text-foreground"
|
||||
<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>
|
||||
|
||||
{/* 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" />
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
<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 */}
|
||||
<PremiumTable
|
||||
data={filteredData}
|
||||
keyExtractor={(tld) => tld.tld}
|
||||
loading={loading}
|
||||
onRowClick={(tld) => window.location.href = `/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: '120px',
|
||||
render: (tld) => (
|
||||
<span className="font-mono text-lg font-semibold text-foreground group-hover:text-accent transition-colors">
|
||||
.{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)}%
|
||||
{/* TLD Table */}
|
||||
<PremiumTable
|
||||
data={filteredData}
|
||||
keyExtractor={(tld) => tld.tld}
|
||||
loading={loading}
|
||||
onRowClick={(tld) => window.location.href = `/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: '120px',
|
||||
render: (tld) => (
|
||||
<span className="font-mono text-lg font-semibold text-foreground group-hover:text-accent transition-colors">
|
||||
.{tld.tld}
|
||||
</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="Set price alert"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
</Link>
|
||||
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
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>
|
||||
</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 */}
|
||||
{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>
|
||||
{/* Pagination */}
|
||||
{total > 50 && (
|
||||
<div className="flex items-center justify-center gap-4 pt-6">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</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