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:
yves.gugger
2025-12-10 10:50:54 +01:00
parent 940fd177e7
commit b5c73c9068
4 changed files with 664 additions and 402 deletions

166
analysis_3.md Normal file
View 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.

View File

@ -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>
)
}

View File

@ -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
View File