From 02545ffe76313a66787c3fa5d5ac006edd74cd64 Mon Sep 17 00:00:00 2001 From: Yves Gugger Date: Sat, 13 Dec 2025 10:08:05 +0100 Subject: [PATCH] PWA: Radar fullscreen nav + contrast, Watchlist native app experience --- frontend/src/app/layout.tsx | 3 +- frontend/src/app/terminal/radar/page.tsx | 264 +++-- frontend/src/app/terminal/watchlist/page.tsx | 991 +++++++++---------- frontend/src/components/SEO.tsx | 2 +- start.sh | 30 +- 5 files changed, 694 insertions(+), 596 deletions(-) diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 3a4f43a..5b1bd97 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -10,8 +10,7 @@ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.com' export const viewport: Viewport = { width: 'device-width', initialScale: 1, - themeColor: '#020202', - viewportFit: 'cover', + themeColor: '#10b981', } export const metadata: Metadata = { diff --git a/frontend/src/app/terminal/radar/page.tsx b/frontend/src/app/terminal/radar/page.tsx index 1e04154..64f4e8e 100644 --- a/frontend/src/app/terminal/radar/page.tsx +++ b/frontend/src/app/terminal/radar/page.tsx @@ -27,8 +27,13 @@ import { ChevronRight, TrendingUp, RefreshCw, + Menu, + X, Tag, - Coins + Coins, + Shield, + LogOut, + User } from 'lucide-react' import clsx from 'clsx' import Link from 'next/link' @@ -57,39 +62,151 @@ interface SearchResult { } // ============================================================================ -// MOBILE BOTTOM NAV - 5 Items +// FULLSCREEN NAVIGATION MENU // ============================================================================ -function MobileBottomNav({ active }: { active: string }) { +function FullscreenNav({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { + const { user, logout, subscription } = useStore() + + const mainLinks = [ + { label: 'Radar', href: '/terminal/radar', icon: Crosshair, desc: 'Domain search & overview' }, + { label: 'Market', href: '/terminal/market', icon: Gavel, desc: 'Live auctions' }, + { label: 'Watchlist', href: '/terminal/watchlist', icon: Eye, desc: 'Track domains' }, + { label: 'Intel', href: '/terminal/intel', icon: BarChart3, desc: 'TLD pricing data' }, + ] + + const secondaryLinks = [ + { label: 'Sniper', href: '/terminal/sniper', icon: Target }, + { label: 'Yield', href: '/terminal/yield', icon: Coins }, + { label: 'For Sale', href: '/terminal/listing', icon: Tag }, + { label: 'Settings', href: '/terminal/settings', icon: Settings }, + ] + + if (!isOpen) return null + + return ( +
+ {/* Header */} +
+
+
+ +
+ Menu +
+ +
+ + {/* Main Links */} +
+ {mainLinks.map((item) => ( + +
+ +
+
+
{item.label}
+
{item.desc}
+
+ + + ))} +
+ + {/* Secondary Links */} +
+
More
+
+ {secondaryLinks.map((item) => ( + + + {item.label} + + ))} +
+
+ + {/* User Section */} +
+
+
+ +
+
+
{user?.name || user?.email?.split('@')[0]}
+
{subscription?.tier || 'Scout'}
+
+ +
+
+
+ ) +} + +// ============================================================================ +// MOBILE BOTTOM NAV - With Menu Button +// ============================================================================ + +function MobileBottomNav({ active, onMenuOpen }: { active: string; onMenuOpen: () => void }) { const navItems = [ { id: 'radar', label: 'Radar', icon: Crosshair, href: '/terminal/radar' }, { id: 'market', label: 'Market', icon: Gavel, href: '/terminal/market' }, { id: 'watchlist', label: 'Watch', icon: Eye, href: '/terminal/watchlist' }, - { id: 'intel', label: 'Intel', icon: BarChart3, href: '/terminal/intel' }, - { id: 'more', label: 'More', icon: Settings, href: '/terminal/settings' }, ] return ( ) @@ -109,17 +226,17 @@ function MobileHeader({ onRefresh: () => void }) { return ( -
+
-
+
-

Radar

+

Radar

- Live + Live
@@ -128,13 +245,13 @@ function MobileHeader({ @@ -178,22 +295,22 @@ function MobileSearchModal({ return (
{/* Header */} -
+
- + setSearchQuery(e.target.value)} placeholder="Search domain..." - className="w-full h-11 bg-white/10 border border-white/[0.15] pl-11 pr-4 text-white placeholder:text-white/40 outline-none focus:border-accent/50 rounded-lg text-[15px]" + className="w-full h-11 bg-white/10 border border-white/[0.2] pl-11 pr-4 text-white placeholder:text-white/40 outline-none focus:border-accent/50 rounded-xl text-base" autoComplete="off" autoCorrect="off" autoCapitalize="none" @@ -206,33 +323,33 @@ function MobileSearchModal({ {searchResult?.loading && (
- Checking availability... + Checking availability...
)} {searchResult && !searchResult.loading && (
{/* Status Banner */}
{searchResult.is_available ? ( -
+
) : ( -
- +
+
)}
-
{searchResult.domain}
+
{searchResult.domain}
{searchResult.is_available ? 'Available for registration' : 'Already registered'} @@ -240,12 +357,12 @@ function MobileSearchModal({
- {/* Details for taken */} + {/* Details */} {!searchResult.is_available && searchResult.registrar && (
- Registrar - {searchResult.registrar} + Registrar + {searchResult.registrar}
)} @@ -256,7 +373,7 @@ function MobileSearchModal({ onClick={onAddToWatchlist} disabled={addingToWatchlist} className={clsx( - "w-full h-[52px] flex items-center justify-center gap-3 text-[15px] font-semibold rounded-lg transition-colors active:scale-[0.98]", + "w-full h-14 flex items-center justify-center gap-3 text-base font-bold rounded-xl transition-colors active:scale-[0.98]", searchResult.is_available ? "border-2 border-white/30 text-white active:bg-white/10" : "border-2 border-accent text-accent active:bg-accent/10" @@ -274,7 +391,7 @@ function MobileSearchModal({ Register Now @@ -286,10 +403,10 @@ function MobileSearchModal({ {!searchResult && searchQuery.length === 0 && (
-
- +
+
-

Enter a domain to check availability

+

Enter a domain to check availability

)}
@@ -298,7 +415,7 @@ function MobileSearchModal({ } // ============================================================================ -// STAT CARD - Mobile with better contrast +// STAT CARD - Higher Contrast // ============================================================================ function StatCard({ label, value, highlight, icon: Icon }: { @@ -308,13 +425,13 @@ function StatCard({ label, value, highlight, icon: Icon }: { icon: any }) { return ( -
+
- - {label} + + {label}
{value} @@ -324,7 +441,7 @@ function StatCard({ label, value, highlight, icon: Icon }: { } // ============================================================================ -// AUCTION CARD - Mobile with better contrast +// AUCTION CARD - Higher Contrast // ============================================================================ function AuctionCard({ auction }: { auction: HotAuction }) { @@ -332,19 +449,20 @@ function AuctionCard({ auction }: { auction: HotAuction }) { -
- {auction.platform.substring(0, 2).toUpperCase()} +
+ {auction.platform.substring(0, 2).toUpperCase()}
-
{auction.domain}
-
{auction.time_remaining}
+
{auction.domain}
+
{auction.time_remaining}
-
${auction.current_bid.toLocaleString()}
+
${auction.current_bid.toLocaleString()}
+
Current bid
- +
) } @@ -392,6 +510,7 @@ export default function RadarPage() { const [addingToWatchlist, setAddingToWatchlist] = useState(false) const [searchFocused, setSearchFocused] = useState(false) const [mobileSearchOpen, setMobileSearchOpen] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) const searchInputRef = useRef(null) // Load Data @@ -484,6 +603,9 @@ export default function RadarPage() { return ( <> + {/* Fullscreen Navigation Menu */} + setMenuOpen(false)} /> + {/* Mobile Header */} setMobileSearchOpen(true)} @@ -503,7 +625,7 @@ export default function RadarPage() { /> {/* Mobile Content */} -
+
{toast && } {/* Stats Grid */} @@ -518,14 +640,14 @@ export default function RadarPage() { {availableDomains.length > 0 && (
-
+
-
{availableDomains.length} Domain{availableDomains.length > 1 ? 's' : ''} Available!
-
Grab them now
+
{availableDomains.length} Domain{availableDomains.length > 1 ? 's' : ''} Available!
+
Grab them now
- +
@@ -534,12 +656,12 @@ export default function RadarPage() { {/* Section: Live Auctions */}
-
+
- Live Auctions + Live Auctions
- + See all
@@ -555,18 +677,18 @@ export default function RadarPage() { ))}
) : ( -
- -

No active auctions

+
+ +

No active auctions

)}
{/* Section: Quick Links */}
-
- - Quick Actions +
+ + Quick Actions
@@ -579,19 +701,19 @@ export default function RadarPage() { -
{item.label}
-
{item.desc}
+
{item.label}
+
{item.desc}
))}
- {/* Mobile Bottom Nav */} - + {/* Mobile Bottom Nav with Menu Button */} + setMenuOpen(true)} /> {/* ═══════════════════════════════════════════════════════════════════════ */} {/* DESKTOP LAYOUT */} diff --git a/frontend/src/app/terminal/watchlist/page.tsx b/frontend/src/app/terminal/watchlist/page.tsx index 1f405ae..d94d215 100755 --- a/frontend/src/app/terminal/watchlist/page.tsx +++ b/frontend/src/app/terminal/watchlist/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState, useMemo, useCallback } from 'react' +import { useEffect, useState, useMemo, useCallback, useRef } from 'react' import { useStore } from '@/lib/store' import { api, DomainHealthReport, HealthStatus } from '@/lib/api' import { CommandCenterLayout } from '@/components/CommandCenterLayout' @@ -25,11 +25,20 @@ import { Calendar, Shield, Crosshair, + Menu, + Search, Gavel, BarChart3, Settings, + Tag, + Coins, + LogOut, + User, ChevronRight, - MoreHorizontal + Clock, + Wifi, + WifiOff, + Lock } from 'lucide-react' import clsx from 'clsx' import Link from 'next/link' @@ -51,48 +60,139 @@ function formatExpiryDate(expirationDate: string | null): string { return new Date(expirationDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) } -const healthConfig: Record = { - healthy: { label: 'Healthy', color: 'text-accent', bgMobile: 'bg-accent/15 border-accent/40' }, - weakening: { label: 'Weak', color: 'text-amber-400', bgMobile: 'bg-amber-500/15 border-amber-500/40' }, - parked: { label: 'Parked', color: 'text-blue-400', bgMobile: 'bg-blue-500/15 border-blue-500/40' }, - critical: { label: 'Critical', color: 'text-rose-400', bgMobile: 'bg-rose-500/15 border-rose-500/40' }, - unknown: { label: '—', color: 'text-white/30', bgMobile: 'bg-white/10 border-white/20' }, +function getTimeAgo(date: string | null): string { + if (!date) return 'Never' + const now = new Date() + const past = new Date(date) + const diffMs = now.getTime() - past.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + if (diffMins < 1) return 'Just now' + if (diffMins < 60) return `${diffMins}m ago` + if (diffHours < 24) return `${diffHours}h ago` + return `${diffDays}d ago` +} + +const healthConfig: Record = { + healthy: { label: 'Healthy', color: 'text-accent', bg: 'bg-accent/10' }, + weakening: { label: 'Weak', color: 'text-amber-400', bg: 'bg-amber-500/10' }, + parked: { label: 'Parked', color: 'text-blue-400', bg: 'bg-blue-500/10' }, + critical: { label: 'Critical', color: 'text-rose-400', bg: 'bg-rose-500/10' }, + unknown: { label: 'Unknown', color: 'text-white/40', bg: 'bg-white/5' }, +} + +// ============================================================================ +// FULLSCREEN NAVIGATION (reuse) +// ============================================================================ + +function FullscreenNav({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { + const { user, logout, subscription } = useStore() + + const mainLinks = [ + { label: 'Radar', href: '/terminal/radar', icon: Crosshair, desc: 'Search & overview' }, + { label: 'Market', href: '/terminal/market', icon: Gavel, desc: 'Live auctions' }, + { label: 'Watchlist', href: '/terminal/watchlist', icon: Eye, desc: 'Track domains' }, + { label: 'Intel', href: '/terminal/intel', icon: BarChart3, desc: 'TLD pricing' }, + ] + + const secondaryLinks = [ + { label: 'Sniper', href: '/terminal/sniper', icon: Target }, + { label: 'Yield', href: '/terminal/yield', icon: Coins }, + { label: 'For Sale', href: '/terminal/listing', icon: Tag }, + { label: 'Settings', href: '/terminal/settings', icon: Settings }, + ] + + if (!isOpen) return null + + return ( +
+
+
+
+ +
+ Menu +
+ +
+ +
+ {mainLinks.map((item) => ( + +
+ +
+
+
{item.label}
+
{item.desc}
+
+ + + ))} +
+ +
+
More
+
+ {secondaryLinks.map((item) => ( + + + {item.label} + + ))} +
+
+ +
+
+
+ +
+
+
{user?.name || user?.email?.split('@')[0]}
+
{subscription?.tier || 'Scout'}
+
+ +
+
+
+ ) } // ============================================================================ // MOBILE BOTTOM NAV // ============================================================================ -function MobileBottomNav({ active }: { active: string }) { +function MobileBottomNav({ active, onMenuOpen }: { active: string; onMenuOpen: () => void }) { const navItems = [ { id: 'radar', label: 'Radar', icon: Crosshair, href: '/terminal/radar' }, { id: 'market', label: 'Market', icon: Gavel, href: '/terminal/market' }, { id: 'watchlist', label: 'Watch', icon: Eye, href: '/terminal/watchlist' }, - { id: 'intel', label: 'Intel', icon: BarChart3, href: '/terminal/intel' }, - { id: 'more', label: 'More', icon: Settings, href: '/terminal/settings' }, ] return ( ) @@ -103,46 +203,39 @@ function MobileBottomNav({ active }: { active: string }) { // ============================================================================ function MobileHeader({ - stats, onAddOpen, + onRefreshAll, isRefreshing, - onRefresh + availableCount }: { - stats: { total: number; available: number; expiring: number } onAddOpen: () => void + onRefreshAll: () => void isRefreshing: boolean - onRefresh: () => void + availableCount: number }) { return ( -
+
-
+
+ {availableCount > 0 && ( +
+ {availableCount} +
+ )}
-

Watchlist

-
- {stats.total} tracking - {stats.available > 0 && ( - • {stats.available} ready - )} -
+

Watchlist

+ Tracking domains
- -
@@ -152,58 +245,60 @@ function MobileHeader({ } // ============================================================================ -// MOBILE ADD DOMAIN MODAL +// MOBILE ADD MODAL // ============================================================================ -function MobileAddDomainModal({ - isOpen, - onClose, - value, - setValue, - isAdding, - onAdd -}: { +function MobileAddModal({ isOpen, onClose, onAdd, adding }: { isOpen: boolean onClose: () => void - value: string - setValue: (v: string) => void - isAdding: boolean - onAdd: () => void + onAdd: (domain: string) => Promise + adding: boolean }) { + const [value, setValue] = useState('') + const inputRef = useRef(null) + + useEffect(() => { + if (isOpen) setTimeout(() => inputRef.current?.focus(), 100) + }, [isOpen]) + + const handleSubmit = async () => { + if (!value.trim()) return + await onAdd(value.trim()) + setValue('') + onClose() + } + if (!isOpen) return null return (
-
- -

Add Domain

-
+
+ + Add Domain +
-
- setValue(e.target.value)} - placeholder="example.com" - autoFocus - className="w-full h-14 bg-white/10 border-2 border-white/[0.15] px-4 text-[17px] text-white placeholder:text-white/40 outline-none focus:border-accent/50 rounded-xl mb-4" - autoComplete="off" - autoCorrect="off" - autoCapitalize="none" - /> +
+
+ + setValue(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSubmit()} + placeholder="example.com" + className="w-full h-14 bg-white/10 border border-white/[0.2] pl-12 pr-4 text-white text-lg placeholder:text-white/40 outline-none focus:border-accent/50 rounded-xl" + autoComplete="off" + autoCorrect="off" + autoCapitalize="none" + /> +
- - -

- We'll monitor this domain and notify you when it becomes available +

+ Enter a domain name to start tracking

@@ -214,143 +309,107 @@ function MobileAddDomainModal({ // MOBILE DOMAIN CARD // ============================================================================ -function MobileDomainCard({ - domain, - health, - onRefresh, - onDelete, +function MobileDomainCard({ + domain, + health, + loadingHealth, + onRefresh, + onDelete, onToggleNotify, - onViewHealth, - isRefreshing, - isDeleting, - isTogglingNotify -}: { + onHealthClick, + refreshing, + deleting, + togglingNotify +}: { domain: any - health: DomainHealthReport | undefined + health?: DomainHealthReport + loadingHealth: boolean onRefresh: () => void onDelete: () => void onToggleNotify: () => void - onViewHealth: () => void - isRefreshing: boolean - isDeleting: boolean - isTogglingNotify: boolean + onHealthClick: () => void + refreshing: boolean + deleting: boolean + togglingNotify: boolean }) { + const config = healthConfig[health?.status || 'unknown'] const days = getDaysUntilExpiry(domain.expiration_date) - const healthStatus = health?.status || 'unknown' - const config = healthConfig[healthStatus] const isExpiringSoon = days !== null && days <= 30 && days > 0 return ( -
+
{/* Main Content */}
-
+
-
-
{domain.name}
-
- - {domain.is_available ? 'Available!' : 'Taken'} - - {isExpiringSoon && ( - - {days}d left - - )} -
-
+ {domain.name}
- - + + {domain.is_available ? 'Available!' : 'Taken'} +
{/* Info Row */} -
- {domain.expiration_date && ( -
- - {formatExpiryDate(domain.expiration_date)} -
- )} - {domain.registrar && ( -
- - {domain.registrar} -
- )} -
- - {/* Health Button */} - + + + + {/* Expiry */} +
+ + + {formatExpiryDate(domain.expiration_date)} +
- - +
{/* Actions */}
- - - - Visit - - + +
+ +
@@ -358,137 +417,128 @@ function MobileDomainCard({ } // ============================================================================ -// MOBILE HEALTH DETAIL SHEET +// MOBILE HEALTH MODAL // ============================================================================ -function MobileHealthSheet({ - domain, +function MobileHealthModal({ + isOpen, + onClose, + domain, health, - isOpen, - onClose, onRefresh, - isLoading -}: { - domain: any - health: DomainHealthReport | null + loading +}: { isOpen: boolean onClose: () => void + domain: any + health?: DomainHealthReport onRefresh: () => void - isLoading: boolean + loading: boolean }) { if (!isOpen || !domain) return null - const config = healthConfig[health?.status || 'unknown'] + const checks = [ + { + label: 'DNS Resolution', + ok: health?.dns?.has_a || health?.dns?.has_ns, + icon: Globe, + detail: health?.dns?.error || (health?.dns?.has_a ? 'A record found' : 'No A record') + }, + { + label: 'HTTP Reachable', + ok: health?.http?.is_reachable, + icon: Wifi, + detail: health?.http?.error || (health?.http?.status_code ? `Status ${health.http.status_code}` : 'Not checked') + }, + { + label: 'SSL Certificate', + ok: health?.ssl?.has_certificate, + icon: Lock, + detail: health?.ssl?.error || (health?.ssl?.has_certificate ? 'Valid SSL' : 'No SSL') + }, + { + label: 'Not Parked', + ok: !(health?.dns?.is_parked || health?.http?.is_parked), + icon: Shield, + detail: (health?.dns?.is_parked || health?.http?.is_parked) ? 'Domain is parked' : 'Active site' + }, + ] + + const score = health?.score ?? 0 return ( -
-
-
e.stopPropagation()} - > - {/* Handle */} -
-
-
+
+
+ + Health Report + +
- {/* Header */} -
-
-
-

{domain.name}

-
- {config.label} - {health?.score !== undefined && ( - Score: {health.score}/100 - )} -
-
- +
+ {/* Domain Header */} +
+
{domain.name}
+
= 75 ? "bg-accent/15 text-accent" : + score >= 50 ? "bg-amber-500/15 text-amber-400" : + "bg-rose-500/15 text-rose-400" + )}> + + Score: {score}/100
{/* Checks */} -
- {health ? ( - <> - - - - - - {(health.http?.error === 'timeout' || health.ssl?.error?.includes('timeout')) && ( -
-

- ⚠️ Network issue: Server could not reach this domain -

-
+
+ {checks.map((check, i) => ( +
+
+ +
+
+
{check.label}
+
{check.detail}
+
+ {check.ok ? ( + + ) : ( + )} - - ) : ( -
- No health data yet
- )} + ))}
- {/* Refresh Button */} -
- -
+ {/* Warning */} + {health?.http?.error === 'timeout' && ( +
+
+ +
+
Network Issue
+
+ Could not reach domain from our servers. This may be a temporary issue or firewall restriction. +
+
+
+
+ )}
) } -function HealthCheckRow({ label, ok, error }: { label: string; ok?: boolean; error?: string }) { - return ( -
-
- {label} - {error &&

{error}

} -
- {ok ? ( - - ) : error ? ( - - ) : ( - - )} -
- ) -} - // ============================================================================ // MAIN PAGE // ============================================================================ export default function WatchlistPage() { - const { domains, fetchDomains, addDomain, deleteDomain, refreshDomain, updateDomain } = useStore() + const { domains, addDomain, deleteDomain, refreshDomain, updateDomain } = useStore() const { toast, showToast, hideToast } = useToast() const [newDomain, setNewDomain] = useState('') @@ -498,11 +548,11 @@ export default function WatchlistPage() { const [togglingNotifyId, setTogglingNotifyId] = useState(null) const [healthReports, setHealthReports] = useState>({}) const [loadingHealth, setLoadingHealth] = useState>({}) + const [selectedDomain, setSelectedDomain] = useState(null) const [filter, setFilter] = useState<'all' | 'available' | 'expiring'>('all') - - // Mobile states - const [showAddModal, setShowAddModal] = useState(false) - const [selectedDomainId, setSelectedDomainId] = useState(null) + const [menuOpen, setMenuOpen] = useState(false) + const [addModalOpen, setAddModalOpen] = useState(false) + const [healthModalOpen, setHealthModalOpen] = useState(false) const [isRefreshingAll, setIsRefreshingAll] = useState(false) // Stats @@ -533,40 +583,24 @@ export default function WatchlistPage() { }) }, [domains, filter]) - // Load health on mount - useEffect(() => { - if (!domains?.length) return - domains.forEach(domain => { - if (!healthReports[domain.id] && !loadingHealth[domain.id]) { - setLoadingHealth(prev => ({ ...prev, [domain.id]: true })) - api.getDomainHealth(domain.id, { refresh: false }) - .then(report => setHealthReports(prev => ({ ...prev, [domain.id]: report }))) - .catch(() => {}) - .finally(() => setLoadingHealth(prev => ({ ...prev, [domain.id]: false }))) - } - }) - }, [domains]) - // Handlers - const handleAdd = useCallback(async () => { - if (!newDomain.trim()) return + const handleAdd = useCallback(async (domainName: string) => { setAdding(true) try { - await addDomain(newDomain.trim().toLowerCase()) - showToast(`Added: ${newDomain.trim()}`, 'success') - setNewDomain('') - setShowAddModal(false) + await addDomain(domainName.toLowerCase()) + showToast(`Added: ${domainName}`, 'success') } catch (err: any) { showToast(err.message || 'Failed', 'error') } finally { setAdding(false) } - }, [newDomain, addDomain, showToast]) + }, [addDomain, showToast]) const handleRefresh = useCallback(async (id: number) => { setRefreshingId(id) try { await refreshDomain(id) + // Also refresh health const report = await api.getDomainHealth(id, { refresh: true }) setHealthReports(prev => ({ ...prev, [id]: report })) showToast('Updated', 'success') @@ -577,11 +611,13 @@ export default function WatchlistPage() { const handleRefreshAll = useCallback(async () => { setIsRefreshingAll(true) try { - await fetchDomains() - showToast('All domains refreshed', 'success') - } catch { showToast('Failed', 'error') } + for (const domain of (domains || [])) { + await refreshDomain(domain.id) + } + showToast('All refreshed', 'success') + } catch {} finally { setIsRefreshingAll(false) } - }, [fetchDomains, showToast]) + }, [domains, refreshDomain, showToast]) const handleDelete = useCallback(async (id: number, name: string) => { setDeletingId(id) @@ -597,7 +633,7 @@ export default function WatchlistPage() { try { await api.updateDomainNotify(id, !current) updateDomain(id, { notify_on_available: !current }) - showToast(!current ? 'Alerts on' : 'Alerts off', 'success') + showToast(!current ? 'Alert on' : 'Alert off', 'success') } catch { showToast('Failed', 'error') } finally { setTogglingNotifyId(null) } }, [updateDomain, showToast]) @@ -612,77 +648,124 @@ export default function WatchlistPage() { finally { setLoadingHealth(prev => ({ ...prev, [id]: false })) } }, [loadingHealth]) - const selectedDomain = selectedDomainId ? domains?.find(d => d.id === selectedDomainId) : null - const selectedHealth = selectedDomainId ? healthReports[selectedDomainId] : null + // Load health + useEffect(() => { + const load = async () => { + if (!domains?.length) return + try { + const data = await api.getDomainsHealthCache() + if (data?.reports) setHealthReports(prev => ({ ...prev, ...data.reports })) + } catch {} + } + load() + }, [domains]) + + // Auto-trigger health for new domains + useEffect(() => { + if (!domains?.length) return + domains.forEach(domain => { + if (!healthReports[domain.id] && !loadingHealth[domain.id]) { + setLoadingHealth(prev => ({ ...prev, [domain.id]: true })) + api.getDomainHealth(domain.id, { refresh: false }) + .then(report => setHealthReports(prev => ({ ...prev, [domain.id]: report }))) + .catch(() => {}) + .finally(() => setLoadingHealth(prev => ({ ...prev, [domain.id]: false }))) + } + }) + }, [domains]) + + const selectedDomainData = selectedDomain ? domains?.find(d => d.id === selectedDomain) : null + const selectedHealth = selectedDomain ? healthReports[selectedDomain] : null return ( <> + {/* Fullscreen Navigation */} + setMenuOpen(false)} /> + {/* Mobile Header */} setShowAddModal(true)} + onAddOpen={() => setAddModalOpen(true)} + onRefreshAll={handleRefreshAll} isRefreshing={isRefreshingAll} - onRefresh={handleRefreshAll} + availableCount={stats.available} /> {/* Mobile Add Modal */} - setShowAddModal(false)} - value={newDomain} - setValue={setNewDomain} - isAdding={adding} + setAddModalOpen(false)} onAdd={handleAdd} + adding={adding} /> - {/* Mobile Health Sheet */} - setHealthModalOpen(false)} + domain={selectedDomainData} health={selectedHealth} - isOpen={selectedDomainId !== null} - onClose={() => setSelectedDomainId(null)} - onRefresh={() => selectedDomainId && handleHealthCheck(selectedDomainId)} - isLoading={selectedDomainId ? loadingHealth[selectedDomainId] : false} + onRefresh={() => selectedDomain && handleHealthCheck(selectedDomain)} + loading={selectedDomain ? loadingHealth[selectedDomain] || false : false} /> - {/* Mobile Content */} -
+ {/* ═══════════════════════════════════════════════════════════════════════ */} + {/* MOBILE CONTENT */} + {/* ═══════════════════════════════════════════════════════════════════════ */} +
{toast && } - - {/* Filters */} -
+ + {/* Stats */} +
{[ - { value: 'all', label: 'All', count: stats.total }, - { value: 'available', label: 'Available', count: stats.available }, - { value: 'expiring', label: 'Expiring', count: stats.expiring }, + { label: 'Total', value: stats.total, active: filter === 'all' }, + { label: 'Available', value: stats.available, active: filter === 'available', highlight: stats.available > 0 }, + { label: 'Expiring', value: stats.expiring, active: filter === 'expiring', warning: stats.expiring > 0 }, ].map((item) => ( ))}
- {/* Domain List */} -
- {filteredDomains.length === 0 ? ( -
-
- + {/* Available Banner */} + {stats.available > 0 && ( +
+
+
+
-

No domains yet

-
+
+ )} + + {/* Domains List */} +
+ {filteredDomains.length === 0 ? ( +
+
+ +
+

No domains yet

+
@@ -692,13 +775,14 @@ export default function WatchlistPage() { key={domain.id} domain={domain} health={healthReports[domain.id]} + loadingHealth={loadingHealth[domain.id] || false} onRefresh={() => handleRefresh(domain.id)} onDelete={() => handleDelete(domain.id, domain.name)} onToggleNotify={() => handleToggleNotify(domain.id, domain.notify_on_available)} - onViewHealth={() => setSelectedDomainId(domain.id)} - isRefreshing={refreshingId === domain.id} - isDeleting={deletingId === domain.id} - isTogglingNotify={togglingNotifyId === domain.id} + onHealthClick={() => { setSelectedDomain(domain.id); setHealthModalOpen(true) }} + refreshing={refreshingId === domain.id} + deleting={deletingId === domain.id} + togglingNotify={togglingNotifyId === domain.id} /> )) )} @@ -706,7 +790,7 @@ export default function WatchlistPage() {
{/* Mobile Bottom Nav */} - + setMenuOpen(true)} /> {/* ═══════════════════════════════════════════════════════════════════════ */} {/* DESKTOP LAYOUT */} @@ -715,7 +799,7 @@ export default function WatchlistPage() { {toast && } - {/* Header */} + {/* HEADER */}
@@ -741,9 +825,9 @@ export default function WatchlistPage() {
- {/* Add Domain */} + {/* ADD DOMAIN */}
-
{ e.preventDefault(); handleAdd() }} className="relative max-w-xl"> + { e.preventDefault(); if (newDomain.trim()) handleAdd(newDomain.trim()).then(() => setNewDomain('')) }} className="relative max-w-xl">
- {/* Filters */} + {/* FILTERS */}
{[ @@ -777,9 +861,7 @@ export default function WatchlistPage() { onClick={() => setFilter(item.value as typeof filter)} className={clsx( "px-4 py-2 text-xs font-medium transition-colors", - filter === item.value - ? "bg-white/10 text-white" - : "text-white/40 hover:text-white/60" + filter === item.value ? "bg-white/10 text-white" : "text-white/40 hover:text-white/60" )} > {item.label} ({item.count}) @@ -788,18 +870,18 @@ export default function WatchlistPage() {
- {/* Table */} + {/* TABLE */}
{!filteredDomains.length ? (
-
+

No domains in your watchlist

) : (
-
+
Domain
Status
Health
@@ -810,21 +892,14 @@ export default function WatchlistPage() { {filteredDomains.map((domain) => { const health = healthReports[domain.id] - const healthStatus = health?.status || 'unknown' - const config = healthConfig[healthStatus] + const config = healthConfig[health?.status || 'unknown'] const days = getDaysUntilExpiry(domain.expiration_date) return ( -
+
-
+
- + {domain.is_available ? 'Available' : 'Taken'}
- -
- {days !== null && days <= 30 && days > 0 ? ( - {days} days - ) : ( - formatExpiryDate(domain.expiration_date) - )} +
+ {formatExpiryDate(domain.expiration_date)}
- -
- -
@@ -906,74 +944,13 @@ export default function WatchlistPage() {
)}
- - {/* Desktop Health Modal */} - {selectedDomain && ( -
setSelectedDomainId(null)}> -
e.stopPropagation()}> -
-
- - Health Report -
- -
- -
-
-

{selectedDomain.name}

-
- - {healthConfig[selectedHealth?.status || 'unknown'].label} - - {selectedHealth?.score !== undefined && ( - Score: {selectedHealth.score}/100 - )} -
-
- - {selectedHealth ? ( -
- - - - -
- ) : ( -
- Click to run health check -
- )} - - -
-
-
- )}
- + ) diff --git a/frontend/src/components/SEO.tsx b/frontend/src/components/SEO.tsx index 21cd18b..afcefc6 100644 --- a/frontend/src/components/SEO.tsx +++ b/frontend/src/components/SEO.tsx @@ -93,7 +93,7 @@ export function SEO({ - + ) } diff --git a/start.sh b/start.sh index 699ca04..8974c15 100755 --- a/start.sh +++ b/start.sh @@ -20,7 +20,7 @@ NC='\033[0m' # No Color # Funktion zum Beenden von Prozessen stop_services() { - echo "" +echo "" echo -e "${YELLOW}🛑 Beende laufende Prozesse...${NC}" # Backend (uvicorn) - mehrere Versuche @@ -50,9 +50,9 @@ stop_services() { if lsof -i:3000 >/dev/null 2>&1; then echo -e "${RED}✗ Port 3000 ist noch belegt!${NC}" lsof -i:3000 - exit 1 - fi - + exit 1 +fi + echo -e "${GREEN}✓ Alle Prozesse beendet, Ports frei${NC}" } @@ -69,14 +69,14 @@ start_backend() { exit 1 fi - source venv/bin/activate +source venv/bin/activate # Lösche altes Log > backend.log # Starte uvicorn im Hintergrund nohup uvicorn app.main:app --host 0.0.0.0 --port 8000 > backend.log 2>&1 & - BACKEND_PID=$! +BACKEND_PID=$! echo "Backend PID: $BACKEND_PID" @@ -107,15 +107,15 @@ start_frontend() { # Prüfe ob .next existiert if [ ! -d ".next" ]; then echo -e "${RED}✗ .next nicht gefunden! Bitte erst 'npm run build' ausführen.${NC}" - exit 1 - fi - + exit 1 +fi + # Lösche altes Log > frontend.log # Starte Frontend im Hintergrund PORT=3000 nohup npm start > frontend.log 2>&1 & - FRONTEND_PID=$! +FRONTEND_PID=$! echo "Frontend PID: $FRONTEND_PID" @@ -139,8 +139,8 @@ start_frontend() { fi echo -n "." done - - echo "" + +echo "" echo -e "${RED}✗ Frontend konnte nicht gestartet werden${NC}" echo "Letzte 30 Zeilen vom Log:" tail -30 frontend.log @@ -165,15 +165,15 @@ show_status() { echo "" echo "Laufende Prozesse:" ps aux | grep -E "(uvicorn|next start)" | grep -v grep | awk '{print " PID " $2 ": " $11 " " $12 " " $13}' - echo "" +echo "" echo "Ports:" lsof -i:8000 -i:3000 2>/dev/null | grep LISTEN || echo " Keine Port-Info verfügbar" - echo "" +echo "" } # Funktion zum Testen der Services test_services() { - echo "" +echo "" echo -e "${YELLOW}🧪 Teste Services...${NC}" # Test Backend Health