diff --git a/analysis_1.md b/analysis_1.md new file mode 100644 index 0000000..84fe8cc --- /dev/null +++ b/analysis_1.md @@ -0,0 +1,173 @@ +Das ist ein gewaltiger Schritt nach vorne! 🚀 + +Die Seiten wirken jetzt kohärent, professionell und haben eine klare psychologische Führung (Hook -> Value -> Gate -> Sign Up). Besonders der Wechsel auf **$9 für den Einstieg** (Trader) ist smart – das ist ein "No-Brainer"-Preis für Impulse-Käufe. + +Hier ist mein Feedback zu den einzelnen Seiten mit Fokus auf Conversion und UX: + +--- + +### 1. Navigation & Globales Layout +Die Navigation ist **perfekt minimalistisch**. +* `Market | TLD Intel | Pricing` – Das sind genau die drei Säulen. +* **Vorschlag:** Ich würde "Market" eventuell in **"Auctions"** oder **"Live Market"** umbenennen. "Market" ist etwas vage. "Auctions" triggert eher das Gefühl "Hier gibt es Schnäppchen". + +--- + +### 2. Landing Page +**Das Starke:** +* Die Headline *"The market never sleeps. You should."* ist Weltklasse. +* Der Ticker mit den Live-Preisen erzeugt sofort FOMO (Fear Of Missing Out). +* Die Sektion "TLD Intelligence" mit den "Sign in to view"-Overlays bei den Daten ist ein **exzellenter Conversion-Treiber**. Der User sieht, dass da Daten *sind*, aber er muss sich anmelden (kostenlos), um sie zu sehen. Das ist der perfekte "Account-Erstellungs-Köder". + +**Kritikpunkt / To-Do:** +* **Der "Search"-Fokus:** Du schreibst *"Try dream.com..."*, aber visuell muss dort ein **riesiges Input-Feld** sein. Das muss das dominante Element sein. +* **Der Ticker:** Achte darauf, dass der Ticker technisch sauber läuft (marquee/scrolling). Im Text oben wiederholt sich die Liste statisch – auf der echten Seite muss das fließen. + +--- + +### 3. Market / Auctions Page (WICHTIG!) +Hier sehe ich das **größte Risiko**. +Dein Konzept ("Unlock Smart Opportunities") ist super. Aber die **Beispiel-Daten**, die du auf der Public-Seite zeigst, sind gefährlich. + +**Das Problem:** +In deiner Liste stehen Dinge wie: +* `fgagtqjisqxyoyjrjfizxshtw.xyz` +* `52gao1588.cc` +* `professional-packing-services...website` + +Wenn ein neuer User das sieht, denkt er: **"Das ist eine Spam-Seite voll mit Schrott."** Er wird sich nicht anmelden. + +**Die Lösung (Der "Vanity-Filter"):** +Du musst für die **öffentliche Seite (ausgeloggt)** einen harten Filter in den Code bauen. Zeige ausgeloggten Usern **NUR** Domains an, die schön aussehen. +* Regel 1: Keine Zahlen (außer bei kurzen Domains). +* Regel 2: Keine Bindestriche (Hyphens). +* Regel 3: Länge < 12 Zeichen. +* Regel 4: Nur .com, .io, .ai, .co, .de, .ch (Keine .cc, .website Spam-Cluster). + +**Warum?** +Der User soll denken: "Wow, hier gibt es Premium-Domains wie `nexus.dev`". Er darf den Müll nicht sehen, bevor er eingeloggt ist (und selbst dann solltest du den Müll filtern, wie wir besprochen haben). + +--- + +### 4. TLD Pricing Page +**Sehr gut gelöst.** +* Die "Moving Now"-Karten oben (.ai +35%) sind der Haken. +* Die Tabelle darunter mit "Sign in" zu sperren (Blur-Effekt oder Schloss-Icon), ist genau richtig. +* Der User bekommt genug Info ("Aha, .com ist beliebt"), aber für die Details ("Ist der Trend steigend?") muss er 'Scout' werden. + +--- + +### 5. Pricing Page +Die neue Struktur mit **Scout (Free) / Trader ($9) / Tycoon ($29)** ist viel besser als das alte $19-Modell. + +**Optimierung der Tabelle:** +Du musst den Unterschied zwischen **Scout** und **Trader** noch schärfer machen, damit die Leute die $9 bezahlen. + +| Feature | Scout (Free) | Trader ($9) | Warum Upgrade? | +| :--- | :--- | :--- | :--- | +| **Auctions** | Raw Feed (Ungefiltert) | **Smart Clean Feed** | *"Ich will den Spam nicht sehen."* | +| **Data** | Nur Preise | **Valuation & Deal Score** | *"Ich will wissen, ob es ein Schnäppchen ist."* | +| **Updates** | Täglich | **Stündlich** | *"Ich will schneller sein als andere."* | + +**Wichtig:** Füge in der "Trader"-Spalte explizit **"Spam Filters"** oder **"Curated List"** hinzu. Das ist Zeitersparnis, und dafür zahlen Leute. + +--- + +### Zusammenfassung & Tone of Voice + +Der Tone of Voice ist jetzt konsistent: **Analytisch, Knapp, Strategisch.** + +* *Alt:* "Jage Domains." (Bisschen spielerisch) +* *Neu:* "Don't guess. Know." (Professionell, B2B-tauglich) + +**Letzter Check vor dem Launch:** +1. **Mobile View:** Prüfe die riesige Tabelle auf dem Handy. Wahrscheinlich musst du auf Mobile Spalten ausblenden (z.B. nur Domain + Preis + Button zeigen). +2. **Der Filter:** Bitte, bitte filtere die `fgagtqjis...xyz` Domains auf der Startseite raus. Das ist der wichtigste Punkt für den ersten Eindruck. + +Das sieht nach einem Produkt aus, für das ich meine Kreditkarte zücken würde. Gute Arbeit! + +Hier ist die komplette **Master-Zusammenfassung** für `pounce.ch`. Dies ist dein Bauplan (Blueprint) für die Umsetzung. + +--- + +### 1. Die Vision & Positionierung +**Name:** Pounce +**Tagline:** *Domain Intelligence for Hunters.* +**Slogan:** *"Don't guess. Know."* +**Konzept:** Pounce ist das "Bloomberg Terminal" für Domains. Es verwandelt den unübersichtlichen, lauten Domain-Markt in klare, handlungsfähige Daten. Es richtet sich an Leute, die nicht suchen, sondern finden wollen. + +* **Zielgruppe:** + * **Dreamers (Gründer):** Suchen den perfekten Namen für ihr Projekt. + * **Hunters (Investoren/Händler):** Suchen unterbewertete Assets für Arbitrage (günstig kaufen, teuer verkaufen). + +--- + +### 2. Die 3 Produktsäulen (Das "Command Center") + +Das Produkt gliedert sich logisch in drei Phasen der Domain-Beschaffung: + +#### A. DISCOVER (Markt-Intelligenz) +*Der "Honigtopf", um User anzuziehen (SEO & Traffic).* +* **TLD Intel:** Zeigt Markttrends (z.B. `.ai` steigt um 35%). +* **Smart Search:** Wenn eine Domain vergeben ist, zeigt Pounce **intelligente Alternativen** (z.B. `.io` für Tech, `.shop` für E-Commerce), statt nur zufällige Endungen. +* **Der Hook:** Öffentliche Besucher sehen Trends, aber Details (Charts, Historie) sind ausgeblendet ("Sign in to view"). + +#### B. TRACK (Die Watchlist) +*Das Tool für Kundenbindung.* +* **Funktion:** Überwachung von *vergebenen* Domains. +* **Der USP:** Nicht nur "frei/besetzt", sondern **"Pre-Drop Indicators"**. Warnung bei DNS-Änderungen oder wenn die Webseite offline geht. Das gibt dem User einen Zeitvorsprung vor der Konkurrenz. + +#### C. ACQUIRE (Der Auktions-Aggregator) +*Der Hauptgrund für das Upgrade.* +* **Funktion:** Aggregiert Live-Auktionen von GoDaddy, Sedo, NameJet & DropCatch an einem Ort. +* **Der "Killer-Feature" (Spam-Filter):** + * *Free User:* Sieht alles (auch "Müll"-Domains wie `kredit-24-online.info`). + * *Paid User:* Sieht einen **kuratierten Feed**. Der Algorithmus filtert Zahlen, Bindestriche und Spam raus. Übrig bleiben nur hochwertige Investitions-Chancen. + +--- + +### 3. Das Geschäftsmodell (Pricing) + +Das Modell basiert auf "Freemium mit Schranken". Der Preis von $9 ist ein "No-Brainer" (Impulskauf), um die Hürde niedrig zu halten. + +| Plan | Preis | Zielgruppe | Haupt-Features | Der "Schmerz" (Warum upgraden?) | +| :--- | :--- | :--- | :--- | :--- | +| **SCOUT** | **0 €** | Neugierige | 5 Watchlist-Domains, roher Auktions-Feed, Basis-Suche. | Muss sich durch "Spam" wühlen, sieht keine Bewertungen, langsame Alerts. | +| **TRADER** | **9 €** | Hobby-Investoren | 50 Watchlist-Domains, **Spam-freier Feed**, Deal Scores (Bewertungen), stündliche Checks. | Zahlt für Zeitersparnis (Filter) und Sicherheit (Bewertung). | +| **TYCOON** | **29 €** | Profis | 500 Domains, Echtzeit-Checks (10 Min), API-Zugriff (geplant). | Braucht Volumen und Geschwindigkeit. | + +--- + +### 4. UX/UI & Tone of Voice + +* **Design-Philosophie:** "Dark Mode & Data". + * Dunkler Hintergrund (Schwarz/Grau) wirkt professionell (wie Trading-Software). + * Akzentfarben: Neon-Grün (für "Frei" / "Profit") und Warn-Orange. + * Wenig Text, viele Datenpunkte, klare Tabellen. +* **Tone of Voice:** + * Knapp, präzise, strategisch. + * Kein Marketing-Bla-Bla. + * *Beispiel:* Statt "Wir haben viele tolle Funktionen" → "Three moves to dominate." + +--- + +### 5. Die User Journey (Der "Golden Path") + +1. **Der Einstieg:** User googelt "Domain Preise .ai" und landet auf deiner **TLD Intel Page**. +2. **Der Hook:** Er sieht "`.ai` +35%", will aber die Details sehen. Die Tabelle ist unscharf. Button: *"Sign In to view details"*. +3. **Die Registrierung:** Er erstellt einen Free Account ("Scout"). +4. **Die Erkenntnis:** Er geht zu den Auktionen. Er sieht eine interessante Domain, aber weiß nicht, ob der Preis gut ist. Neben dem Preis steht: *"Valuation locked"*. +5. **Das Upgrade:** Er sieht das Angebot: "Für nur $9/Monat siehst du den echten Wert und wir filtern den Müll für dich." +6. **Der Kauf:** Er abonniert den "Trader"-Plan. + +--- + +### Zusammenfassung für den Entwickler (Tech Stack Requirements) + +* **Frontend:** Muss extrem schnell sein (Reagierende Suche). Mobile-freundlich (Tabellen müssen auf dem Handy lesbar sein oder ausgeblendet werden). +* **Daten-Integration:** APIs zu GoDaddy, Sedo etc. oder Scraping für die Auktionsdaten. +* **Logik:** + * **Filter-Algorithmus:** Das Wichtigste! (Regeln: Keine Zahlen, max. 2 Bindestriche, Wörterbuch-Abgleich). + * **Alert-System:** Cronjobs für E-Mail/SMS Benachrichtigungen. + +Das Konzept ist jetzt rund, logisch und bereit für den Bau. Viel Erfolg mit **Pounce**! 🚀 \ No newline at end of file diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index b6f0273..75a42b2 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -88,7 +88,7 @@ export default function AdminPage() { const [bulkTier, setBulkTier] = useState('trader') useEffect(() => { - loadAdminData() + loadAdminData() }, [activeTab]) const loadAdminData = async () => { @@ -112,23 +112,23 @@ export default function AdminPage() { setNewsletter(nlData.subscribers) setNewsletterTotal(nlData.total) } else if (activeTab === 'system') { - const [healthData, schedulerData] = await Promise.all([ + const [healthData, schedulerData] = await Promise.all([ api.getSystemHealth().catch(() => null), api.getSchedulerStatus().catch(() => null), - ]) - setSystemHealth(healthData) - setSchedulerStatus(schedulerData) + ]) + setSystemHealth(healthData) + setSchedulerStatus(schedulerData) } else if (activeTab === 'activity') { const logData = await api.getActivityLog(50, 0).catch(() => ({ logs: [], total: 0 })) - setActivityLog(logData.logs) - setActivityLogTotal(logData.total) + setActivityLog(logData.logs) + setActivityLogTotal(logData.total) } else if (activeTab === 'blog') { const blogData = await api.getAdminBlogPosts(50, 0).catch(() => ({ posts: [], total: 0 })) - setBlogPosts(blogData.posts) - setBlogPostsTotal(blogData.total) + setBlogPosts(blogData.posts) + setBlogPostsTotal(blogData.total) } } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load admin data') + setError(err instanceof Error ? err.message : 'Failed to load admin data') } finally { setLoading(false) } @@ -229,7 +229,7 @@ export default function AdminPage() { } } - return ( + return ( setActiveTab(tab as TabType)} > - {/* Messages */} + {/* Messages */} {error && (

{error}

-
- )} + + )} - {success && ( + {success && (
- +

{success}

-
- )} + + )} - {loading ? ( -
+ {loading ? ( +
-
- ) : ( - <> - {/* Overview Tab */} - {activeTab === 'overview' && stats && ( +
+ ) : ( + <> + {/* Overview Tab */} + {activeTab === 'overview' && stats && (
- + -
+
{[ @@ -288,44 +288,44 @@ export default function AdminPage() {
{tier} -
+

{stats.subscriptions[tier] || 0}

- + ))} - +

Active Auctions

{stats.auctions.toLocaleString()}

-
+

Price Alerts

{stats.price_alerts.toLocaleString()}

+
- - )} + )} - {/* Users Tab */} - {activeTab === 'users' && ( + {/* Users Tab */} + {activeTab === 'users' && (
-
-
- - setSearchQuery(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && loadAdminData()} - placeholder="Search users..." +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && loadAdminData()} + placeholder="Search users..." className="w-full pl-11 pr-4 py-3 bg-background-secondary/50 border border-border/30 rounded-xl text-sm" - /> -
+ /> +
-
+ +

{u.email}

{u.name || 'No name'}

-
+ ), }, { @@ -353,7 +353,7 @@ export default function AdminPage() { {u.is_admin && Admin} {u.is_verified && Verified} {!u.is_active && Inactive} - + ), }, { @@ -361,7 +361,7 @@ export default function AdminPage() { header: 'Tier', render: (u) => ( - {u.subscription.tier_name} + {u.subscription.tier_name} ), }, @@ -376,19 +376,19 @@ export default function AdminPage() { header: 'Actions', align: 'right', render: (u) => ( -
- handleUpgradeUser(u.id, e.target.value)} className="px-2 py-1.5 bg-background-secondary border border-border/30 rounded-lg text-xs" - > - - - - + > + + + + handleToggleAdmin(u.id, u.is_admin)} variant={u.is_admin ? 'accent' : 'default'} title={u.is_admin ? 'Remove admin' : 'Make admin'} /> handleDeleteUser(u.id, u.email)} variant="danger" disabled={u.is_admin} title="Delete user" /> -
+ ), }, ]} @@ -396,28 +396,28 @@ export default function AdminPage() { emptyTitle="No users found" />

Showing {users.length} of {usersTotal} users

- - )} - - {/* Newsletter Tab */} - {activeTab === 'newsletter' && ( -
-
-

{newsletterTotal} subscribers

-
+ )} + + {/* Newsletter Tab */} + {activeTab === 'newsletter' && ( +
+
+

{newsletterTotal} subscribers

+ +
s.id} @@ -427,14 +427,14 @@ export default function AdminPage() { { key: 'subscribed', header: 'Subscribed', render: (s) => new Date(s.subscribed_at).toLocaleDateString() }, ]} /> -
- )} +
+ )} - {/* System Tab */} - {activeTab === 'system' && ( + {/* System Tab */} + {activeTab === 'system' && (
-

System Status

+

System Status

{[ { label: 'Database', ok: systemHealth?.database === 'healthy', text: systemHealth?.database || 'Unknown' }, @@ -448,23 +448,23 @@ export default function AdminPage() { {item.ok ? : } {item.text} +
+ ))}
- ))} -
- +
-

Manual Triggers

-
+

Manual Triggers

+
+ {domainChecking ? : } + {domainChecking ? 'Checking...' : 'Check All Domains'} + + {sendingEmail ? : } + {sendingEmail ? 'Sending...' : 'Send Test Email'} + +
-
{schedulerStatus && (
@@ -484,14 +484,14 @@ export default function AdminPage() {
{job.name} {job.trigger} -
+
))} -
- - )} + + + )} + - - )} + )} {/* Other tabs similar pattern... */} {activeTab === 'alerts' && ( @@ -508,9 +508,9 @@ export default function AdminPage() { { key: 'created', header: 'Created', hideOnMobile: true, render: (a) => new Date(a.created_at).toLocaleDateString() }, ]} /> - )} + )} - {activeTab === 'activity' && ( + {activeTab === 'activity' && ( l.id} @@ -531,8 +531,8 @@ export default function AdminPage() {

{blogPostsTotal} posts

- + + p.id} @@ -548,12 +548,12 @@ export default function AdminPage() { window.open(`/blog/${p.slug}`, '_blank')} /> - + ) }, ]} /> - - )} + + )} )}
diff --git a/frontend/src/app/auctions/page.tsx b/frontend/src/app/auctions/page.tsx index 12165cb..3027de0 100644 --- a/frontend/src/app/auctions/page.tsx +++ b/frontend/src/app/auctions/page.tsx @@ -5,7 +5,7 @@ import { useStore } from '@/lib/store' import { api } from '@/lib/api' import { Header } from '@/components/Header' import { Footer } from '@/components/Footer' -import { PremiumTable, Badge, PlatformBadge, StatCard, PageContainer } from '@/components/PremiumTable' +import { PremiumTable, Badge, PlatformBadge, StatCard } from '@/components/PremiumTable' import { Clock, ExternalLink, @@ -17,9 +17,9 @@ import { RefreshCw, Target, X, + ArrowRight, Lock, Sparkles, - ArrowRight, } from 'lucide-react' import Link from 'next/link' import clsx from 'clsx' @@ -41,19 +41,7 @@ interface Auction { affiliate_url: string } -interface Opportunity { - auction: Auction - analysis: { - opportunity_score: number - urgency?: string - competition?: string - price_range?: string - recommendation: string - reasoning?: string - } -} - -type TabType = 'all' | 'ending' | 'hot' | 'opportunities' +type TabType = 'all' | 'ending' | 'hot' type SortField = 'ending' | 'bid_asc' | 'bid_desc' | 'bids' const PLATFORMS = [ @@ -65,60 +53,39 @@ const PLATFORMS = [ ] export default function AuctionsPage() { - const { isAuthenticated } = useStore() + const { isAuthenticated, checkAuth } = useStore() const [allAuctions, setAllAuctions] = useState([]) const [endingSoon, setEndingSoon] = useState([]) const [hotAuctions, setHotAuctions] = useState([]) - const [opportunities, setOpportunities] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [activeTab, setActiveTab] = useState('all') const [sortBy, setSortBy] = useState('ending') const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc') - // Filters const [searchQuery, setSearchQuery] = useState('') const [selectedPlatform, setSelectedPlatform] = useState('All') - const [maxBid, setMaxBid] = useState('') + const [maxBid, setMaxBid] = useState('') useEffect(() => { - loadData() - }, []) + checkAuth() + loadAuctions() + }, [checkAuth]) - useEffect(() => { - if (isAuthenticated && opportunities.length === 0) { - loadOpportunities() - } - }, [isAuthenticated]) - - const loadOpportunities = async () => { - try { - const oppData = await api.getAuctionOpportunities() - setOpportunities(oppData.opportunities || []) - } catch (e) { - console.error('Failed to load opportunities:', e) - } - } - - const loadData = async () => { + const loadAuctions = async () => { setLoading(true) try { - const [auctionsData, hotData, endingData] = await Promise.all([ - api.getAuctions(), + const [all, ending, hot] = await Promise.all([ + api.getAuctions(undefined, undefined, undefined, undefined, undefined, false, 'ending', 100, 0), + api.getEndingSoonAuctions(50), api.getHotAuctions(50), - api.getEndingSoonAuctions(24, 50), ]) - - setAllAuctions(auctionsData.auctions || []) - setHotAuctions(hotData || []) - setEndingSoon(endingData || []) - - if (isAuthenticated) { - await loadOpportunities() - } + setAllAuctions(all.auctions || []) + setEndingSoon(ending || []) + setHotAuctions(hot || []) } catch (error) { - console.error('Failed to load auction data:', error) + console.error('Failed to load auctions:', error) } finally { setLoading(false) } @@ -126,62 +93,43 @@ export default function AuctionsPage() { const handleRefresh = async () => { setRefreshing(true) - await loadData() + await loadAuctions() setRefreshing(false) } - const formatCurrency = (value: number) => { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(value) - } - const getCurrentAuctions = (): Auction[] => { switch (activeTab) { case 'ending': return endingSoon case 'hot': return hotAuctions - case 'opportunities': return opportunities.map(o => o.auction) default: return allAuctions } } - const getOpportunityData = (domain: string) => { - if (activeTab !== 'opportunities') return null - return opportunities.find(o => o.auction.domain === domain)?.analysis - } - const filteredAuctions = getCurrentAuctions().filter(auction => { - if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) return false - if (selectedPlatform !== 'All' && auction.platform !== selectedPlatform) return false - if (maxBid && auction.current_bid > parseFloat(maxBid)) return false + if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) { + return false + } + if (selectedPlatform !== 'All' && auction.platform !== selectedPlatform) { + return false + } + if (maxBid && auction.current_bid > parseFloat(maxBid)) { + return false + } return true }) - const sortedAuctions = activeTab === 'opportunities' - ? filteredAuctions - : [...filteredAuctions].sort((a, b) => { - const mult = sortDirection === 'asc' ? 1 : -1 - switch (sortBy) { - case 'ending': - return mult * (new Date(a.end_time).getTime() - new Date(b.end_time).getTime()) - case 'bid_asc': - case 'bid_desc': - return mult * (a.current_bid - b.current_bid) - case 'bids': - return mult * (b.num_bids - a.num_bids) - default: - return 0 - } - }) - - const getTimeColor = (timeRemaining: string) => { - if (timeRemaining.includes('m') && !timeRemaining.includes('h') && !timeRemaining.includes('d')) return 'text-red-400' - if (timeRemaining.includes('h') && parseInt(timeRemaining) < 2) return 'text-amber-400' - return 'text-foreground-muted' - } + const sortedAuctions = [...filteredAuctions].sort((a, b) => { + switch (sortBy) { + case 'bid_asc': + return sortDirection === 'asc' ? a.current_bid - b.current_bid : b.current_bid - a.current_bid + case 'bid_desc': + return sortDirection === 'asc' ? b.current_bid - a.current_bid : a.current_bid - b.current_bid + case 'bids': + return sortDirection === 'asc' ? a.num_bids - b.num_bids : b.num_bids - a.num_bids + default: + return 0 + } + }) const handleSort = (field: SortField) => { if (sortBy === field) { @@ -192,7 +140,16 @@ export default function AuctionsPage() { } } - // Dynamic subtitle + const formatCurrency = (amount: number, currency = 'USD') => { + return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount) + } + + const getTimeColor = (timeRemaining: string) => { + if (timeRemaining.includes('m') && !timeRemaining.includes('h')) return 'text-red-400' + if (timeRemaining.includes('h') && parseInt(timeRemaining) < 12) return 'text-amber-400' + return 'text-foreground-muted' + } + const getSubtitle = () => { if (loading) return 'Loading live auctions...' const total = allAuctions.length @@ -201,23 +158,32 @@ export default function AuctionsPage() { } return ( -
-
- - {/* Hero Section */} -
- {/* Background Effects */} -
-
-
+
+ {/* Background Effects */} +
+
+
+
+
-
-
+
+ +
+
+ {/* Header */} +
-

+ Live Market +

Domain Auctions

-

{getSubtitle()}

+

{getSubtitle()}

))}
{/* Filters */} -
+
- {/* Table */} - `${a.domain}-${a.platform}`} - loading={loading} - sortBy={sortBy} - sortDirection={sortDirection} - onSort={(key) => handleSort(key as SortField)} - emptyIcon={} - 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) => ( -
- + `${a.domain}-${a.platform}`} + loading={loading} + sortBy={sortBy} + sortDirection={sortDirection} + onSort={(key) => handleSort(key as SortField)} + emptyIcon={} + 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) => ( +
+ + {a.domain} + +
+ + {a.age_years && {a.age_years}y} +
+
+ ), + }, + { + key: 'platform', + header: 'Platform', + hideOnMobile: true, + render: (a) => ( +
+ + {a.age_years && ( + + {a.age_years}y + + )} +
+ ), + }, + { + key: 'bid_asc', + header: 'Bid', + sortable: true, + align: 'right', + render: (a) => ( +
+ {formatCurrency(a.current_bid)} + {a.buy_now_price && ( +

Buy: {formatCurrency(a.buy_now_price)}

+ )} +
+ ), + }, + { + key: 'bids', + header: 'Bids', + sortable: true, + align: 'right', + hideOnMobile: true, + render: (a) => ( + = 20 ? "text-accent" : a.num_bids >= 10 ? "text-amber-400" : "text-foreground-muted" + )}> + {a.num_bids} + {a.num_bids >= 20 && } + + ), + }, + { + key: 'ending', + header: 'Time Left', + sortable: true, + align: 'right', + hideOnMobile: true, + render: (a) => ( + + {a.time_remaining} + + ), + }, + { + key: 'action', + header: '', + align: 'right', + render: (a) => ( + - {a.domain} + Bid -
- - {a.age_years && {a.age_years}y} -
-
- ), - }, - { - key: 'platform', - header: 'Platform', - hideOnMobile: true, - render: (a) => ( -
- - {a.age_years && ( - - {a.age_years}y - - )} -
- ), - }, - { - key: 'bid_asc', - header: 'Bid', - sortable: true, - align: 'right', - render: (a) => ( -
- {formatCurrency(a.current_bid)} - {a.buy_now_price && ( -

Buy: {formatCurrency(a.buy_now_price)}

- )} -
- ), - }, - { - key: 'bids', - header: 'Bids', - sortable: true, - align: 'right', - hideOnMobile: true, - render: (a) => ( - = 20 ? "text-accent" : a.num_bids >= 10 ? "text-amber-400" : "text-foreground-muted" - )}> - {a.num_bids} - {a.num_bids >= 20 && } - - ), - }, - { - key: 'ending', - header: 'Time Left', - sortable: true, - align: 'right', - hideOnMobile: true, - render: (a) => ( - - {a.time_remaining} - - ), - }, - ...(activeTab === 'opportunities' ? [{ - key: 'score', - header: 'Score', - align: 'center' as const, - render: (a: Auction) => { - const oppData = getOpportunityData(a.domain) - if (!oppData) return null - return ( - - {oppData.opportunity_score} - - ) + ), }, - }] : []), - { - key: 'action', - header: '', - align: 'right', - render: (a) => ( - - Bid - - ), - }, - ]} - /> + ]} + /> +
-
+ -
) diff --git a/frontend/src/app/command/auctions/page.tsx b/frontend/src/app/command/auctions/page.tsx new file mode 100644 index 0000000..68e82ba --- /dev/null +++ b/frontend/src/app/command/auctions/page.tsx @@ -0,0 +1,425 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useStore } from '@/lib/store' +import { api } from '@/lib/api' +import { CommandCenterLayout } from '@/components/CommandCenterLayout' +import { PremiumTable, Badge, PlatformBadge, StatCard, PageContainer } from '@/components/PremiumTable' +import { + Clock, + ExternalLink, + Search, + Flame, + Timer, + Gavel, + ChevronUp, + ChevronDown, + ChevronsUpDown, + DollarSign, + RefreshCw, + Target, + X, + TrendingUp, + Loader2, +} from 'lucide-react' +import Link from 'next/link' +import clsx from 'clsx' + +interface Auction { + domain: string + platform: string + platform_url: string + current_bid: number + currency: string + num_bids: number + end_time: string + time_remaining: string + buy_now_price: number | null + reserve_met: boolean | null + traffic: number | null + age_years: number | null + tld: string + affiliate_url: string +} + +interface Opportunity { + auction: Auction + analysis: { + opportunity_score: number + urgency?: string + competition?: string + price_range?: string + recommendation: string + reasoning?: string + } +} + +type TabType = 'all' | 'ending' | 'hot' | 'opportunities' +type SortField = 'ending' | 'bid_asc' | 'bid_desc' | 'bids' + +const PLATFORMS = [ + { id: 'All', name: 'All Sources' }, + { id: 'GoDaddy', name: 'GoDaddy' }, + { id: 'Sedo', name: 'Sedo' }, + { id: 'NameJet', name: 'NameJet' }, + { id: 'DropCatch', name: 'DropCatch' }, +] + +export default function AuctionsPage() { + const { isAuthenticated, subscription } = useStore() + + const [allAuctions, setAllAuctions] = useState([]) + const [endingSoon, setEndingSoon] = useState([]) + const [hotAuctions, setHotAuctions] = useState([]) + const [opportunities, setOpportunities] = useState([]) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [activeTab, setActiveTab] = useState('all') + const [sortBy, setSortBy] = useState('ending') + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc') + + // Filters + const [searchQuery, setSearchQuery] = useState('') + const [selectedPlatform, setSelectedPlatform] = useState('All') + const [maxBid, setMaxBid] = useState('') + + useEffect(() => { + loadData() + }, []) + + useEffect(() => { + if (isAuthenticated && opportunities.length === 0) { + loadOpportunities() + } + }, [isAuthenticated]) + + const loadOpportunities = async () => { + try { + const oppData = await api.getAuctionOpportunities() + setOpportunities(oppData.opportunities || []) + } catch (e) { + console.error('Failed to load opportunities:', e) + } + } + + const loadData = async () => { + setLoading(true) + try { + const [auctionsData, hotData, endingData] = await Promise.all([ + api.getAuctions(), + api.getHotAuctions(50), + api.getEndingSoonAuctions(24, 50), + ]) + + setAllAuctions(auctionsData.auctions || []) + setHotAuctions(hotData || []) + setEndingSoon(endingData || []) + + if (isAuthenticated) { + await loadOpportunities() + } + } catch (error) { + console.error('Failed to load auction data:', error) + } finally { + setLoading(false) + } + } + + const handleRefresh = async () => { + setRefreshing(true) + await loadData() + setRefreshing(false) + } + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value) + } + + const getCurrentAuctions = (): Auction[] => { + switch (activeTab) { + case 'ending': return endingSoon + case 'hot': return hotAuctions + case 'opportunities': return opportunities.map(o => o.auction) + default: return allAuctions + } + } + + const getOpportunityData = (domain: string) => { + if (activeTab !== 'opportunities') return null + return opportunities.find(o => o.auction.domain === domain)?.analysis + } + + const filteredAuctions = getCurrentAuctions().filter(auction => { + if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) return false + if (selectedPlatform !== 'All' && auction.platform !== selectedPlatform) return false + if (maxBid && auction.current_bid > parseFloat(maxBid)) return false + return true + }) + + const sortedAuctions = activeTab === 'opportunities' + ? filteredAuctions + : [...filteredAuctions].sort((a, b) => { + const mult = sortDirection === 'asc' ? 1 : -1 + switch (sortBy) { + case 'ending': + return mult * (new Date(a.end_time).getTime() - new Date(b.end_time).getTime()) + case 'bid_asc': + case 'bid_desc': + return mult * (a.current_bid - b.current_bid) + case 'bids': + return mult * (b.num_bids - a.num_bids) + default: + return 0 + } + }) + + const getTimeColor = (timeRemaining: string) => { + if (timeRemaining.includes('m') && !timeRemaining.includes('h') && !timeRemaining.includes('d')) return 'text-red-400' + if (timeRemaining.includes('h') && parseInt(timeRemaining) < 2) return 'text-amber-400' + return 'text-foreground-muted' + } + + const handleSort = (field: SortField) => { + if (sortBy === field) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc') + } else { + setSortBy(field) + setSortDirection('asc') + } + } + + // Dynamic subtitle + const getSubtitle = () => { + if (loading) return 'Loading live auctions...' + const total = allAuctions.length + if (total === 0) return 'No active auctions found' + return `${total.toLocaleString()} live auctions across 4 platforms` + } + + return ( + + + Refresh + + } + > + + {/* Stats */} +
+ + + + +
+ + {/* Tabs */} +
+ {[ + { 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) => ( + + ))} +
+ + {/* Filters */} +
+
+ + 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 && ( + + )} +
+ +
+ + 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" + /> +
+
+ + {/* Table */} + `${a.domain}-${a.platform}`} + loading={loading} + sortBy={sortBy} + sortDirection={sortDirection} + onSort={(key) => handleSort(key as SortField)} + emptyIcon={} + 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) => ( +
+ + {a.domain} + +
+ + {a.age_years && {a.age_years}y} +
+
+ ), + }, + { + key: 'platform', + header: 'Platform', + hideOnMobile: true, + render: (a) => ( +
+ + {a.age_years && ( + + {a.age_years}y + + )} +
+ ), + }, + { + key: 'bid_asc', + header: 'Bid', + sortable: true, + align: 'right', + render: (a) => ( +
+ {formatCurrency(a.current_bid)} + {a.buy_now_price && ( +

Buy: {formatCurrency(a.buy_now_price)}

+ )} +
+ ), + }, + { + key: 'bids', + header: 'Bids', + sortable: true, + align: 'right', + hideOnMobile: true, + render: (a) => ( + = 20 ? "text-accent" : a.num_bids >= 10 ? "text-amber-400" : "text-foreground-muted" + )}> + {a.num_bids} + {a.num_bids >= 20 && } + + ), + }, + { + key: 'ending', + header: 'Time Left', + sortable: true, + align: 'right', + hideOnMobile: true, + render: (a) => ( + + {a.time_remaining} + + ), + }, + ...(activeTab === 'opportunities' ? [{ + key: 'score', + header: 'Score', + align: 'center' as const, + render: (a: Auction) => { + const oppData = getOpportunityData(a.domain) + if (!oppData) return null + return ( + + {oppData.opportunity_score} + + ) + }, + }] : []), + { + key: 'action', + header: '', + align: 'right', + render: (a) => ( + + Bid + + ), + }, + ]} + /> +
+
+ ) +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/command/dashboard/page.tsx similarity index 96% rename from frontend/src/app/dashboard/page.tsx rename to frontend/src/app/command/dashboard/page.tsx index 4d9745c..310fb5a 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/command/dashboard/page.tsx @@ -187,14 +187,14 @@ export default function DashboardPage() { {/* Stats Overview */}
- + - + 0} /> - + + View all → } @@ -293,7 +293,7 @@ export default function DashboardPage() { icon={Gavel} compact action={ - + View all → } @@ -351,7 +351,7 @@ export default function DashboardPage() { icon={TrendingUp} compact action={ - + View all → } diff --git a/frontend/src/app/command/intelligence/page.tsx b/frontend/src/app/command/intelligence/page.tsx new file mode 100755 index 0000000..e1201a5 --- /dev/null +++ b/frontend/src/app/command/intelligence/page.tsx @@ -0,0 +1,298 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useStore } from '@/lib/store' +import { api } from '@/lib/api' +import { CommandCenterLayout } from '@/components/CommandCenterLayout' +import { PremiumTable, Badge, StatCard, PageContainer } from '@/components/PremiumTable' +import { + Search, + TrendingUp, + TrendingDown, + Minus, + ChevronRight, + Globe, + ArrowUpDown, + DollarSign, + BarChart3, + RefreshCw, + Bell, + X, +} from 'lucide-react' +import clsx from 'clsx' +import Link from 'next/link' + +interface TLDData { + tld: string + min_price: number + avg_price: number + max_price: number + cheapest_registrar: string + cheapest_registrar_url?: string + price_change_7d?: number + popularity_rank?: number +} + +export default function IntelligencePage() { + const { subscription } = useStore() + + const [tldData, setTldData] = useState([]) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [sortBy, setSortBy] = useState<'popularity' | 'price_asc' | 'price_desc' | 'change'>('popularity') + const [page, setPage] = useState(0) + const [total, setTotal] = useState(0) + + useEffect(() => { + loadTLDData() + }, [page, sortBy]) + + const loadTLDData = async () => { + setLoading(true) + try { + const response = await api.getTldPrices({ + limit: 50, + offset: page * 50, + sort_by: sortBy, + }) + setTldData(response.tlds || []) + setTotal(response.total || 0) + } catch (error) { + console.error('Failed to load TLD data:', error) + } finally { + setLoading(false) + } + } + + const handleRefresh = async () => { + setRefreshing(true) + await loadTLDData() + setRefreshing(false) + } + + // Filter by search + const filteredData = tldData.filter(tld => + tld.tld.toLowerCase().includes(searchQuery.toLowerCase()) + ) + + const getTrendIcon = (change: number | undefined) => { + if (!change || change === 0) return + if (change > 0) return + return + } + + // Calculate stats + const lowestPrice = tldData.length > 0 + ? tldData.reduce((min, tld) => Math.min(min, tld.min_price), Infinity) + : 0.99 + const hottestTld = tldData.find(tld => (tld.price_change_7d || 0) > 0)?.tld || 'com' + + // Dynamic subtitle + const getSubtitle = () => { + if (loading && total === 0) return 'Loading TLD pricing data...' + if (total === 0) return 'No TLD data available' + return `Comparing prices across ${total.toLocaleString()} TLDs` + } + + return ( + + + Refresh + + } + > + + {/* Stats Overview */} +
+ 0 ? total.toLocaleString() : '—'} + subtitle="updated daily" + icon={Globe} + accent + /> + 0 ? `$${lowestPrice.toFixed(2)}` : '—'} + icon={DollarSign} + /> + 0 ? `.${hottestTld}` : '—'} + subtitle="rising prices" + icon={TrendingUp} + /> + +
+ + {/* Filters */} +
+
+ + 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 && ( + + )} +
+
+ + +
+
+ + {/* TLD Table */} + tld.tld} + loading={loading} + onRowClick={(tld) => window.location.href = `/tld-pricing/${tld.tld}`} + emptyIcon={} + 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) => ( + + .{tld.tld} + + ), + }, + { + key: 'min_price', + header: 'Min Price', + align: 'right', + width: '100px', + render: (tld) => ( + ${tld.min_price.toFixed(2)} + ), + }, + { + key: 'avg_price', + header: 'Avg Price', + align: 'right', + width: '100px', + hideOnMobile: true, + render: (tld) => ( + ${tld.avg_price.toFixed(2)} + ), + }, + { + key: 'change', + header: '7d Change', + align: 'right', + width: '120px', + render: (tld) => ( +
+ {getTrendIcon(tld.price_change_7d)} + 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)}% + +
+ ), + }, + { + key: 'registrar', + header: 'Cheapest At', + hideOnMobile: true, + render: (tld) => ( + {tld.cheapest_registrar} + ), + }, + { + key: 'actions', + header: '', + align: 'right', + width: '80px', + render: (tld) => ( +
+ e.stopPropagation()} + title="Set price alert" + > + + + +
+ ), + }, + ]} + /> + + {/* Pagination */} + {total > 50 && ( +
+ + + Page {page + 1} of {Math.ceil(total / 50)} + + +
+ )} +
+
+ ) +} diff --git a/frontend/src/app/command/page.tsx b/frontend/src/app/command/page.tsx new file mode 100644 index 0000000..a4b01b0 --- /dev/null +++ b/frontend/src/app/command/page.tsx @@ -0,0 +1,19 @@ +'use client' + +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' + +export default function CommandPage() { + const router = useRouter() + + useEffect(() => { + router.replace('/command/dashboard') + }, [router]) + + return ( +
+
+
+ ) +} + diff --git a/frontend/src/app/portfolio/page.tsx b/frontend/src/app/command/portfolio/page.tsx similarity index 100% rename from frontend/src/app/portfolio/page.tsx rename to frontend/src/app/command/portfolio/page.tsx diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/command/settings/page.tsx similarity index 99% rename from frontend/src/app/settings/page.tsx rename to frontend/src/app/command/settings/page.tsx index c534437..886602c 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/command/settings/page.tsx @@ -360,7 +360,7 @@ export default function SettingsPage() {

No price alerts set

- + Browse TLD prices →
diff --git a/frontend/src/app/watchlist/page.tsx b/frontend/src/app/command/watchlist/page.tsx similarity index 100% rename from frontend/src/app/watchlist/page.tsx rename to frontend/src/app/command/watchlist/page.tsx diff --git a/frontend/src/app/intelligence/page.tsx b/frontend/src/app/intelligence/page.tsx index 22d623e..c28cebf 100755 --- a/frontend/src/app/intelligence/page.tsx +++ b/frontend/src/app/intelligence/page.tsx @@ -5,7 +5,7 @@ import { useStore } from '@/lib/store' import { api } from '@/lib/api' import { Header } from '@/components/Header' import { Footer } from '@/components/Footer' -import { PremiumTable, Badge, StatCard, PageContainer } from '@/components/PremiumTable' +import { PremiumTable, Badge, StatCard } from '@/components/PremiumTable' import { Search, TrendingUp, @@ -19,8 +19,9 @@ import { RefreshCw, Bell, X, - Sparkles, ArrowRight, + Lock, + Sparkles, } from 'lucide-react' import clsx from 'clsx' import Link from 'next/link' @@ -37,7 +38,7 @@ interface TLDData { } export default function IntelligencePage() { - const { isAuthenticated } = useStore() + const { isAuthenticated, checkAuth } = useStore() const [tldData, setTldData] = useState([]) const [loading, setLoading] = useState(true) @@ -47,6 +48,10 @@ export default function IntelligencePage() { const [page, setPage] = useState(0) const [total, setTotal] = useState(0) + useEffect(() => { + checkAuth() + }, [checkAuth]) + useEffect(() => { loadTLDData() }, [page, sortBy]) @@ -99,23 +104,32 @@ export default function IntelligencePage() { } return ( -
-
- - {/* Hero Section */} -
- {/* Background Effects */} -
-
-
+
+ {/* Background Effects */} +
+
+
+
+
-
-
+
+ +
+
+ {/* Header */} +
-

+ Market Intel +

TLD Intelligence

-

{getSubtitle()}

+

{getSubtitle()}

+ -
) diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index f28d760..30b5018 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -55,7 +55,7 @@ function LoginForm() { const [verified, setVerified] = useState(false) // Get redirect URL from query params - const redirectTo = searchParams.get('redirect') || '/dashboard' + const redirectTo = searchParams.get('redirect') || '/command/dashboard' // Check for verified status useEffect(() => { @@ -113,7 +113,7 @@ function LoginForm() { } // Generate register link with redirect preserved - const registerLink = redirectTo !== '/dashboard' + const registerLink = redirectTo !== '/command/dashboard' ? `/register?redirect=${encodeURIComponent(redirectTo)}` : '/register' diff --git a/frontend/src/app/pricing/page.tsx b/frontend/src/app/pricing/page.tsx index 0a67703..8d1f5b5 100644 --- a/frontend/src/app/pricing/page.tsx +++ b/frontend/src/app/pricing/page.tsx @@ -126,7 +126,7 @@ export default function PricingPage() { } if (!isPaid) { - router.push('/dashboard') + router.push('/command/dashboard') return } diff --git a/frontend/src/components/AdminLayout.tsx b/frontend/src/components/AdminLayout.tsx index 7f8c969..8a2c8ee 100644 --- a/frontend/src/components/AdminLayout.tsx +++ b/frontend/src/components/AdminLayout.tsx @@ -91,7 +91,7 @@ export function AdminLayout({

Access Denied

Admin privileges required