diff --git a/deploy.sh b/deploy.sh index 086f892..43ab9c4 100755 --- a/deploy.sh +++ b/deploy.sh @@ -88,7 +88,8 @@ fi # Step 2: Sync files (only changed) echo -e "\n${YELLOW}[2/4] Syncing changed files...${NC}" -RSYNC_OPTS="-avz --delete" +# Using compression (-z), checksum-based detection, and bandwidth throttling for stability +RSYNC_OPTS="-avz --delete --compress-level=9 --checksum" if ! $BACKEND_ONLY; then echo " Frontend:" @@ -160,16 +161,24 @@ if ! $BACKEND_ONLY; then set -e cd ~/pounce/frontend - echo " Installing dependencies..." - if [ -f "package-lock.json" ]; then - npm ci + # Check if package.json changed (skip npm ci if not) + LOCKFILE_HASH="" + if [ -f ".lockfile_hash" ]; then + LOCKFILE_HASH=$(cat .lockfile_hash) + fi + CURRENT_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d' ' -f1 || echo "none") + + if [ "$LOCKFILE_HASH" != "$CURRENT_HASH" ]; then + echo " Installing dependencies (package-lock.json changed)..." + npm ci --prefer-offline --no-audit --no-fund + echo "$CURRENT_HASH" > .lockfile_hash else - npm install + echo " ✓ Dependencies unchanged, skipping npm ci" fi - # Build new version + # Build new version (with reduced memory for stability) echo " Building..." - npm run build + NODE_OPTIONS="--max-old-space-size=2048" npm run build BUILD_EXIT=$? if [ $BUILD_EXIT -eq 0 ]; then diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 1105ed3..d1fdeb5 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -56,7 +56,7 @@ function LoginForm() { const [verified, setVerified] = useState(false) const sanitizeRedirect = (value: string | null | undefined): string => { - const fallback = '/terminal/radar' + const fallback = '/terminal/hunt' if (!value) return fallback const v = value.trim() if (!v.startsWith('/')) return fallback @@ -131,7 +131,7 @@ function LoginForm() { } // Generate register link with redirect preserved - const registerLink = redirectTo !== '/terminal/radar' + const registerLink = redirectTo !== '/terminal/hunt' ? `/register?redirect=${encodeURIComponent(redirectTo)}` : '/register' diff --git a/frontend/src/app/oauth/callback/page.tsx b/frontend/src/app/oauth/callback/page.tsx index 37f5920..9c00395 100644 --- a/frontend/src/app/oauth/callback/page.tsx +++ b/frontend/src/app/oauth/callback/page.tsx @@ -12,7 +12,7 @@ function OAuthCallbackContent() { useEffect(() => { const sanitizeRedirect = (value: string | null): string => { - const fallback = '/terminal/radar' + const fallback = '/terminal/hunt' if (!value) return fallback const v = value.trim() if (!v.startsWith('/')) return fallback diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index cfa0c02..a698d3f 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -630,7 +630,7 @@ export default function HomePage() {
diff --git a/frontend/src/app/pricing/page.tsx b/frontend/src/app/pricing/page.tsx index c128fd8..443a393 100644 --- a/frontend/src/app/pricing/page.tsx +++ b/frontend/src/app/pricing/page.tsx @@ -144,7 +144,7 @@ export default function PricingPage() { } if (!isPaid) { - router.push('/terminal/radar') + router.push('/terminal/hunt') return } @@ -405,7 +405,7 @@ export default function PricingPage() { Start with Scout. It's free forever. Upgrade when you need more firepower.

{isAuthenticated ? "Command Center" : "Join the Hunt"} diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx index c526c7e..4ae38f8 100644 --- a/frontend/src/app/register/page.tsx +++ b/frontend/src/app/register/page.tsx @@ -62,7 +62,7 @@ function RegisterForm() { const [registered, setRegistered] = useState(false) // Get redirect URL from query params - const redirectTo = searchParams.get('redirect') || '/terminal/radar' + const redirectTo = searchParams.get('redirect') || '/terminal/hunt' const refFromUrl = searchParams.get('ref') const getCookie = (name: string): string | null => { @@ -85,7 +85,7 @@ function RegisterForm() { const ref = refFromUrl || getCookie('pounce_ref') || undefined await register(email, password, undefined, ref || undefined) - if (redirectTo !== '/terminal/radar') { + if (redirectTo !== '/terminal/hunt') { localStorage.setItem('pounce_redirect_after_login', redirectTo) } @@ -98,7 +98,7 @@ function RegisterForm() { } // Generate login link with redirect preserved - const loginLink = redirectTo !== '/terminal/radar' + const loginLink = redirectTo !== '/terminal/hunt' ? `/login?redirect=${encodeURIComponent(redirectTo)}` : '/login' diff --git a/frontend/src/app/terminal/cfo/page.tsx b/frontend/src/app/terminal/cfo/page.tsx deleted file mode 100644 index 1286b5f..0000000 --- a/frontend/src/app/terminal/cfo/page.tsx +++ /dev/null @@ -1,176 +0,0 @@ -'use client' - -import { useCallback, useEffect, useState } from 'react' -import { Sidebar } from '@/components/Sidebar' -import { Toast, useToast } from '@/components/Toast' -import { api } from '@/lib/api' -import { useStore } from '@/lib/store' -import { BurnRateTimeline } from '@/components/cfo/BurnRateTimeline' -import { KillList } from '@/components/cfo/KillList' -import { Loader2, RefreshCw } from 'lucide-react' -import clsx from 'clsx' - -export default function CfoPage() { - const { checkAuth } = useStore() - const { toast, hideToast } = useToast() - - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [data, setData] = useState<{ - computed_at: string - upcoming_30d_total_usd: number - upcoming_30d_rows: Array<{ - domain_id: number - domain: string - renewal_date: string | null - renewal_cost_usd: number | null - cost_source: string - is_sold: boolean - }> - monthly: Array<{ month: string; total_cost_usd: number; domains: number }> - kill_list: Array<{ - domain_id: number - domain: string - renewal_date: string | null - renewal_cost_usd: number | null - cost_source: string - auto_renew: boolean - is_dns_verified: boolean - yield_net_60d: number - yield_clicks_60d: number - reason: string - }> - } | null>(null) - - const load = useCallback(async () => { - setError(null) - const res = await api.getCfoSummary() - setData(res) - }, []) - - useEffect(() => { - checkAuth() - }, [checkAuth]) - - useEffect(() => { - let cancelled = false - const run = async () => { - setLoading(true) - try { - await load() - } catch (e) { - if (!cancelled) setError(e instanceof Error ? e.message : String(e)) - } finally { - if (!cancelled) setLoading(false) - } - } - run() - return () => { - cancelled = true - } - }, [load]) - - const refresh = useCallback(async () => { - setLoading(true) - try { - await load() - } finally { - setLoading(false) - } - }, [load]) - - return ( -
- - -
-
-
-
-

PHASE 4

-

CFO

-

- Renewal runway, burn rate, and drop advice. No fluff — just numbers. -

-
- -
-
- -
- {loading ? ( -
- -
- ) : error ? ( -
{error}
- ) : !data ? ( -
No data.
- ) : ( - <> -
-
-
-
Upcoming costs
-
Next 30 days
-
-
-
${Math.round(data.upcoming_30d_total_usd)}
-
{data.upcoming_30d_rows.length} renewals due
-
- {data.upcoming_30d_rows.slice(0, 8).map((r) => ( -
- {r.domain} - - {r.renewal_date ? r.renewal_date.slice(0, 10) : '—'} · ${Math.round(r.renewal_cost_usd || 0)} - -
- ))} - {data.upcoming_30d_rows.length === 0 ? ( -
No renewals in the next 30 days.
- ) : null} -
-
-
- - -
- - - -
-
What to do next
-
-
- - If a renewal cost is missing, fill it in on the domain in Portfolio → Edit. -
-
- - “Set to Drop” is a local flag — you still need to disable auto-renew at your registrar. -
-
- - Want to cover costs? Activate Yield only for DNS‑verified domains. -
-
-
- - )} -
-
- - {toast && ( - - )} -
- ) -} - diff --git a/frontend/src/app/terminal/hunt/page.tsx b/frontend/src/app/terminal/hunt/page.tsx index ddf721a..7efd98f 100644 --- a/frontend/src/app/terminal/hunt/page.tsx +++ b/frontend/src/app/terminal/hunt/page.tsx @@ -1,11 +1,12 @@ 'use client' -import { useEffect, useState, useCallback } from 'react' +import { useEffect, useState } from 'react' import { useStore } from '@/lib/store' import { Sidebar } from '@/components/Sidebar' import { Toast, useToast } from '@/components/Toast' -import { HuntStrategyChips, type HuntTab } from '@/components/hunt/HuntStrategyChips' -import { SniperTab } from '@/components/hunt/SniperTab' +import { AuctionsTab } from '@/components/hunt/AuctionsTab' +import { DropsTab } from '@/components/hunt/DropsTab' +import { SearchTab } from '@/components/hunt/SearchTab' import { TrendSurferTab } from '@/components/hunt/TrendSurferTab' import { BrandableForgeTab } from '@/components/hunt/BrandableForgeTab' import { @@ -25,19 +26,41 @@ import { Crown, X, Briefcase, + Search, + Download, + Flame, + Wand2, } from 'lucide-react' import clsx from 'clsx' import Link from 'next/link' import Image from 'next/image' +// ============================================================================ +// TYPES +// ============================================================================ + +type HuntTab = 'auctions' | 'drops' | 'search' | 'trends' | 'forge' + +// ============================================================================ +// TAB CONFIG +// ============================================================================ + +const TABS: Array<{ key: HuntTab; label: string; shortLabel: string; icon: any; color: string }> = [ + { key: 'auctions', label: 'Auctions', shortLabel: 'Auctions', icon: Gavel, color: 'accent' }, + { key: 'drops', label: 'Drops', shortLabel: 'Drops', icon: Download, color: 'blue' }, + { key: 'search', label: 'Search', shortLabel: 'Search', icon: Search, color: 'white' }, + { key: 'trends', label: 'Trends', shortLabel: 'Trends', icon: Flame, color: 'orange' }, + { key: 'forge', label: 'Forge', shortLabel: 'Forge', icon: Wand2, color: 'purple' }, +] + // ============================================================================ // MAIN PAGE // ============================================================================ export default function HuntPage() { - const { isAuthenticated, isLoading: authLoading, user, subscription, logout, checkAuth, domains } = useStore() + const { user, subscription, logout, checkAuth, domains } = useStore() const { toast, showToast, hideToast } = useToast() - const [tab, setTab] = useState('sniper') + const [tab, setTab] = useState('auctions') // Mobile Menu State const [menuOpen, setMenuOpen] = useState(false) @@ -48,15 +71,15 @@ export default function HuntPage() { }, [checkAuth]) // Computed - const availableDomains = domains?.filter(d => d.is_available) || [] + const availableDomains = domains?.filter((d) => d.is_available) || [] const totalDomains = domains?.length || 0 // Nav Items for Mobile Bottom Bar const mobileNavItems = [ - { href: '/terminal/radar', label: 'Radar', icon: Target, active: false }, { href: '/terminal/hunt', label: 'Hunt', icon: Crosshair, active: true }, { href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false }, - { href: '/terminal/market', label: 'Market', icon: Gavel, active: false }, + { href: '/terminal/portfolio', label: 'Portfolio', icon: Briefcase, active: false }, + { href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false }, ] // Full Navigation for Drawer @@ -67,36 +90,28 @@ export default function HuntPage() { { title: 'Discover', items: [ - { href: '/terminal/radar', label: 'Radar', icon: Target }, { href: '/terminal/hunt', label: 'Hunt', icon: Crosshair }, - { href: '/terminal/market', label: 'Market', icon: Gavel }, { href: '/terminal/intel', label: 'Intel', icon: TrendingUp }, - ] + ], }, { title: 'Manage', items: [ { href: '/terminal/watchlist', label: 'Watchlist', icon: Eye }, { href: '/terminal/portfolio', label: 'Portfolio', icon: Briefcase }, - { href: '/terminal/cfo', label: 'CFO', icon: Shield }, { href: '/terminal/sniper', label: 'Sniper', icon: Target }, - ] + ], }, { title: 'Monetize', items: [ { href: '/terminal/yield', label: 'Yield', icon: Coins, isNew: true }, { href: '/terminal/listing', label: 'For Sale', icon: Tag }, - ] - } + ], + }, ] - // Tab labels for header - const tabLabels: Record = { - sniper: 'Closeout Sniper', - trends: 'Trend Surfer', - forge: 'Brandable Forge', - } + const activeTab = TABS.find((t) => t.key === tab)! return (
@@ -107,106 +122,121 @@ export default function HuntPage() { {/* Main Content */}
- {/* ═══════════════════════════════════════════════════════════════════════ */} - {/* MOBILE HEADER - Techy Angular */} + {/* MOBILE HEADER */} {/* ═══════════════════════════════════════════════════════════════════════ */}
- {/* Top Row: Brand + Stats */} + {/* Top Row */}
-
+ Domain Hunt
-
- {tabLabels[tab]} +
+ {totalDomains} tracked · {availableDomains.length} available
- {/* Stats Grid */} -
-
-
{totalDomains}
-
Tracked
+ {/* Tab Bar - Scrollable */} +
+
+ {TABS.map((t) => { + const isActive = tab === t.key + return ( + + ) + })}
-
-
{availableDomains.length}
-
Available
-
-
{/* ═══════════════════════════════════════════════════════════════════════ */} - {/* DESKTOP HERO + STRATEGY CHIPS */} + {/* DESKTOP HEADER + TAB BAR */} {/* ═══════════════════════════════════════════════════════════════════════ */} -
- {/* Desktop Hero Text */} -
-
-
- Phase 1 · Discovery +
+
+
+
+ + Discovery Hub +
+

Domain Hunt

+

+ Search domains, browse auctions, discover drops, ride trends, or generate brandables. +

+
+ +
+
+
{totalDomains}
+
Tracked
+
+
+
{availableDomains.length}
+
Available
+
-

- Domain Hunt -

-

- Find → Analyze → Decide. Strategy-first discovery for domainers. -

- {/* Strategy Chips - Desktop */} -
- -
+ {/* Desktop Tab Bar */} +
+ {TABS.map((t) => { + const isActive = tab === t.key + const colorClasses: Record = { + accent: { active: 'border-accent bg-accent/10 text-accent', inactive: 'border-white/[0.08] text-white/50 hover:text-white hover:bg-white/[0.02]' }, + blue: { active: 'border-blue-500 bg-blue-500/10 text-blue-400', inactive: 'border-white/[0.08] text-white/50 hover:text-white hover:bg-white/[0.02]' }, + white: { active: 'border-white/40 bg-white/10 text-white', inactive: 'border-white/[0.08] text-white/50 hover:text-white hover:bg-white/[0.02]' }, + orange: { active: 'border-orange-500 bg-orange-500/10 text-orange-400', inactive: 'border-white/[0.08] text-white/50 hover:text-white hover:bg-white/[0.02]' }, + purple: { active: 'border-purple-500 bg-purple-500/10 text-purple-400', inactive: 'border-white/[0.08] text-white/50 hover:text-white hover:bg-white/[0.02]' }, + } + const classes = colorClasses[t.color] || colorClasses.white - {/* Strategy Chips - Mobile (horizontal scroll) */} -
-
- {(['sniper', 'trends', 'forge'] as HuntTab[]).map((t) => { - const active = tab === t - const labels: Record = { - sniper: { label: 'SNIPER', hint: '< $10 · 5y+' }, - trends: { label: 'TRENDS', hint: 'Keywords + Typos' }, - forge: { label: 'FORGE', hint: 'Brandables' }, - } - return ( - - ) - })} -
+ return ( + + ) + })}
{/* ═══════════════════════════════════════════════════════════════════════ */} {/* TAB CONTENT */} {/* ═══════════════════════════════════════════════════════════════════════ */} -
- {tab === 'sniper' && } +
+ {tab === 'auctions' && } + {tab === 'drops' && } + {tab === 'search' && } {tab === 'trends' && } {tab === 'forge' && }
@@ -224,19 +254,16 @@ export default function HuntPage() { key={item.href} href={item.href} className={clsx( - "flex-1 flex flex-col items-center justify-center gap-0.5 relative transition-colors", - item.active ? "text-accent" : "text-white/40 active:text-white/80" + 'flex-1 flex flex-col items-center justify-center gap-0.5 relative transition-colors', + item.active ? 'text-accent' : 'text-white/40 active:text-white/80' )} > - {item.active && ( -
- )} + {item.active &&
} {item.label} ))} - {/* Menu Button */}
- {/* Navigation Sections */}
{drawerNavSections.map((section) => (
@@ -296,16 +318,13 @@ export default function HuntPage() { > {item.label} - {item.isNew && ( - NEW - )} + {item.isNew && NEW} ))}
))} - {/* Settings */}
- {/* User Card */}
-

- {user?.name || user?.email?.split('@')[0] || 'User'} -

+

{user?.name || user?.email?.split('@')[0] || 'User'}

{tierName}

@@ -355,7 +371,10 @@ export default function HuntPage() { )}
- {/* FILTERS */} + {/* TABS */}
-
- {[ - { value: 'all', label: 'All', count: stats.total }, - { value: 'active', label: 'Active', count: stats.active }, - { value: 'sold', label: 'Sold', count: stats.sold }, - { value: 'expiring', label: 'Expiring Soon', count: stats.expiringSoon }, - ].map((item) => ( - - ))} +
+ +
+ + {/* Asset Filters - only show when assets tab active */} + {activeTab === 'assets' && ( +
+ {[ + { value: 'all', label: 'All', count: stats.total }, + { value: 'active', label: 'Active', count: stats.active }, + { value: 'sold', label: 'Sold', count: stats.sold }, + { value: 'expiring', label: 'Expiring Soon', count: stats.expiringSoon }, + ].map((item) => ( + + ))} +
+ )}
- {/* DOMAIN LIST */} + {/* TAB CONTENT */}
+ {/* FINANCIALS TAB */} + {activeTab === 'financials' && ( +
+ {cfoLoading ? ( +
+ +
+ ) : !cfoData ? ( +
+ +

No financial data

+

Add domains to your portfolio to see costs

+ +
+ ) : ( + <> + {/* Summary Cards */} +
+
+
+ + Next 30 Days +
+
${Math.round(cfoData.upcoming_30d_total_usd)}
+
{cfoData.upcoming_30d_rows.length} renewals due
+
+ +
+
+ + Annual Burn +
+
+ ${Math.round(cfoData.monthly.reduce((sum, m) => sum + m.total_cost_usd, 0))} +
+
Based on 12-month forecast
+
+ +
+
+ + Drop Candidates +
+
{cfoData.kill_list.length}
+
No yield, expiring soon
+
+
+ + {/* Upcoming Renewals */} + {cfoData.upcoming_30d_rows.length > 0 && ( +
+
+
Upcoming Costs
+
Next 30 days
+
+
+ {cfoData.upcoming_30d_rows.slice(0, 10).map((r) => ( +
+ {r.domain} + + {r.renewal_date ? r.renewal_date.slice(0, 10) : '—'} · ${Math.round(r.renewal_cost_usd || 0)} + +
+ ))} +
+
+ )} + + {/* Burn Rate Timeline */} + + + {/* Kill List */} + + + {/* Tips */} +
+
What to do next
+
+
- If a renewal cost is missing, fill it in on the domain in Assets → Edit.
+
- "Set to Drop" is a local flag — you still need to disable auto-renew at your registrar.
+
- Want to cover costs? Activate Yield only for DNS‑verified domains.
+
+
+ + )} +
+ )} + + {/* ASSETS TAB (Domain List) */} + {activeTab === 'assets' && ( + <> {loading ? (
@@ -1390,6 +1571,8 @@ export default function PortfolioPage() { })}
)} + + )}
{/* MOBILE BOTTOM NAV */} diff --git a/frontend/src/app/terminal/radar/page.tsx b/frontend/src/app/terminal/radar/page.tsx deleted file mode 100644 index db6d550..0000000 --- a/frontend/src/app/terminal/radar/page.tsx +++ /dev/null @@ -1,882 +0,0 @@ -'use client' - -import { useEffect, useState, useCallback, useRef, useMemo } from 'react' -import { useStore } from '@/lib/store' -import { api } from '@/lib/api' -import { Sidebar } from '@/components/Sidebar' -import { Toast, useToast } from '@/components/Toast' -import { - Eye, - Gavel, - ArrowRight, - CheckCircle2, - XCircle, - Loader2, - Crosshair, - Zap, - Globe, - Target, - Search, - X, - TrendingUp, - Settings, - Clock, - ChevronRight, - ChevronUp, - ChevronDown, - Sparkles, - Radio, - Activity, - Menu, - Tag, - Coins, - Shield, - LogOut, - Crown, - ExternalLink, - Briefcase -} from 'lucide-react' -import clsx from 'clsx' -import Link from 'next/link' -import Image from 'next/image' - -// ============================================================================ -// TYPES -// ============================================================================ - -interface HotAuction { - domain: string - current_bid: number - end_time?: string - platform: string - affiliate_url?: string -} - -function calcTimeRemaining(endTimeIso?: string): string { - if (!endTimeIso) return 'N/A' - const end = new Date(endTimeIso).getTime() - const now = Date.now() - const diff = end - now - - if (diff <= 0) return 'Ended' - - const seconds = Math.floor(diff / 1000) - const days = Math.floor(seconds / 86400) - const hours = Math.floor((seconds % 86400) / 3600) - const mins = Math.floor((seconds % 3600) / 60) - - if (days > 0) return `${days}d ${hours}h` - if (hours > 0) return `${hours}h ${mins}m` - if (mins > 0) return `${mins}m` - return '< 1m' -} - -interface SearchResult { - domain: string - status: string - is_available: boolean | null - registrar: string | null - expiration_date: string | null - loading: boolean - inAuction: boolean - auctionData?: HotAuction -} - -// ============================================================================ -// MAIN PAGE -// ============================================================================ - -export default function RadarPage() { - const { isAuthenticated, isLoading: authLoading, domains, addDomain, user, subscription, logout, checkAuth } = useStore() - const { toast, showToast, hideToast } = useToast() - - const [hotAuctions, setHotAuctions] = useState([]) - const [marketStats, setMarketStats] = useState({ totalAuctions: 0, endingSoon: 0 }) - const [loadingData, setLoadingData] = useState(true) - const [tick, setTick] = useState(0) - - const [searchQuery, setSearchQuery] = useState('') - const [searchResult, setSearchResult] = useState(null) - const [addingToWatchlist, setAddingToWatchlist] = useState(false) - const [searchFocused, setSearchFocused] = useState(false) - const searchInputRef = useRef(null) - - // Mobile Menu State - const [menuOpen, setMenuOpen] = useState(false) - - // Sorting for Auctions - const [auctionSort, setAuctionSort] = useState<'domain' | 'time' | 'bid'>('time') - const [auctionSortDir, setAuctionSortDir] = useState<'asc' | 'desc'>('asc') - - // Check auth on mount - useEffect(() => { - checkAuth() - }, [checkAuth]) - - // Load Data - Using same API as Market page for consistency - const loadDashboardData = useCallback(async () => { - try { - // External auctions only (Pounce Direct has no end_time) - const [feed, ending24h] = await Promise.all([ - api.getMarketFeed({ source: 'external', sortBy: 'time', limit: 10 }), - api.getMarketFeed({ source: 'external', endingWithin: 24, sortBy: 'time', limit: 1 }), - ]) - - const auctions: HotAuction[] = (feed.items || []) - .filter((item: any) => item.status === 'auction' && item.end_time) - .slice(0, 6) - .map((item: any) => ({ - domain: item.domain, - current_bid: item.price || 0, - end_time: item.end_time || undefined, - platform: item.source || 'Unknown', - affiliate_url: item.url || '', - })) - - setHotAuctions(auctions) - setMarketStats({ - totalAuctions: feed.total || feed.auction_count || 0, - endingSoon: ending24h.total || 0, - }) - } catch (error) { - console.error('Failed to load data:', error) - } finally { - setLoadingData(false) - } - }, []) - - useEffect(() => { - if (!authLoading) { - if (isAuthenticated) { - loadDashboardData() - const interval = setInterval(() => setTick(t => t + 1), 30000) - return () => clearInterval(interval) - } else { - setLoadingData(false) - } - } - }, [authLoading, isAuthenticated, loadDashboardData]) - - // Sorted auctions - const sortedAuctions = useMemo(() => { - const mult = auctionSortDir === 'asc' ? 1 : -1 - return [...hotAuctions].sort((a, b) => { - switch (auctionSort) { - case 'domain': return mult * a.domain.localeCompare(b.domain) - case 'bid': return mult * (a.current_bid - b.current_bid) - case 'time': - const aTime = a.end_time ? new Date(a.end_time).getTime() : Infinity - const bTime = b.end_time ? new Date(b.end_time).getTime() : Infinity - return mult * (aTime - bTime) - default: return 0 - } - }) - }, [hotAuctions, auctionSort, auctionSortDir, tick]) - - const handleAuctionSort = useCallback((field: typeof auctionSort) => { - if (auctionSort === field) { - setAuctionSortDir(d => d === 'asc' ? 'desc' : 'asc') - } else { - setAuctionSort(field) - setAuctionSortDir(field === 'bid' ? 'desc' : 'asc') - } - }, [auctionSort]) - - // Search - const handleSearch = useCallback(async (domainInput: string) => { - if (!domainInput.trim()) { setSearchResult(null); return } - const cleanDomain = domainInput.trim().toLowerCase() - setSearchResult({ domain: cleanDomain, status: 'checking', is_available: null, registrar: null, expiration_date: null, loading: true, inAuction: false }) - - try { - const [whoisResult, auctionsResult] = await Promise.all([ - api.checkDomain(cleanDomain).catch(() => null), - api.getAuctions(cleanDomain).catch(() => ({ auctions: [] })), - ]) - const auctionMatch = (auctionsResult as any).auctions?.find((a: any) => a.domain.toLowerCase() === cleanDomain) - setSearchResult({ - domain: whoisResult?.domain || cleanDomain, - status: whoisResult?.status || 'unknown', - is_available: whoisResult?.is_available ?? null, - registrar: whoisResult?.registrar || null, - expiration_date: whoisResult?.expiration_date || null, - loading: false, - inAuction: !!auctionMatch, - auctionData: auctionMatch, - }) - } catch { - setSearchResult({ domain: cleanDomain, status: 'error', is_available: null, registrar: null, expiration_date: null, loading: false, inAuction: false }) - } - }, []) - - const handleAddToWatchlist = useCallback(async () => { - if (!searchQuery.trim()) return - setAddingToWatchlist(true) - try { - await addDomain(searchQuery.trim()) - showToast(`Added: ${searchQuery.trim()}`, 'success') - setSearchQuery('') - setSearchResult(null) - } catch (err: any) { - showToast(err.message || 'Failed', 'error') - } finally { - setAddingToWatchlist(false) - } - }, [searchQuery, addDomain, showToast]) - - useEffect(() => { - const timer = setTimeout(() => { - if (searchQuery.length > 3) handleSearch(searchQuery) - else setSearchResult(null) - }, 500) - return () => clearTimeout(timer) - }, [searchQuery, handleSearch]) - - // Computed - const availableDomains = domains?.filter(d => d.is_available) || [] - const totalDomains = domains?.length || 0 - - // Nav Items for Mobile Bottom Bar - const mobileNavItems = [ - { href: '/terminal/radar', label: 'Radar', icon: Target, active: true }, - { href: '/terminal/market', label: 'Market', icon: Gavel, active: false }, - { href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false }, - { href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false }, - ] - - // Full Navigation for Drawer - const tierName = subscription?.tier_name || subscription?.tier || 'Scout' - const TierIcon = tierName === 'Tycoon' ? Crown : tierName === 'Trader' ? TrendingUp : Zap - - const drawerNavSections = [ - { - title: 'Discover', - items: [ - { href: '/terminal/radar', label: 'Radar', icon: Target }, - { href: '/terminal/market', label: 'Market', icon: Gavel }, - { href: '/terminal/intel', label: 'Intel', icon: TrendingUp }, - ] - }, - { - title: 'Manage', - items: [ - { href: '/terminal/watchlist', label: 'Watchlist', icon: Eye }, - { href: '/terminal/portfolio', label: 'Portfolio', icon: Briefcase }, - { href: '/terminal/sniper', label: 'Sniper', icon: Target }, - ] - }, - { - title: 'Monetize', - items: [ - { href: '/terminal/yield', label: 'Yield', icon: Coins, isNew: true }, - { href: '/terminal/listing', label: 'For Sale', icon: Tag }, - ] - } - ] - - return ( -
- {/* Desktop Sidebar */} -
- -
- - {/* Main Content */} -
- - {/* ═══════════════════════════════════════════════════════════════════════ */} - {/* MOBILE HEADER - Techy Angular */} - {/* ═══════════════════════════════════════════════════════════════════════ */} -
-
- {/* Top Row: Brand + Menu */} -
-
-
- Domain Radar -
-
- {marketStats.totalAuctions.toLocaleString()} auctions - {marketStats.endingSoon} ending 24h -
-
- - {/* Stats Grid */} -
-
-
{totalDomains}
-
Tracked
-
-
-
{availableDomains.length}
-
Available
-
-
-
{marketStats.totalAuctions}
-
Auctions
-
-
-
{marketStats.endingSoon}
-
Ending
-
-
-
-
- - {/* ═══════════════════════════════════════════════════════════════════════ */} - {/* SEARCH SECTION */} - {/* ═══════════════════════════════════════════════════════════════════════ */} -
- {/* Desktop Hero Text */} -
-
-
- Domain Radar -
-

- Domain Radar -

-

- Check domain availability, track your watchlist, and discover live auctions. -

-
- - {/* Search Card */} -
- {/* Desktop Glow */} -
- -
- {/* Terminal Header */} -
- - - Domain Search - -
-
-
-
-
-
- - {/* Search Input */} -
-
-
- - setSearchQuery(e.target.value)} - onFocus={() => setSearchFocused(true)} - onBlur={() => setSearchFocused(false)} - placeholder="example.com" - className="flex-1 bg-transparent px-3 py-4 text-base lg:text-lg text-white placeholder:text-white/20 outline-none font-mono" - /> - {searchQuery && ( - - )} -
-
- - {/* Search Result */} - {searchResult && ( -
- {searchResult.loading ? ( -
- - Scanning... -
- ) : ( -
- {/* Result Header */} -
-
- {searchResult.is_available ? ( - - ) : ( - - )} -
-
{searchResult.domain}
- {!searchResult.is_available && searchResult.registrar && ( -
Registrar: {searchResult.registrar}
- )} -
-
- - {searchResult.is_available ? 'Available' : 'Taken'} - -
- - {/* Actions */} -
- - - {searchResult.is_available && ( - - Register - - - )} -
-
- )} -
- )} - - {/* Hint */} - {!searchResult && ( -

- Enter domain to check availability -

- )} -
-
-
-
- - {/* ═══════════════════════════════════════════════════════════════════════ */} - {/* WATCHLIST PREVIEW */} - {/* ═══════════════════════════════════════════════════════════════════════ */} - {domains && domains.length > 0 && ( -
-
-
- - Your Watchlist - ({domains.length}) -
- - Manage - - -
- - {/* Domain Grid */} -
- {domains.slice(0, 6).map((domain) => ( - -
-
- {domain.is_available ? ( - - ) : ( - - )} -
-
-
{domain.name}
-
- {domain.registrar || 'Unknown registrar'} -
-
-
-
-
- {domain.is_available ? 'AVAILABLE' : 'TAKEN'} -
- {domain.last_checked && ( -
- {new Date(domain.last_checked).toLocaleDateString()} -
- )} -
- - ))} -
- - {domains.length > 6 && ( - - View all {domains.length} domains - - - )} -
- )} - - {/* ═══════════════════════════════════════════════════════════════════════ */} - {/* HOT AUCTIONS */} - {/* ═══════════════════════════════════════════════════════════════════════ */} -
-
-
- - Market Feed - ({sortedAuctions.length}) -
- - View all - - -
- - {loadingData ? ( -
- -
- ) : sortedAuctions.length > 0 ? ( - <> - {/* MOBILE Auction List */} - - - {/* DESKTOP Table */} -
- {/* Desktop Table Header */} -
- -
Platform
- - -
-
- - {sortedAuctions.map((auction, i) => ( - - {/* Domain */} -
-
- -
-
- {auction.domain} -
-
- - {/* Platform */} -
- {auction.platform} -
- - {/* Time */} -
- - - {calcTimeRemaining(auction.end_time)} - -
- - {/* Bid */} -
-
- ${auction.current_bid.toLocaleString()} -
-
- - {/* Link */} -
- -
-
- ))} -
- - ) : ( -
- -

No active auctions

-
- )} - - {/* Desktop Quick Links */} -
- {[ - { href: '/terminal/watchlist', icon: Eye, label: 'Watchlist', desc: 'Track domain availability' }, - { href: '/terminal/market', icon: Gavel, label: 'Market', desc: 'Browse all auctions' }, - { href: '/terminal/intel', icon: Globe, label: 'Intel', desc: 'TLD price analysis' }, - ].map((item) => ( - -
- -
-
-
{item.label}
-
{item.desc}
-
- - - ))} -
-
- - {/* ═══════════════════════════════════════════════════════════════════════ */} - {/* MOBILE BOTTOM NAV */} - {/* ═══════════════════════════════════════════════════════════════════════ */} - - - {/* ═══════════════════════════════════════════════════════════════════════ */} - {/* MOBILE DRAWER */} - {/* ═══════════════════════════════════════════════════════════════════════ */} - {menuOpen && ( -
- {/* Backdrop */} -
setMenuOpen(false)} - /> - - {/* Drawer Panel */} -
- - {/* Drawer Header */} -
-
- Pounce -
-

POUNCE

-

Terminal v1.0

-
-
- -
- - {/* Navigation Sections */} -
- {drawerNavSections.map((section) => ( -
-
-
- {section.title} -
-
- {section.items.map((item: any) => ( - setMenuOpen(false)} - className="flex items-center gap-3 px-4 py-2.5 text-white/60 active:text-white active:bg-white/[0.03] transition-colors border-l-2 border-transparent active:border-accent" - > - - {item.label} - {item.isNew && ( - NEW - )} - - ))} -
-
- ))} - - {/* Settings */} -
- setMenuOpen(false)} - className="flex items-center gap-3 py-2.5 text-white/50 active:text-white transition-colors" - > - - Settings - - - {user?.is_admin && ( - setMenuOpen(false)} - className="flex items-center gap-3 py-2.5 text-amber-500/70 active:text-amber-400 transition-colors" - > - - Admin - - )} -
-
- - {/* User Card */} -
-
-
- -
-
-

- {user?.name || user?.email?.split('@')[0] || 'User'} -

-

{tierName}

-
-
- - {tierName === 'Scout' && ( - setMenuOpen(false)} - className="flex items-center justify-center gap-2 w-full py-2.5 bg-accent text-black text-xs font-bold uppercase tracking-wider active:scale-[0.98] transition-all mb-2" - > - - Upgrade - - )} - - -
-
-
- )} -
- - {/* Toast */} - {toast && } -
- ) -} diff --git a/frontend/src/app/terminal/settings/page.tsx b/frontend/src/app/terminal/settings/page.tsx index b41293c..6578f35 100644 --- a/frontend/src/app/terminal/settings/page.tsx +++ b/frontend/src/app/terminal/settings/page.tsx @@ -270,7 +270,7 @@ export default function SettingsPage() { // Mobile Nav - same as Intel page const mobileNavItems = [ - { href: '/terminal/radar', label: 'Radar', icon: Target, active: false }, + { href: '/terminal/hunt', label: 'Radar', icon: Target, active: false }, { href: '/terminal/market', label: 'Market', icon: Gavel, active: false }, { href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false }, { href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false }, @@ -278,7 +278,7 @@ export default function SettingsPage() { const drawerNavSections = [ { title: 'Discover', items: [ - { href: '/terminal/radar', label: 'Radar', icon: Target }, + { href: '/terminal/hunt', label: 'Radar', icon: Target }, { href: '/terminal/market', label: 'Market', icon: Gavel }, { href: '/terminal/intel', label: 'Intel', icon: TrendingUp }, ]}, diff --git a/frontend/src/app/terminal/sniper/page.tsx b/frontend/src/app/terminal/sniper/page.tsx index ab45d6d..bca8909 100644 --- a/frontend/src/app/terminal/sniper/page.tsx +++ b/frontend/src/app/terminal/sniper/page.tsx @@ -145,7 +145,7 @@ export default function SniperAlertsPage() { // Mobile Nav const mobileNavItems = [ - { href: '/terminal/radar', label: 'Radar', icon: Target, active: false }, + { href: '/terminal/hunt', label: 'Radar', icon: Target, active: false }, { href: '/terminal/market', label: 'Market', icon: Gavel, active: false }, { href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false }, { href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false }, @@ -156,7 +156,7 @@ export default function SniperAlertsPage() { const drawerNavSections = [ { title: 'Discover', items: [ - { href: '/terminal/radar', label: 'Radar', icon: Target }, + { href: '/terminal/hunt', label: 'Radar', icon: Target }, { href: '/terminal/market', label: 'Market', icon: Gavel }, { href: '/terminal/intel', label: 'Intel', icon: TrendingUp }, ]}, diff --git a/frontend/src/app/terminal/watchlist/page.tsx b/frontend/src/app/terminal/watchlist/page.tsx index adc5a49..595012a 100755 --- a/frontend/src/app/terminal/watchlist/page.tsx +++ b/frontend/src/app/terminal/watchlist/page.tsx @@ -247,9 +247,9 @@ export default function WatchlistPage() { // Mobile Nav const mobileNavItems = [ - { href: '/terminal/radar', label: 'Radar', icon: Target, active: false }, - { href: '/terminal/market', label: 'Market', icon: Gavel, active: false }, + { href: '/terminal/hunt', label: 'Hunt', icon: Target, active: false }, { href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: true }, + { href: '/terminal/portfolio', label: 'Portfolio', icon: Briefcase, active: false }, { href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false }, ] @@ -260,8 +260,7 @@ export default function WatchlistPage() { { title: 'Discover', items: [ - { href: '/terminal/radar', label: 'Radar', icon: Target }, - { href: '/terminal/market', label: 'Market', icon: Gavel }, + { href: '/terminal/hunt', label: 'Hunt', icon: Target }, { href: '/terminal/intel', label: 'Intel', icon: TrendingUp }, ] }, diff --git a/frontend/src/app/terminal/welcome/page.tsx b/frontend/src/app/terminal/welcome/page.tsx index fb3456e..abc6a01 100644 --- a/frontend/src/app/terminal/welcome/page.tsx +++ b/frontend/src/app/terminal/welcome/page.tsx @@ -188,7 +188,7 @@ export default function WelcomePage() { {/* Go to Dashboard */}
diff --git a/frontend/src/app/terminal/yield/page.tsx b/frontend/src/app/terminal/yield/page.tsx index 45bcc14..3001ec4 100644 --- a/frontend/src/app/terminal/yield/page.tsx +++ b/frontend/src/app/terminal/yield/page.tsx @@ -360,7 +360,7 @@ export default function YieldPage() { const stats = dashboard?.stats const mobileNavItems = [ - { href: '/terminal/radar', label: 'Radar', icon: Target, active: false }, + { href: '/terminal/hunt', label: 'Radar', icon: Target, active: false }, { href: '/terminal/market', label: 'Market', icon: Gavel, active: false }, { href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false }, { href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false }, @@ -371,7 +371,7 @@ export default function YieldPage() { const drawerNavSections = [ { title: 'Discover', items: [ - { href: '/terminal/radar', label: 'Radar', icon: Target }, + { href: '/terminal/hunt', label: 'Radar', icon: Target }, { href: '/terminal/market', label: 'Market', icon: Gavel }, { href: '/terminal/intel', label: 'Intel', icon: TrendingUp }, ]}, diff --git a/frontend/src/components/AdminLayout.tsx b/frontend/src/components/AdminLayout.tsx index cb6668d..1d83ec7 100644 --- a/frontend/src/components/AdminLayout.tsx +++ b/frontend/src/components/AdminLayout.tsx @@ -92,7 +92,7 @@ export function AdminLayout({

Access Denied

Admin privileges required

+ )} + +
+
+ + {/* Filter Toggle */} + + + {/* Filters Panel */} + {filtersOpen && ( +
+ {/* Source */} +
+
Source
+
+ {[ + { value: 'all', label: 'All' }, + { value: 'pounce', label: 'Pounce', icon: Diamond }, + { value: 'external', label: 'External' }, + ].map((item) => ( + + ))} +
+
+ + {/* TLD */} +
+
TLD
+
+ {['all', 'com', 'ai', 'io', 'net'].map((tld) => ( + + ))} +
+
+ + {/* Price */} +
+
Price
+
+ {[ + { value: 'all', label: 'All' }, + { value: 'low', label: '< $100' }, + { value: 'mid', label: '< $1k' }, + { value: 'high', label: '$1k+' }, + ].map((item) => ( + + ))} +
+
+ + {/* Spam Filter */} + +
+ )} +
+ + {/* Stats Bar */} +
+ {filteredItems.length} domains shown + {filteredItems.filter((i) => i.pounce_score >= 80).length} high score +
+ + {/* Results */} + {filteredItems.length === 0 ? ( +
+ +

No domains found

+

Try adjusting filters

+
+ ) : ( + <> +
+ {/* Desktop Table Header */} +
+ + + + +
Actions
+
+ + {filteredItems.map((item) => { + const timeLeftSec = getSecondsUntilEnd(item.end_time) + const isUrgent = timeLeftSec > 0 && timeLeftSec < 3600 + const isPounce = item.is_pounce + const displayTime = item.status === 'auction' ? calcTimeRemaining(item.end_time) : null + const isTracked = trackedDomains.has(item.domain) + const isTracking = trackingInProgress === item.domain + + return ( +
+ {/* Mobile Row */} +
+
+
+
+ {isPounce ? : {item.source.substring(0, 2).toUpperCase()}} +
+
+ +
+ {item.source} + | + {isPounce ? 'Instant' : displayTime || 'N/A'} +
+
+
+ +
+
{formatPrice(item.price)}
+
= 80 ? "text-accent bg-accent/10" : item.pounce_score >= 50 ? "text-amber-400 bg-amber-400/10" : "text-white/30 bg-white/5")}> + Score {item.pounce_score} +
+
+
+ +
+ + + + + + {isPounce ? 'Buy' : 'Bid'} + {!isPounce && } + +
+
+ + {/* Desktop Row */} +
+
+
+ {isPounce ? : {item.source.substring(0, 2).toUpperCase()}} +
+
+ +
+ {item.source} + {isPounce && item.verified && ( + <> + | + + + Verified + + + )} + {item.num_bids ? ( + <> + | + {item.num_bids} bids + + ) : null} +
+
+
+ +
+ = 80 ? "text-accent bg-accent/10" : item.pounce_score >= 50 ? "text-amber-400 bg-amber-400/10" : "text-white/40 bg-white/5")}> + {item.pounce_score} + +
+ +
+
{formatPrice(item.price)}
+
{item.price_type === 'bid' ? 'Bid' : 'Buy Now'}
+
+ +
+ {isPounce ? ( + + + Instant + + ) : ( + {displayTime || 'N/A'} + )} +
+ +
+ + + + + + {isPounce ? 'Buy' : 'Bid'} + {!isPounce && } + +
+
+
+ ) + })} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+
+ Page {page}/{totalPages} +
+
+ + + + {page}/{totalPages} + + + +
+
+ )} + + )} +
+ ) +} diff --git a/frontend/src/components/hunt/DropsTab.tsx b/frontend/src/components/hunt/DropsTab.tsx new file mode 100644 index 0000000..2afedf3 --- /dev/null +++ b/frontend/src/components/hunt/DropsTab.tsx @@ -0,0 +1,169 @@ +'use client' + +import { useState } from 'react' +import { Download, Clock, Globe, Loader2, Search, Filter, ChevronRight, AlertCircle } from 'lucide-react' +import clsx from 'clsx' + +// ============================================================================ +// DROPS TAB - Zone File Analysis +// Placeholder component - User will set up the data source +// ============================================================================ + +interface DropsTabProps { + showToast: (message: string, type?: 'success' | 'error' | 'info') => void +} + +export function DropsTab({ showToast }: DropsTabProps) { + const [selectedTld, setSelectedTld] = useState('com') + const [loading, setLoading] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [searchFocused, setSearchFocused] = useState(false) + + // TODO: Replace with real API call to zone file analysis endpoint + const handleRefresh = async () => { + setLoading(true) + // Simulated delay - replace with actual API call + await new Promise(resolve => setTimeout(resolve, 1500)) + setLoading(false) + showToast('Zone file data will be available once configured', 'info') + } + + const tlds = ['com', 'net', 'org', 'io', 'ai', 'co'] + + return ( +
+ {/* Header Controls */} +
+ {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + onFocus={() => setSearchFocused(true)} + onBlur={() => setSearchFocused(false)} + placeholder="Search freshly dropped domains..." + className="flex-1 bg-transparent px-3 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono" + /> +
+
+ + {/* TLD Selector */} +
+ {tlds.map((tld) => ( + + ))} +
+
+ + {/* Info Card - Setup Required */} +
+
+ +
+

Zone File Integration

+

+ This feature analyzes zone files to find freshly dropped domains. + Configure your zone file data source to see real-time drops. +

+
+ +
+
+
+
+ + {/* Feature Preview Cards */} +
+
+
+
+ +
+
+
Real-time Drops
+
Updated every hour
+
+
+

+ Monitor domains as they expire and become available for registration. +

+
+ +
+
+
+ +
+
+
Smart Filters
+
Length, keywords, patterns
+
+
+

+ Filter by domain length, keywords, character patterns, and more. +

+
+ +
+
+
+ +
+
+
Multi-TLD
+
All major TLDs
+
+
+

+ Track drops across .com, .net, .org, .io, .ai, and more. +

+
+
+ + {/* Placeholder Table */} +
+
+
+
Freshly Dropped
+
.{selectedTld.toUpperCase()} Domains
+
+
+ Awaiting data... +
+
+ +
+ +

No zone file data available

+

+ Configure zone file integration to see dropped domains +

+
+
+
+ ) +} diff --git a/frontend/src/components/hunt/SearchTab.tsx b/frontend/src/components/hunt/SearchTab.tsx new file mode 100644 index 0000000..69834ec --- /dev/null +++ b/frontend/src/components/hunt/SearchTab.tsx @@ -0,0 +1,349 @@ +'use client' + +import { useState, useCallback, useEffect, useRef } from 'react' +import { useStore } from '@/lib/store' +import { api } from '@/lib/api' +import { useAnalyzePanelStore } from '@/lib/analyze-store' +import { + Search, + X, + Loader2, + CheckCircle2, + XCircle, + Eye, + ArrowRight, + Shield, + Globe, + Calendar, + Building, +} from 'lucide-react' +import clsx from 'clsx' + +// ============================================================================ +// TYPES +// ============================================================================ + +interface SearchResult { + domain: string + status: string + is_available: boolean | null + registrar: string | null + expiration_date: string | null + loading: boolean +} + +// ============================================================================ +// COMPONENT +// ============================================================================ + +interface SearchTabProps { + showToast: (message: string, type?: 'success' | 'error' | 'info') => void +} + +export function SearchTab({ showToast }: SearchTabProps) { + const { addDomain } = useStore() + const openAnalyze = useAnalyzePanelStore((s) => s.open) + + const [searchQuery, setSearchQuery] = useState('') + const [searchResult, setSearchResult] = useState(null) + const [addingToWatchlist, setAddingToWatchlist] = useState(false) + const [searchFocused, setSearchFocused] = useState(false) + const [recentSearches, setRecentSearches] = useState([]) + const searchInputRef = useRef(null) + + // Load recent searches from localStorage + useEffect(() => { + const stored = localStorage.getItem('pounce_recent_searches') + if (stored) { + try { + setRecentSearches(JSON.parse(stored).slice(0, 5)) + } catch { + // Ignore invalid JSON + } + } + }, []) + + // Save to recent searches + const saveToRecent = useCallback((domain: string) => { + setRecentSearches((prev) => { + const filtered = prev.filter((d) => d !== domain) + const updated = [domain, ...filtered].slice(0, 5) + localStorage.setItem('pounce_recent_searches', JSON.stringify(updated)) + return updated + }) + }, []) + + // Search Handler + const handleSearch = useCallback(async (domainInput: string) => { + if (!domainInput.trim()) { + setSearchResult(null) + return + } + const cleanDomain = domainInput.trim().toLowerCase() + setSearchResult({ domain: cleanDomain, status: 'checking', is_available: null, registrar: null, expiration_date: null, loading: true }) + + try { + const whoisResult = await api.checkDomain(cleanDomain).catch(() => null) + setSearchResult({ + domain: whoisResult?.domain || cleanDomain, + status: whoisResult?.status || 'unknown', + is_available: whoisResult?.is_available ?? null, + registrar: whoisResult?.registrar || null, + expiration_date: whoisResult?.expiration_date || null, + loading: false, + }) + saveToRecent(cleanDomain) + } catch { + setSearchResult({ domain: cleanDomain, status: 'error', is_available: null, registrar: null, expiration_date: null, loading: false }) + } + }, [saveToRecent]) + + const handleAddToWatchlist = useCallback(async () => { + if (!searchQuery.trim()) return + setAddingToWatchlist(true) + try { + await addDomain(searchQuery.trim()) + showToast(`Added: ${searchQuery.trim()}`, 'success') + } catch (err: any) { + showToast(err.message || 'Failed', 'error') + } finally { + setAddingToWatchlist(false) + } + }, [searchQuery, addDomain, showToast]) + + // Debounced search + useEffect(() => { + const timer = setTimeout(() => { + if (searchQuery.length > 3) handleSearch(searchQuery) + else setSearchResult(null) + }, 500) + return () => clearTimeout(timer) + }, [searchQuery, handleSearch]) + + // Focus input on mount + useEffect(() => { + searchInputRef.current?.focus() + }, []) + + return ( +
+ {/* Search Card */} +
+
+ +
+
+ + + Domain Search + +
+
+
+
+
+
+ +
+
+
+ + setSearchQuery(e.target.value)} + onFocus={() => setSearchFocused(true)} + onBlur={() => setSearchFocused(false)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch(searchQuery)} + placeholder="example.com" + className="flex-1 bg-transparent px-3 py-4 text-base lg:text-lg text-white placeholder:text-white/20 outline-none font-mono" + /> + {searchQuery && ( + + )} +
+
+ + {!searchResult && ( +

+ Enter a domain to check availability, WHOIS data, and registration options +

+ )} +
+
+
+ + {/* Search Result */} + {searchResult && ( +
+ {searchResult.loading ? ( +
+ + Checking availability... +
+ ) : ( +
+ {/* Result Header */} +
+
+ {searchResult.is_available ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
{searchResult.domain}
+
+ {searchResult.is_available ? 'Available for registration' : 'Already registered'} +
+
+
+ + {searchResult.is_available ? 'Available' : 'Taken'} + +
+ + {/* WHOIS Info (if taken) */} + {!searchResult.is_available && (searchResult.registrar || searchResult.expiration_date) && ( +
+ {searchResult.registrar && ( +
+ +
+
Registrar
+
{searchResult.registrar}
+
+
+ )} + {searchResult.expiration_date && ( +
+ +
+
Expires
+
+ {new Date(searchResult.expiration_date).toLocaleDateString()} +
+
+
+ )} +
+ )} + + {/* Actions */} +
+ + + + + {searchResult.is_available && ( + + Register + + + )} +
+
+ )} +
+ )} + + {/* Recent Searches */} + {!searchResult && recentSearches.length > 0 && ( +
+
+
+ Recent Searches +
+
+ {recentSearches.map((domain) => ( + + ))} +
+
+ )} + + {/* Quick Tips */} + {!searchResult && ( +
+
+ +
Instant Check
+

+ Check any domain's availability in real-time using RDAP/WHOIS. +

+
+ +
+ +
Track Changes
+

+ Add domains to your watchlist to monitor when they become available. +

+
+ +
+ +
Deep Analysis
+

+ Run full analysis to check backlinks, SEO metrics, and domain history. +

+
+
+ )} +
+ ) +} diff --git a/frontend/src/hooks/useKeyboardShortcuts.tsx b/frontend/src/hooks/useKeyboardShortcuts.tsx index 5fe5a65..dfd5647 100644 --- a/frontend/src/hooks/useKeyboardShortcuts.tsx +++ b/frontend/src/hooks/useKeyboardShortcuts.tsx @@ -239,7 +239,7 @@ export function useUserShortcuts() { useEffect(() => { const userShortcuts: Shortcut[] = [ // Navigation - { key: 'g', label: 'Go to Dashboard', description: 'Navigate to dashboard', action: () => router.push('/terminal/radar'), category: 'navigation' }, + { key: 'g', label: 'Go to Hunt', description: 'Navigate to Hunt page', action: () => router.push('/terminal/hunt'), category: 'navigation' }, { key: 'w', label: 'Go to Watchlist', description: 'Navigate to watchlist', action: () => router.push('/terminal/watchlist'), category: 'navigation' }, { key: 'p', label: 'Go to Watchlist', description: 'Navigate to watchlist', action: () => router.push('/terminal/watchlist'), category: 'navigation' }, { key: 'a', label: 'Go to Market', description: 'Navigate to market', action: () => router.push('/terminal/market'), category: 'navigation' }, @@ -281,7 +281,7 @@ export function useAdminShortcuts() { { key: 'e', label: 'Export', description: 'Export current data', action: () => {}, category: 'actions' }, // Global { key: '?', label: 'Show Shortcuts', description: 'Display this help', action: () => setShowHelp(true), category: 'global' }, - { key: 'd', label: 'Back to Dashboard', description: 'Return to user dashboard', action: () => router.push('/terminal/radar'), category: 'global' }, + { key: 'd', label: 'Back to Hunt', description: 'Return to Hunt page', action: () => router.push('/terminal/hunt'), category: 'global' }, ] adminShortcuts.forEach(registerShortcut)