feat: merge hunt/market pages, integrate cfo into portfolio
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
This commit is contained in:
23
deploy.sh
23
deploy.sh
@ -88,7 +88,8 @@ fi
|
|||||||
# Step 2: Sync files (only changed)
|
# Step 2: Sync files (only changed)
|
||||||
echo -e "\n${YELLOW}[2/4] Syncing changed files...${NC}"
|
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
|
if ! $BACKEND_ONLY; then
|
||||||
echo " Frontend:"
|
echo " Frontend:"
|
||||||
@ -160,16 +161,24 @@ if ! $BACKEND_ONLY; then
|
|||||||
set -e
|
set -e
|
||||||
cd ~/pounce/frontend
|
cd ~/pounce/frontend
|
||||||
|
|
||||||
echo " Installing dependencies..."
|
# Check if package.json changed (skip npm ci if not)
|
||||||
if [ -f "package-lock.json" ]; then
|
LOCKFILE_HASH=""
|
||||||
npm ci
|
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
|
else
|
||||||
npm install
|
echo " ✓ Dependencies unchanged, skipping npm ci"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build new version
|
# Build new version (with reduced memory for stability)
|
||||||
echo " Building..."
|
echo " Building..."
|
||||||
npm run build
|
NODE_OPTIONS="--max-old-space-size=2048" npm run build
|
||||||
BUILD_EXIT=$?
|
BUILD_EXIT=$?
|
||||||
|
|
||||||
if [ $BUILD_EXIT -eq 0 ]; then
|
if [ $BUILD_EXIT -eq 0 ]; then
|
||||||
|
|||||||
@ -56,7 +56,7 @@ function LoginForm() {
|
|||||||
const [verified, setVerified] = useState(false)
|
const [verified, setVerified] = useState(false)
|
||||||
|
|
||||||
const sanitizeRedirect = (value: string | null | undefined): string => {
|
const sanitizeRedirect = (value: string | null | undefined): string => {
|
||||||
const fallback = '/terminal/radar'
|
const fallback = '/terminal/hunt'
|
||||||
if (!value) return fallback
|
if (!value) return fallback
|
||||||
const v = value.trim()
|
const v = value.trim()
|
||||||
if (!v.startsWith('/')) return fallback
|
if (!v.startsWith('/')) return fallback
|
||||||
@ -131,7 +131,7 @@ function LoginForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate register link with redirect preserved
|
// Generate register link with redirect preserved
|
||||||
const registerLink = redirectTo !== '/terminal/radar'
|
const registerLink = redirectTo !== '/terminal/hunt'
|
||||||
? `/register?redirect=${encodeURIComponent(redirectTo)}`
|
? `/register?redirect=${encodeURIComponent(redirectTo)}`
|
||||||
: '/register'
|
: '/register'
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ function OAuthCallbackContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sanitizeRedirect = (value: string | null): string => {
|
const sanitizeRedirect = (value: string | null): string => {
|
||||||
const fallback = '/terminal/radar'
|
const fallback = '/terminal/hunt'
|
||||||
if (!value) return fallback
|
if (!value) return fallback
|
||||||
const v = value.trim()
|
const v = value.trim()
|
||||||
if (!v.startsWith('/')) return fallback
|
if (!v.startsWith('/')) return fallback
|
||||||
|
|||||||
@ -630,7 +630,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 sm:gap-8">
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 sm:gap-8">
|
||||||
<Link
|
<Link
|
||||||
href={isAuthenticated ? "/terminal/radar" : "/register"}
|
href={isAuthenticated ? "/terminal/hunt" : "/register"}
|
||||||
className="w-full sm:w-auto group relative px-8 sm:px-10 py-4 sm:py-5 bg-accent text-black text-xs sm:text-sm font-bold uppercase tracking-[0.15em] sm:tracking-[0.2em] hover:bg-white transition-colors duration-500"
|
className="w-full sm:w-auto group relative px-8 sm:px-10 py-4 sm:py-5 bg-accent text-black text-xs sm:text-sm font-bold uppercase tracking-[0.15em] sm:tracking-[0.2em] hover:bg-white transition-colors duration-500"
|
||||||
>
|
>
|
||||||
<span className="relative z-10 flex items-center justify-center gap-3">
|
<span className="relative z-10 flex items-center justify-center gap-3">
|
||||||
|
|||||||
@ -144,7 +144,7 @@ export default function PricingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isPaid) {
|
if (!isPaid) {
|
||||||
router.push('/terminal/radar')
|
router.push('/terminal/hunt')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -405,7 +405,7 @@ export default function PricingPage() {
|
|||||||
Start with Scout. It's free forever. Upgrade when you need more firepower.
|
Start with Scout. It's free forever. Upgrade when you need more firepower.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href={isAuthenticated ? "/terminal/radar" : "/register"}
|
href={isAuthenticated ? "/terminal/hunt" : "/register"}
|
||||||
className="inline-flex items-center gap-3 px-8 py-4 border border-white/20 text-white text-xs font-bold uppercase tracking-[0.2em] hover:bg-white hover:text-black transition-all"
|
className="inline-flex items-center gap-3 px-8 py-4 border border-white/20 text-white text-xs font-bold uppercase tracking-[0.2em] hover:bg-white hover:text-black transition-all"
|
||||||
>
|
>
|
||||||
{isAuthenticated ? "Command Center" : "Join the Hunt"}
|
{isAuthenticated ? "Command Center" : "Join the Hunt"}
|
||||||
|
|||||||
@ -62,7 +62,7 @@ function RegisterForm() {
|
|||||||
const [registered, setRegistered] = useState(false)
|
const [registered, setRegistered] = useState(false)
|
||||||
|
|
||||||
// Get redirect URL from query params
|
// 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 refFromUrl = searchParams.get('ref')
|
||||||
|
|
||||||
const getCookie = (name: string): string | null => {
|
const getCookie = (name: string): string | null => {
|
||||||
@ -85,7 +85,7 @@ function RegisterForm() {
|
|||||||
const ref = refFromUrl || getCookie('pounce_ref') || undefined
|
const ref = refFromUrl || getCookie('pounce_ref') || undefined
|
||||||
await register(email, password, undefined, ref || undefined)
|
await register(email, password, undefined, ref || undefined)
|
||||||
|
|
||||||
if (redirectTo !== '/terminal/radar') {
|
if (redirectTo !== '/terminal/hunt') {
|
||||||
localStorage.setItem('pounce_redirect_after_login', redirectTo)
|
localStorage.setItem('pounce_redirect_after_login', redirectTo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,7 +98,7 @@ function RegisterForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate login link with redirect preserved
|
// Generate login link with redirect preserved
|
||||||
const loginLink = redirectTo !== '/terminal/radar'
|
const loginLink = redirectTo !== '/terminal/hunt'
|
||||||
? `/login?redirect=${encodeURIComponent(redirectTo)}`
|
? `/login?redirect=${encodeURIComponent(redirectTo)}`
|
||||||
: '/login'
|
: '/login'
|
||||||
|
|
||||||
|
|||||||
@ -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<string | null>(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 (
|
|
||||||
<div className="min-h-screen bg-[#020202]">
|
|
||||||
<Sidebar />
|
|
||||||
|
|
||||||
<main className="lg:ml-72">
|
|
||||||
<section className="px-4 lg:px-10 pt-8 pb-5 border-b border-white/[0.08]">
|
|
||||||
<div className="flex items-end justify-between gap-6 flex-wrap">
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] font-mono text-white/40 uppercase tracking-[0.25em]">PHASE 4</p>
|
|
||||||
<h1 className="text-3xl font-bold text-white tracking-tight">CFO</h1>
|
|
||||||
<p className="text-white/40 text-sm font-mono mt-2 max-w-2xl">
|
|
||||||
Renewal runway, burn rate, and drop advice. No fluff — just numbers.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={refresh}
|
|
||||||
disabled={loading}
|
|
||||||
className={clsx(
|
|
||||||
'p-2 border border-white/10 text-white/40 hover:text-white hover:bg-white/5 transition-colors',
|
|
||||||
loading && 'opacity-50'
|
|
||||||
)}
|
|
||||||
title="Refresh"
|
|
||||||
>
|
|
||||||
<RefreshCw className={clsx('w-4 h-4', loading && 'animate-spin')} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="px-4 lg:px-10 py-6 pb-24 space-y-3">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-16">
|
|
||||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="border border-white/[0.08] bg-[#020202] p-4 text-[12px] font-mono text-red-300">{error}</div>
|
|
||||||
) : !data ? (
|
|
||||||
<div className="border border-white/[0.08] bg-[#020202] p-6 text-white/40 font-mono text-sm">No data.</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
||||||
<div className="border border-white/[0.08] bg-[#020202]">
|
|
||||||
<div className="px-4 py-3 border-b border-white/[0.08]">
|
|
||||||
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">Upcoming costs</div>
|
|
||||||
<div className="text-sm font-bold text-white">Next 30 days</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="text-2xl font-bold text-white font-mono">${Math.round(data.upcoming_30d_total_usd)}</div>
|
|
||||||
<div className="text-[10px] font-mono text-white/30 mt-1">{data.upcoming_30d_rows.length} renewals due</div>
|
|
||||||
<div className="mt-3 space-y-1">
|
|
||||||
{data.upcoming_30d_rows.slice(0, 8).map((r) => (
|
|
||||||
<div key={r.domain_id} className="flex items-center justify-between text-[11px] font-mono text-white/60">
|
|
||||||
<span className="truncate">{r.domain}</span>
|
|
||||||
<span className="text-white/40">
|
|
||||||
{r.renewal_date ? r.renewal_date.slice(0, 10) : '—'} · ${Math.round(r.renewal_cost_usd || 0)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{data.upcoming_30d_rows.length === 0 ? (
|
|
||||||
<div className="text-[12px] font-mono text-white/30">No renewals in the next 30 days.</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BurnRateTimeline monthly={data.monthly} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<KillList rows={data.kill_list} onChanged={refresh} />
|
|
||||||
|
|
||||||
<div className="border border-white/[0.08] bg-[#020202] p-4">
|
|
||||||
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">What to do next</div>
|
|
||||||
<div className="mt-2 text-[12px] font-mono text-white/50 space-y-1">
|
|
||||||
<div>
|
|
||||||
- If a renewal cost is missing, fill it in on the domain in <span className="text-white/70">Portfolio → Edit</span>.
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
- “Set to Drop” is a local flag — you still need to disable auto-renew at your registrar.
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
- Want to cover costs? Activate Yield only for <span className="text-white/70">DNS‑verified</span> domains.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{toast && (
|
|
||||||
<Toast message={toast.message} type={toast.type} onClose={hideToast} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,11 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { Sidebar } from '@/components/Sidebar'
|
import { Sidebar } from '@/components/Sidebar'
|
||||||
import { Toast, useToast } from '@/components/Toast'
|
import { Toast, useToast } from '@/components/Toast'
|
||||||
import { HuntStrategyChips, type HuntTab } from '@/components/hunt/HuntStrategyChips'
|
import { AuctionsTab } from '@/components/hunt/AuctionsTab'
|
||||||
import { SniperTab } from '@/components/hunt/SniperTab'
|
import { DropsTab } from '@/components/hunt/DropsTab'
|
||||||
|
import { SearchTab } from '@/components/hunt/SearchTab'
|
||||||
import { TrendSurferTab } from '@/components/hunt/TrendSurferTab'
|
import { TrendSurferTab } from '@/components/hunt/TrendSurferTab'
|
||||||
import { BrandableForgeTab } from '@/components/hunt/BrandableForgeTab'
|
import { BrandableForgeTab } from '@/components/hunt/BrandableForgeTab'
|
||||||
import {
|
import {
|
||||||
@ -25,19 +26,41 @@ import {
|
|||||||
Crown,
|
Crown,
|
||||||
X,
|
X,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
|
Search,
|
||||||
|
Download,
|
||||||
|
Flame,
|
||||||
|
Wand2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Image from 'next/image'
|
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
|
// MAIN PAGE
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export default function HuntPage() {
|
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 { toast, showToast, hideToast } = useToast()
|
||||||
const [tab, setTab] = useState<HuntTab>('sniper')
|
const [tab, setTab] = useState<HuntTab>('auctions')
|
||||||
|
|
||||||
// Mobile Menu State
|
// Mobile Menu State
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
@ -48,15 +71,15 @@ export default function HuntPage() {
|
|||||||
}, [checkAuth])
|
}, [checkAuth])
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const availableDomains = domains?.filter(d => d.is_available) || []
|
const availableDomains = domains?.filter((d) => d.is_available) || []
|
||||||
const totalDomains = domains?.length || 0
|
const totalDomains = domains?.length || 0
|
||||||
|
|
||||||
// Nav Items for Mobile Bottom Bar
|
// Nav Items for Mobile Bottom Bar
|
||||||
const mobileNavItems = [
|
const mobileNavItems = [
|
||||||
{ href: '/terminal/radar', label: 'Radar', icon: Target, active: false },
|
|
||||||
{ href: '/terminal/hunt', label: 'Hunt', icon: Crosshair, active: true },
|
{ href: '/terminal/hunt', label: 'Hunt', icon: Crosshair, active: true },
|
||||||
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
{ 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
|
// Full Navigation for Drawer
|
||||||
@ -67,36 +90,28 @@ export default function HuntPage() {
|
|||||||
{
|
{
|
||||||
title: 'Discover',
|
title: 'Discover',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/terminal/radar', label: 'Radar', icon: Target },
|
|
||||||
{ href: '/terminal/hunt', label: 'Hunt', icon: Crosshair },
|
{ href: '/terminal/hunt', label: 'Hunt', icon: Crosshair },
|
||||||
{ href: '/terminal/market', label: 'Market', icon: Gavel },
|
|
||||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Manage',
|
title: 'Manage',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/terminal/watchlist', label: 'Watchlist', icon: Eye },
|
{ href: '/terminal/watchlist', label: 'Watchlist', icon: Eye },
|
||||||
{ href: '/terminal/portfolio', label: 'Portfolio', icon: Briefcase },
|
{ href: '/terminal/portfolio', label: 'Portfolio', icon: Briefcase },
|
||||||
{ href: '/terminal/cfo', label: 'CFO', icon: Shield },
|
|
||||||
{ href: '/terminal/sniper', label: 'Sniper', icon: Target },
|
{ href: '/terminal/sniper', label: 'Sniper', icon: Target },
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Monetize',
|
title: 'Monetize',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/terminal/yield', label: 'Yield', icon: Coins, isNew: true },
|
{ href: '/terminal/yield', label: 'Yield', icon: Coins, isNew: true },
|
||||||
{ href: '/terminal/listing', label: 'For Sale', icon: Tag },
|
{ href: '/terminal/listing', label: 'For Sale', icon: Tag },
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// Tab labels for header
|
const activeTab = TABS.find((t) => t.key === tab)!
|
||||||
const tabLabels: Record<HuntTab, string> = {
|
|
||||||
sniper: 'Closeout Sniper',
|
|
||||||
trends: 'Trend Surfer',
|
|
||||||
forge: 'Brandable Forge',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#020202]">
|
<div className="min-h-screen bg-[#020202]">
|
||||||
@ -107,106 +122,121 @@ export default function HuntPage() {
|
|||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="lg:pl-[240px]">
|
<main className="lg:pl-[240px]">
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
{/* MOBILE HEADER - Techy Angular */}
|
{/* MOBILE HEADER */}
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
<header
|
<header
|
||||||
className="lg:hidden sticky top-0 z-40 bg-[#020202]/95 backdrop-blur-md border-b border-white/[0.08]"
|
className="lg:hidden sticky top-0 z-40 bg-[#020202]/95 backdrop-blur-md border-b border-white/[0.08]"
|
||||||
style={{ paddingTop: 'env(safe-area-inset-top)' }}
|
style={{ paddingTop: 'env(safe-area-inset-top)' }}
|
||||||
>
|
>
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
{/* Top Row: Brand + Stats */}
|
{/* Top Row */}
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
|
<Crosshair className="w-4 h-4 text-accent" />
|
||||||
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Domain Hunt</span>
|
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Domain Hunt</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-[10px] font-mono text-white/40">
|
<div className="text-[10px] font-mono text-white/40">
|
||||||
<span className="text-accent">{tabLabels[tab]}</span>
|
{totalDomains} tracked · {availableDomains.length} available
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Tab Bar - Scrollable */}
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="-mx-4 px-4 overflow-x-auto">
|
||||||
<div className="bg-white/[0.02] border border-white/[0.08] p-2">
|
<div className="flex gap-1 min-w-max pb-1">
|
||||||
<div className="text-lg font-bold text-white tabular-nums">{totalDomains}</div>
|
{TABS.map((t) => {
|
||||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Tracked</div>
|
const isActive = tab === t.key
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-1.5 px-3 py-2 border transition-all shrink-0',
|
||||||
|
isActive
|
||||||
|
? t.color === 'accent'
|
||||||
|
? 'border-accent/40 bg-accent/10 text-accent'
|
||||||
|
: t.color === 'blue'
|
||||||
|
? 'border-blue-500/40 bg-blue-500/10 text-blue-400'
|
||||||
|
: t.color === 'orange'
|
||||||
|
? 'border-orange-500/40 bg-orange-500/10 text-orange-400'
|
||||||
|
: t.color === 'purple'
|
||||||
|
? 'border-purple-500/40 bg-purple-500/10 text-purple-400'
|
||||||
|
: 'border-white/40 bg-white/10 text-white'
|
||||||
|
: 'border-transparent text-white/40 active:bg-white/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<t.icon className="w-3.5 h-3.5" />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider font-mono">{t.shortLabel}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-accent/[0.05] border border-accent/20 p-2">
|
|
||||||
<div className="text-lg font-bold text-accent tabular-nums">{availableDomains.length}</div>
|
|
||||||
<div className="text-[9px] font-mono text-accent/60 uppercase tracking-wider">Available</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const next: HuntTab = tab === 'sniper' ? 'trends' : tab === 'trends' ? 'forge' : 'sniper'
|
|
||||||
setTab(next)
|
|
||||||
}}
|
|
||||||
className="bg-white/[0.02] border border-white/[0.08] p-2 text-left hover:bg-white/[0.04] active:bg-white/[0.06] transition-colors"
|
|
||||||
>
|
|
||||||
<div className="text-[10px] font-bold text-white/70 truncate">{tabLabels[tab]}</div>
|
|
||||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Strategy</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
{/* DESKTOP HERO + STRATEGY CHIPS */}
|
{/* DESKTOP HEADER + TAB BAR */}
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
<section className="px-4 lg:px-10 pt-4 lg:pt-10 pb-4">
|
<section className="hidden lg:block px-10 pt-10 pb-6 border-b border-white/[0.08]">
|
||||||
{/* Desktop Hero Text */}
|
<div className="flex items-end justify-between gap-6 mb-6">
|
||||||
<div className="hidden lg:block mb-8">
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
|
<Crosshair className="w-5 h-5 text-accent" />
|
||||||
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Phase 1 · Discovery</span>
|
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Discovery Hub</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-[2.5rem] leading-[1] tracking-[-0.02em] text-white">Domain Hunt</h1>
|
||||||
|
<p className="text-white/40 text-sm font-mono mt-2 max-w-lg">
|
||||||
|
Search domains, browse auctions, discover drops, ride trends, or generate brandables.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-accent font-mono">{totalDomains}</div>
|
||||||
|
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Tracked</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-white font-mono">{availableDomains.length}</div>
|
||||||
|
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Available</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-display text-[2.5rem] leading-[1] tracking-[-0.02em] text-white mb-2">
|
|
||||||
Domain Hunt
|
|
||||||
</h1>
|
|
||||||
<p className="text-white/40 text-sm font-mono max-w-lg">
|
|
||||||
Find → Analyze → Decide. Strategy-first discovery for domainers.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Strategy Chips - Desktop */}
|
{/* Desktop Tab Bar */}
|
||||||
<div className="hidden lg:block">
|
<div className="flex gap-2">
|
||||||
<HuntStrategyChips tab={tab} onChange={setTab} />
|
{TABS.map((t) => {
|
||||||
</div>
|
const isActive = tab === t.key
|
||||||
|
const colorClasses: Record<string, { active: string; inactive: string }> = {
|
||||||
|
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) */}
|
return (
|
||||||
<div className="lg:hidden -mx-4 px-4 overflow-x-auto pb-2">
|
<button
|
||||||
<div className="flex gap-2 min-w-max">
|
key={t.key}
|
||||||
{(['sniper', 'trends', 'forge'] as HuntTab[]).map((t) => {
|
onClick={() => setTab(t.key)}
|
||||||
const active = tab === t
|
className={clsx('flex items-center gap-2 px-4 py-2.5 border transition-all', isActive ? classes.active : classes.inactive)}
|
||||||
const labels: Record<HuntTab, { label: string; hint: string }> = {
|
>
|
||||||
sniper: { label: 'SNIPER', hint: '< $10 · 5y+' },
|
<t.icon className="w-4 h-4" />
|
||||||
trends: { label: 'TRENDS', hint: 'Keywords + Typos' },
|
<span className="text-xs font-bold uppercase tracking-wider">{t.label}</span>
|
||||||
forge: { label: 'FORGE', hint: 'Brandables' },
|
</button>
|
||||||
}
|
)
|
||||||
return (
|
})}
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
onClick={() => setTab(t)}
|
|
||||||
className={clsx(
|
|
||||||
'px-3 py-2 border text-left transition-colors shrink-0',
|
|
||||||
active ? 'border-accent/30 bg-accent/10 text-accent' : 'border-white/10 text-white/50 active:bg-white/5'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="text-[10px] font-bold uppercase tracking-wider font-mono">{labels[t].label}</div>
|
|
||||||
<div className="text-[9px] font-mono mt-0.5 text-white/30">{labels[t].hint}</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
{/* TAB CONTENT */}
|
{/* TAB CONTENT */}
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
<section className="px-4 lg:px-10 py-4 pb-28 lg:pb-10">
|
<section className="px-4 lg:px-10 py-4 lg:py-6 pb-28 lg:pb-10">
|
||||||
{tab === 'sniper' && <SniperTab showToast={showToast} />}
|
{tab === 'auctions' && <AuctionsTab showToast={showToast} />}
|
||||||
|
{tab === 'drops' && <DropsTab showToast={showToast} />}
|
||||||
|
{tab === 'search' && <SearchTab showToast={showToast} />}
|
||||||
{tab === 'trends' && <TrendSurferTab showToast={showToast} />}
|
{tab === 'trends' && <TrendSurferTab showToast={showToast} />}
|
||||||
{tab === 'forge' && <BrandableForgeTab showToast={showToast} />}
|
{tab === 'forge' && <BrandableForgeTab showToast={showToast} />}
|
||||||
</section>
|
</section>
|
||||||
@ -224,19 +254,16 @@ export default function HuntPage() {
|
|||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex-1 flex flex-col items-center justify-center gap-0.5 relative transition-colors",
|
'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 ? 'text-accent' : 'text-white/40 active:text-white/80'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.active && (
|
{item.active && <div className="absolute top-0 left-1/2 -translate-x-1/2 w-10 h-0.5 bg-accent" />}
|
||||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-10 h-0.5 bg-accent" />
|
|
||||||
)}
|
|
||||||
<item.icon className="w-5 h-5" />
|
<item.icon className="w-5 h-5" />
|
||||||
<span className="text-[9px] font-mono uppercase tracking-wider">{item.label}</span>
|
<span className="text-[9px] font-mono uppercase tracking-wider">{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Menu Button */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setMenuOpen(true)}
|
onClick={() => setMenuOpen(true)}
|
||||||
className="flex-1 flex flex-col items-center justify-center gap-0.5 text-white/40 active:text-white/80 transition-all"
|
className="flex-1 flex flex-col items-center justify-center gap-0.5 text-white/40 active:text-white/80 transition-all"
|
||||||
@ -252,16 +279,12 @@ export default function HuntPage() {
|
|||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="lg:hidden fixed inset-0 z-[100]">
|
<div className="lg:hidden fixed inset-0 z-[100]">
|
||||||
{/* Backdrop */}
|
<div className="absolute inset-0 bg-black/80 animate-in fade-in duration-200" onClick={() => setMenuOpen(false)} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/80 animate-in fade-in duration-200"
|
className="absolute top-0 right-0 bottom-0 w-[80%] max-w-[300px] bg-[#0A0A0A] border-l border-white/[0.08] animate-in slide-in-from-right duration-300 flex flex-col"
|
||||||
onClick={() => setMenuOpen(false)}
|
style={{ paddingTop: 'env(safe-area-inset-top)', paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||||
/>
|
>
|
||||||
|
|
||||||
{/* Drawer Panel */}
|
|
||||||
<div className="absolute top-0 right-0 bottom-0 w-[80%] max-w-[300px] bg-[#0A0A0A] border-l border-white/[0.08] animate-in slide-in-from-right duration-300 flex flex-col" style={{ paddingTop: 'env(safe-area-inset-top)', paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
|
||||||
|
|
||||||
{/* Drawer Header */}
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-white/[0.08]">
|
<div className="flex items-center justify-between p-4 border-b border-white/[0.08]">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Image src="/pounce-puma.png" alt="Pounce" width={28} height={28} className="object-contain" />
|
<Image src="/pounce-puma.png" alt="Pounce" width={28} height={28} className="object-contain" />
|
||||||
@ -278,7 +301,6 @@ export default function HuntPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Sections */}
|
|
||||||
<div className="flex-1 overflow-y-auto py-4">
|
<div className="flex-1 overflow-y-auto py-4">
|
||||||
{drawerNavSections.map((section) => (
|
{drawerNavSections.map((section) => (
|
||||||
<div key={section.title} className="mb-4">
|
<div key={section.title} className="mb-4">
|
||||||
@ -296,16 +318,13 @@ export default function HuntPage() {
|
|||||||
>
|
>
|
||||||
<item.icon className="w-4 h-4 text-white/30" />
|
<item.icon className="w-4 h-4 text-white/30" />
|
||||||
<span className="text-sm font-medium flex-1">{item.label}</span>
|
<span className="text-sm font-medium flex-1">{item.label}</span>
|
||||||
{item.isNew && (
|
{item.isNew && <span className="px-1.5 py-0.5 text-[8px] font-bold bg-accent text-black">NEW</span>}
|
||||||
<span className="px-1.5 py-0.5 text-[8px] font-bold bg-accent text-black">NEW</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Settings */}
|
|
||||||
<div className="pt-3 border-t border-white/[0.08] mx-4">
|
<div className="pt-3 border-t border-white/[0.08] mx-4">
|
||||||
<Link
|
<Link
|
||||||
href="/terminal/settings"
|
href="/terminal/settings"
|
||||||
@ -329,16 +348,13 @@ export default function HuntPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Card */}
|
|
||||||
<div className="p-4 bg-white/[0.02] border-t border-white/[0.08]">
|
<div className="p-4 bg-white/[0.02] border-t border-white/[0.08]">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center">
|
<div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center">
|
||||||
<TierIcon className="w-4 h-4 text-accent" />
|
<TierIcon className="w-4 h-4 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-bold text-white truncate">
|
<p className="text-sm font-bold text-white truncate">{user?.name || user?.email?.split('@')[0] || 'User'}</p>
|
||||||
{user?.name || user?.email?.split('@')[0] || 'User'}
|
|
||||||
</p>
|
|
||||||
<p className="text-[9px] font-mono text-white/40 uppercase tracking-wider">{tierName}</p>
|
<p className="text-[9px] font-mono text-white/40 uppercase tracking-wider">{tierName}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -355,7 +371,10 @@ export default function HuntPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { logout(); setMenuOpen(false) }}
|
onClick={() => {
|
||||||
|
logout()
|
||||||
|
setMenuOpen(false)
|
||||||
|
}}
|
||||||
className="flex items-center justify-center gap-2 w-full py-2 border border-white/10 text-white/40 text-[10px] font-mono uppercase tracking-wider active:bg-white/5 transition-all"
|
className="flex items-center justify-center gap-2 w-full py-2 border border-white/10 text-white/40 text-[10px] font-mono uppercase tracking-wider active:bg-white/5 transition-all"
|
||||||
>
|
>
|
||||||
<LogOut className="w-3 h-3" />
|
<LogOut className="w-3 h-3" />
|
||||||
|
|||||||
@ -68,7 +68,7 @@ export default function InboxPage() {
|
|||||||
|
|
||||||
const drawerNavSections = [
|
const drawerNavSections = [
|
||||||
{ title: 'Discover', items: [
|
{ 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/market', label: 'Market', icon: Gavel },
|
||||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
||||||
]},
|
]},
|
||||||
|
|||||||
@ -368,7 +368,7 @@ export default function TldDetailPage() {
|
|||||||
|
|
||||||
// Mobile Nav
|
// Mobile Nav
|
||||||
const mobileNavItems = [
|
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/market', label: 'Market', icon: Gavel, active: false },
|
||||||
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
||||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: true },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: true },
|
||||||
@ -381,7 +381,7 @@ export default function TldDetailPage() {
|
|||||||
{
|
{
|
||||||
title: 'Discover',
|
title: 'Discover',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/terminal/radar', label: 'Radar', icon: Target },
|
{ href: '/terminal/hunt', label: 'Radar', icon: Target },
|
||||||
{ href: '/terminal/market', label: 'Market', icon: Gavel },
|
{ href: '/terminal/market', label: 'Market', icon: Gavel },
|
||||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
||||||
]
|
]
|
||||||
|
|||||||
@ -234,7 +234,7 @@ export default function IntelPage() {
|
|||||||
|
|
||||||
// Mobile Nav
|
// Mobile Nav
|
||||||
const mobileNavItems = [
|
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/market', label: 'Market', icon: Gavel, active: false },
|
||||||
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
||||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: true },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: true },
|
||||||
@ -247,7 +247,7 @@ export default function IntelPage() {
|
|||||||
{
|
{
|
||||||
title: 'Discover',
|
title: 'Discover',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/terminal/radar', label: 'Radar', icon: Target },
|
{ href: '/terminal/hunt', label: 'Radar', icon: Target },
|
||||||
{ href: '/terminal/market', label: 'Market', icon: Gavel },
|
{ href: '/terminal/market', label: 'Market', icon: Gavel },
|
||||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
||||||
]
|
]
|
||||||
|
|||||||
@ -126,7 +126,7 @@ export default function MyListingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mobileNavItems = [
|
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/market', label: 'Market', icon: Gavel, active: false },
|
||||||
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
||||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
|
||||||
@ -137,7 +137,7 @@ export default function MyListingsPage() {
|
|||||||
|
|
||||||
const drawerNavSections = [
|
const drawerNavSections = [
|
||||||
{ title: 'Discover', items: [
|
{ 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/market', label: 'Market', icon: Gavel },
|
||||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
||||||
]},
|
]},
|
||||||
|
|||||||
@ -304,7 +304,7 @@ export default function MarketPage() {
|
|||||||
|
|
||||||
// Mobile Nav
|
// Mobile Nav
|
||||||
const mobileNavItems = [
|
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: true },
|
{ href: '/terminal/market', label: 'Market', icon: Gavel, active: true },
|
||||||
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
||||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
|
||||||
@ -317,7 +317,7 @@ export default function MarketPage() {
|
|||||||
{
|
{
|
||||||
title: 'Discover',
|
title: 'Discover',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/terminal/radar', label: 'Radar', icon: Target },
|
{ href: '/terminal/hunt', label: 'Radar', icon: Target },
|
||||||
{ href: '/terminal/market', label: 'Market', icon: Gavel },
|
{ href: '/terminal/market', label: 'Market', icon: Gavel },
|
||||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
||||||
]
|
]
|
||||||
|
|||||||
@ -3,11 +3,11 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
export default function CommandPage() {
|
export default function TerminalPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
router.replace('/terminal/radar')
|
router.replace('/terminal/hunt')
|
||||||
}, [router])
|
}, [router])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import { api, PortfolioDomain, PortfolioSummary, DomainHealthReport, HealthStatu
|
|||||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||||
import { Sidebar } from '@/components/Sidebar'
|
import { Sidebar } from '@/components/Sidebar'
|
||||||
import { Toast, useToast } from '@/components/Toast'
|
import { Toast, useToast } from '@/components/Toast'
|
||||||
|
import { BurnRateTimeline } from '@/components/cfo/BurnRateTimeline'
|
||||||
|
import { KillList } from '@/components/cfo/KillList'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
@ -14,10 +16,7 @@ import {
|
|||||||
Briefcase,
|
Briefcase,
|
||||||
X,
|
X,
|
||||||
Target,
|
Target,
|
||||||
ExternalLink,
|
|
||||||
Gavel,
|
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Menu,
|
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Shield,
|
Shield,
|
||||||
@ -28,24 +27,20 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
DollarSign,
|
|
||||||
Calendar,
|
Calendar,
|
||||||
Edit3,
|
Edit3,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertCircle,
|
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
ArrowUpRight,
|
|
||||||
Navigation,
|
Navigation,
|
||||||
Coins,
|
Coins,
|
||||||
Activity,
|
Activity,
|
||||||
Save,
|
Save,
|
||||||
FileText,
|
FileText,
|
||||||
Clock,
|
|
||||||
Building2,
|
Building2,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
MoreVertical,
|
Crosshair,
|
||||||
RotateCcw
|
Wallet,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
@ -55,6 +50,34 @@ import Image from 'next/image'
|
|||||||
// TYPES
|
// TYPES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
type PortfolioTab = 'assets' | 'financials'
|
||||||
|
|
||||||
|
interface CfoData {
|
||||||
|
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
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
interface EditFormData {
|
interface EditFormData {
|
||||||
purchase_date: string
|
purchase_date: string
|
||||||
purchase_price: string
|
purchase_price: string
|
||||||
@ -628,12 +651,19 @@ export default function PortfolioPage() {
|
|||||||
const { toast, showToast, hideToast } = useToast()
|
const { toast, showToast, hideToast } = useToast()
|
||||||
const openAnalyze = useAnalyzePanelStore((s) => s.open)
|
const openAnalyze = useAnalyzePanelStore((s) => s.open)
|
||||||
|
|
||||||
|
// Tab state
|
||||||
|
const [activeTab, setActiveTab] = useState<PortfolioTab>('assets')
|
||||||
|
|
||||||
const [domains, setDomains] = useState<PortfolioDomain[]>([])
|
const [domains, setDomains] = useState<PortfolioDomain[]>([])
|
||||||
const [summary, setSummary] = useState<PortfolioSummary | null>(null)
|
const [summary, setSummary] = useState<PortfolioSummary | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshingId, setRefreshingId] = useState<number | null>(null)
|
const [refreshingId, setRefreshingId] = useState<number | null>(null)
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// CFO / Financials data
|
||||||
|
const [cfoData, setCfoData] = useState<CfoData | null>(null)
|
||||||
|
const [cfoLoading, setCfoLoading] = useState(false)
|
||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
const [editingDomain, setEditingDomain] = useState<PortfolioDomain | null>(null)
|
const [editingDomain, setEditingDomain] = useState<PortfolioDomain | null>(null)
|
||||||
@ -698,7 +728,27 @@ export default function PortfolioPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Load CFO/Financials data
|
||||||
|
const loadCfoData = useCallback(async () => {
|
||||||
|
setCfoLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await api.getCfoSummary()
|
||||||
|
setCfoData(res)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load CFO data:', err)
|
||||||
|
} finally {
|
||||||
|
setCfoLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { loadData() }, [loadData])
|
useEffect(() => { loadData() }, [loadData])
|
||||||
|
|
||||||
|
// Load CFO data when switching to financials tab
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'financials' && !cfoData && !cfoLoading) {
|
||||||
|
loadCfoData()
|
||||||
|
}
|
||||||
|
}, [activeTab, cfoData, cfoLoading, loadCfoData])
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@ -710,8 +760,9 @@ export default function PortfolioPage() {
|
|||||||
const days = getDaysUntil(d.renewal_date)
|
const days = getDaysUntil(d.renewal_date)
|
||||||
return days !== null && days <= 30 && days > 0
|
return days !== null && days <= 30 && days > 0
|
||||||
}).length
|
}).length
|
||||||
return { total: domains.length, active, sold, verified, expiringSoon }
|
const upcoming30dCost = cfoData?.upcoming_30d_total_usd || 0
|
||||||
}, [domains])
|
return { total: domains.length, active, sold, verified, expiringSoon, upcoming30dCost }
|
||||||
|
}, [domains, cfoData])
|
||||||
|
|
||||||
// Filter & Sort
|
// Filter & Sort
|
||||||
const filteredDomains = useMemo(() => {
|
const filteredDomains = useMemo(() => {
|
||||||
@ -824,16 +875,15 @@ export default function PortfolioPage() {
|
|||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
const mobileNavItems = [
|
const mobileNavItems = [
|
||||||
{ href: '/terminal/radar', label: 'Radar', icon: Target, active: false },
|
{ href: '/terminal/hunt', label: 'Hunt', icon: Crosshair, active: false },
|
||||||
{ href: '/terminal/market', label: 'Market', icon: Gavel, active: false },
|
|
||||||
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
||||||
{ href: '/terminal/portfolio', label: 'Portfolio', icon: Briefcase, active: true },
|
{ href: '/terminal/portfolio', label: 'Portfolio', icon: Briefcase, active: true },
|
||||||
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
const drawerNavSections = [
|
const drawerNavSections = [
|
||||||
{ title: 'Discover', items: [
|
{ 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 },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
||||||
]},
|
]},
|
||||||
{ title: 'Manage', items: [
|
{ title: 'Manage', items: [
|
||||||
@ -1042,33 +1092,164 @@ export default function PortfolioPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FILTERS */}
|
{/* TABS */}
|
||||||
<section className="px-4 lg:px-10 py-4 border-y border-white/[0.08] bg-white/[0.01]">
|
<section className="px-4 lg:px-10 py-4 border-y border-white/[0.08] bg-white/[0.01]">
|
||||||
<div className="flex items-center gap-3 overflow-x-auto">
|
<div className="flex items-center gap-4 mb-4">
|
||||||
{[
|
<button
|
||||||
{ value: 'all', label: 'All', count: stats.total },
|
onClick={() => setActiveTab('assets')}
|
||||||
{ value: 'active', label: 'Active', count: stats.active },
|
className={clsx(
|
||||||
{ value: 'sold', label: 'Sold', count: stats.sold },
|
"flex items-center gap-2 px-4 py-2 text-xs font-bold uppercase tracking-wider border transition-colors",
|
||||||
{ value: 'expiring', label: 'Expiring Soon', count: stats.expiringSoon },
|
activeTab === 'assets'
|
||||||
].map((item) => (
|
? "bg-accent/10 text-accent border-accent/30"
|
||||||
<button
|
: "text-white/50 border-white/[0.08] hover:text-white hover:bg-white/[0.02]"
|
||||||
key={item.value}
|
)}
|
||||||
onClick={() => setFilter(item.value as any)}
|
>
|
||||||
className={clsx(
|
<Briefcase className="w-4 h-4" />
|
||||||
"shrink-0 px-3 py-2 text-[10px] font-mono uppercase tracking-wider border transition-colors",
|
Assets
|
||||||
filter === item.value
|
</button>
|
||||||
? "bg-white/10 text-white border-white/20"
|
<button
|
||||||
: "text-white/40 border-transparent hover:text-white/60"
|
onClick={() => setActiveTab('financials')}
|
||||||
)}
|
className={clsx(
|
||||||
>
|
"flex items-center gap-2 px-4 py-2 text-xs font-bold uppercase tracking-wider border transition-colors",
|
||||||
{item.label} ({item.count})
|
activeTab === 'financials'
|
||||||
</button>
|
? "bg-orange-500/10 text-orange-400 border-orange-500/30"
|
||||||
))}
|
: "text-white/50 border-white/[0.08] hover:text-white hover:bg-white/[0.02]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Wallet className="w-4 h-4" />
|
||||||
|
Financials
|
||||||
|
{stats.upcoming30dCost > 0 && (
|
||||||
|
<span className="ml-1 px-1.5 py-0.5 text-[9px] bg-orange-500/20 text-orange-400 border border-orange-500/20">
|
||||||
|
${Math.round(stats.upcoming30dCost)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Asset Filters - only show when assets tab active */}
|
||||||
|
{activeTab === 'assets' && (
|
||||||
|
<div className="flex items-center gap-3 overflow-x-auto">
|
||||||
|
{[
|
||||||
|
{ 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) => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
onClick={() => setFilter(item.value as any)}
|
||||||
|
className={clsx(
|
||||||
|
"shrink-0 px-3 py-2 text-[10px] font-mono uppercase tracking-wider border transition-colors",
|
||||||
|
filter === item.value
|
||||||
|
? "bg-white/10 text-white border-white/20"
|
||||||
|
: "text-white/40 border-transparent hover:text-white/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label} ({item.count})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* DOMAIN LIST */}
|
{/* TAB CONTENT */}
|
||||||
<section className="px-4 lg:px-10 py-6">
|
<section className="px-4 lg:px-10 py-6">
|
||||||
|
{/* FINANCIALS TAB */}
|
||||||
|
{activeTab === 'financials' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{cfoLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : !cfoData ? (
|
||||||
|
<div className="text-center py-16 border border-dashed border-white/[0.08]">
|
||||||
|
<Wallet className="w-10 h-10 text-white/10 mx-auto mb-4" />
|
||||||
|
<p className="text-white/40 text-sm font-mono mb-1">No financial data</p>
|
||||||
|
<p className="text-white/25 text-xs font-mono mb-4">Add domains to your portfolio to see costs</p>
|
||||||
|
<button
|
||||||
|
onClick={loadCfoData}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 border border-white/10 text-white/60 text-xs font-bold uppercase hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||||
|
<div className="border border-orange-500/20 bg-orange-500/[0.03] p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Wallet className="w-4 h-4 text-orange-400" />
|
||||||
|
<span className="text-[10px] font-mono text-orange-400/60 uppercase tracking-wider">Next 30 Days</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-orange-400 font-mono">${Math.round(cfoData.upcoming_30d_total_usd)}</div>
|
||||||
|
<div className="text-[10px] font-mono text-white/30 mt-1">{cfoData.upcoming_30d_rows.length} renewals due</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-white/[0.08] bg-white/[0.02] p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Calendar className="w-4 h-4 text-white/40" />
|
||||||
|
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Annual Burn</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-white font-mono">
|
||||||
|
${Math.round(cfoData.monthly.reduce((sum, m) => sum + m.total_cost_usd, 0))}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-mono text-white/30 mt-1">Based on 12-month forecast</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-white/[0.08] bg-white/[0.02] p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Trash2 className="w-4 h-4 text-white/40" />
|
||||||
|
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Drop Candidates</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-white font-mono">{cfoData.kill_list.length}</div>
|
||||||
|
<div className="text-[10px] font-mono text-white/30 mt-1">No yield, expiring soon</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upcoming Renewals */}
|
||||||
|
{cfoData.upcoming_30d_rows.length > 0 && (
|
||||||
|
<div className="border border-white/[0.08] bg-[#020202]">
|
||||||
|
<div className="px-4 py-3 border-b border-white/[0.08]">
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">Upcoming Costs</div>
|
||||||
|
<div className="text-sm font-bold text-white">Next 30 days</div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-white/[0.06]">
|
||||||
|
{cfoData.upcoming_30d_rows.slice(0, 10).map((r) => (
|
||||||
|
<div key={r.domain_id} className="px-4 py-2 flex items-center justify-between text-[11px] font-mono">
|
||||||
|
<span className="text-white/60 truncate">{r.domain}</span>
|
||||||
|
<span className="text-white/40 shrink-0">
|
||||||
|
{r.renewal_date ? r.renewal_date.slice(0, 10) : '—'} · ${Math.round(r.renewal_cost_usd || 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Burn Rate Timeline */}
|
||||||
|
<BurnRateTimeline monthly={cfoData.monthly} />
|
||||||
|
|
||||||
|
{/* Kill List */}
|
||||||
|
<KillList rows={cfoData.kill_list} onChanged={loadCfoData} />
|
||||||
|
|
||||||
|
{/* Tips */}
|
||||||
|
<div className="border border-white/[0.08] bg-[#020202] p-4">
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40 mb-2">What to do next</div>
|
||||||
|
<div className="text-[12px] font-mono text-white/50 space-y-1">
|
||||||
|
<div>- If a renewal cost is missing, fill it in on the domain in <span className="text-white/70">Assets → Edit</span>.</div>
|
||||||
|
<div>- "Set to Drop" is a local flag — you still need to disable auto-renew at your registrar.</div>
|
||||||
|
<div>- Want to cover costs? Activate Yield only for <span className="text-white/70">DNS‑verified</span> domains.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ASSETS TAB (Domain List) */}
|
||||||
|
{activeTab === 'assets' && (
|
||||||
|
<>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||||
@ -1390,6 +1571,8 @@ export default function PortfolioPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* MOBILE BOTTOM NAV */}
|
{/* MOBILE BOTTOM NAV */}
|
||||||
|
|||||||
@ -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<HotAuction[]>([])
|
|
||||||
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<SearchResult | null>(null)
|
|
||||||
const [addingToWatchlist, setAddingToWatchlist] = useState(false)
|
|
||||||
const [searchFocused, setSearchFocused] = useState(false)
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(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 (
|
|
||||||
<div className="min-h-screen bg-[#020202]">
|
|
||||||
{/* Desktop Sidebar */}
|
|
||||||
<div className="hidden lg:block">
|
|
||||||
<Sidebar />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="lg:pl-[240px]">
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
|
||||||
{/* MOBILE HEADER - Techy Angular */}
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
|
||||||
<header
|
|
||||||
className="lg:hidden sticky top-0 z-40 bg-[#020202]/95 backdrop-blur-md border-b border-white/[0.08]"
|
|
||||||
style={{ paddingTop: 'env(safe-area-inset-top)' }}
|
|
||||||
>
|
|
||||||
<div className="px-4 py-3">
|
|
||||||
{/* Top Row: Brand + Menu */}
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
|
|
||||||
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Domain Radar</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-[10px] font-mono text-white/40">
|
|
||||||
<span>{marketStats.totalAuctions.toLocaleString()} auctions</span>
|
|
||||||
<span className="text-accent">{marketStats.endingSoon} ending 24h</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Grid */}
|
|
||||||
<div className="grid grid-cols-4 gap-2">
|
|
||||||
<div className="bg-white/[0.02] border border-white/[0.08] p-2">
|
|
||||||
<div className="text-lg font-bold text-white tabular-nums">{totalDomains}</div>
|
|
||||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Tracked</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-accent/[0.05] border border-accent/20 p-2">
|
|
||||||
<div className="text-lg font-bold text-accent tabular-nums">{availableDomains.length}</div>
|
|
||||||
<div className="text-[9px] font-mono text-accent/60 uppercase tracking-wider">Available</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white/[0.02] border border-white/[0.08] p-2">
|
|
||||||
<div className="text-lg font-bold text-white tabular-nums">{marketStats.totalAuctions}</div>
|
|
||||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Auctions</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-orange-500/[0.05] border border-orange-500/20 p-2">
|
|
||||||
<div className="text-lg font-bold text-orange-400 tabular-nums">{marketStats.endingSoon}</div>
|
|
||||||
<div className="text-[9px] font-mono text-orange-400/60 uppercase tracking-wider">Ending</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
|
||||||
{/* SEARCH SECTION */}
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
|
||||||
<section className="px-4 lg:px-10 pt-4 lg:pt-10 pb-4">
|
|
||||||
{/* Desktop Hero Text */}
|
|
||||||
<div className="hidden lg:block mb-8">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
|
|
||||||
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Domain Radar</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="font-display text-[2.5rem] leading-[1] tracking-[-0.02em] text-white mb-2">
|
|
||||||
Domain Radar
|
|
||||||
</h1>
|
|
||||||
<p className="text-white/40 text-sm font-mono max-w-lg">
|
|
||||||
Check domain availability, track your watchlist, and discover live auctions.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Card */}
|
|
||||||
<div className="relative">
|
|
||||||
{/* Desktop Glow */}
|
|
||||||
<div className="hidden lg:block absolute -inset-6 bg-gradient-to-tr from-accent/5 via-transparent to-accent/5 blur-3xl opacity-50 pointer-events-none" />
|
|
||||||
|
|
||||||
<div className="relative bg-[#0A0A0A] border border-white/[0.08] overflow-hidden">
|
|
||||||
{/* Terminal Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-2 border-b border-white/[0.06] bg-black/40">
|
|
||||||
<span className="text-[10px] font-mono text-white/40 flex items-center gap-2">
|
|
||||||
<Crosshair className="w-3 h-3 text-accent" />
|
|
||||||
Domain Search
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<div className="w-2 h-2 bg-white/10" />
|
|
||||||
<div className="w-2 h-2 bg-white/10" />
|
|
||||||
<div className="w-2 h-2 bg-accent/50" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Input */}
|
|
||||||
<div className="p-4 lg:p-6">
|
|
||||||
<div className={clsx(
|
|
||||||
"relative border-2 transition-all duration-200",
|
|
||||||
searchFocused
|
|
||||||
? "border-accent/50 bg-accent/[0.02]"
|
|
||||||
: "border-white/[0.08] bg-white/[0.02]"
|
|
||||||
)}>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Search className={clsx(
|
|
||||||
"w-5 h-5 ml-4 transition-colors",
|
|
||||||
searchFocused ? "text-accent" : "text-white/30"
|
|
||||||
)} />
|
|
||||||
<input
|
|
||||||
ref={searchInputRef}
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => 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 && (
|
|
||||||
<button
|
|
||||||
onClick={() => { setSearchQuery(''); setSearchResult(null) }}
|
|
||||||
className="p-4 text-white/30 hover:text-white active:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Result */}
|
|
||||||
{searchResult && (
|
|
||||||
<div className="mt-4 animate-in fade-in slide-in-from-bottom-2 duration-200">
|
|
||||||
{searchResult.loading ? (
|
|
||||||
<div className="flex items-center justify-center gap-3 py-8 bg-white/[0.02] border border-white/[0.06]">
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-accent" />
|
|
||||||
<span className="text-sm text-white/50 font-mono">Scanning...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={clsx(
|
|
||||||
"border-2 overflow-hidden",
|
|
||||||
searchResult.is_available
|
|
||||||
? "border-accent/40 bg-accent/[0.03]"
|
|
||||||
: "border-white/[0.08] bg-white/[0.02]"
|
|
||||||
)}>
|
|
||||||
{/* Result Header */}
|
|
||||||
<div className={clsx(
|
|
||||||
"px-4 py-3 flex items-center justify-between",
|
|
||||||
searchResult.is_available ? "bg-accent/[0.05]" : "bg-white/[0.02]"
|
|
||||||
)}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{searchResult.is_available ? (
|
|
||||||
<CheckCircle2 className="w-5 h-5 text-accent" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="w-5 h-5 text-white/30" />
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<div className="text-base font-bold text-white font-mono">{searchResult.domain}</div>
|
|
||||||
{!searchResult.is_available && searchResult.registrar && (
|
|
||||||
<div className="text-[10px] text-white/40 font-mono">Registrar: {searchResult.registrar}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className={clsx(
|
|
||||||
"text-[10px] font-bold px-2 py-1 uppercase tracking-wider",
|
|
||||||
searchResult.is_available
|
|
||||||
? "bg-accent text-black"
|
|
||||||
: "bg-white/10 text-white/50"
|
|
||||||
)}>
|
|
||||||
{searchResult.is_available ? 'Available' : 'Taken'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="p-4 flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={handleAddToWatchlist}
|
|
||||||
disabled={addingToWatchlist}
|
|
||||||
className={clsx(
|
|
||||||
"flex-1 py-3 text-sm font-bold flex items-center justify-center gap-2 transition-all active:scale-[0.98]",
|
|
||||||
searchResult.is_available
|
|
||||||
? "border border-white/20 text-white hover:bg-white/5"
|
|
||||||
: "border-2 border-accent text-accent hover:bg-accent/10"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{addingToWatchlist ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
|
|
||||||
{searchResult.is_available ? 'Track' : 'Monitor'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{searchResult.is_available && (
|
|
||||||
<a
|
|
||||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${searchResult.domain}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex-1 py-3 bg-accent text-black text-sm font-bold flex items-center justify-center gap-2 hover:bg-white active:scale-[0.98] transition-all"
|
|
||||||
>
|
|
||||||
Register
|
|
||||||
<ArrowRight className="w-4 h-4" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Hint */}
|
|
||||||
{!searchResult && (
|
|
||||||
<p className="text-[10px] text-white/30 mt-3 font-mono">
|
|
||||||
Enter domain to check availability
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
|
||||||
{/* WATCHLIST PREVIEW */}
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
|
||||||
{domains && domains.length > 0 && (
|
|
||||||
<section className="px-4 lg:px-10 py-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Eye className="w-4 h-4 text-accent" />
|
|
||||||
<span className="text-xs font-bold text-white uppercase tracking-wider">Your Watchlist</span>
|
|
||||||
<span className="text-[10px] font-mono text-white/30">({domains.length})</span>
|
|
||||||
</div>
|
|
||||||
<Link href="/terminal/watchlist" className="text-[10px] font-mono text-accent hover:text-white transition-colors flex items-center gap-1">
|
|
||||||
Manage
|
|
||||||
<ChevronRight className="w-3 h-3" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Domain Grid */}
|
|
||||||
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
|
|
||||||
{domains.slice(0, 6).map((domain) => (
|
|
||||||
<Link
|
|
||||||
key={domain.id}
|
|
||||||
href="/terminal/watchlist"
|
|
||||||
className={clsx(
|
|
||||||
"flex items-center justify-between p-3 bg-[#020202] hover:bg-white/[0.02] active:bg-white/[0.03] transition-all",
|
|
||||||
domain.is_available && "bg-accent/[0.02]"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={clsx(
|
|
||||||
"w-8 h-8 flex items-center justify-center border",
|
|
||||||
domain.is_available
|
|
||||||
? "bg-accent/10 border-accent/20"
|
|
||||||
: "bg-white/[0.02] border-white/[0.06]"
|
|
||||||
)}>
|
|
||||||
{domain.is_available ? (
|
|
||||||
<CheckCircle2 className="w-4 h-4 text-accent" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-4 h-4 text-white/30" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-bold text-white font-mono">{domain.name}</div>
|
|
||||||
<div className="text-[10px] font-mono text-white/30">
|
|
||||||
{domain.registrar || 'Unknown registrar'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className={clsx(
|
|
||||||
"text-[10px] font-bold uppercase tracking-wider",
|
|
||||||
domain.is_available ? "text-accent" : "text-white/30"
|
|
||||||
)}>
|
|
||||||
{domain.is_available ? 'AVAILABLE' : 'TAKEN'}
|
|
||||||
</div>
|
|
||||||
{domain.last_checked && (
|
|
||||||
<div className="text-[9px] font-mono text-white/20">
|
|
||||||
{new Date(domain.last_checked).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{domains.length > 6 && (
|
|
||||||
<Link
|
|
||||||
href="/terminal/watchlist"
|
|
||||||
className="mt-2 flex items-center justify-center gap-2 py-2 border border-white/[0.08] text-white/40 text-[10px] font-mono uppercase tracking-wider hover:text-white hover:border-white/20 active:bg-white/[0.02] transition-all"
|
|
||||||
>
|
|
||||||
View all {domains.length} domains
|
|
||||||
<ArrowRight className="w-3 h-3" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
|
||||||
{/* HOT AUCTIONS */}
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
|
||||||
<section className="px-4 lg:px-10 py-4 pb-28 lg:pb-10">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Activity className="w-4 h-4 text-accent" />
|
|
||||||
<span className="text-xs font-bold text-white uppercase tracking-wider">Market Feed</span>
|
|
||||||
<span className="text-[10px] font-mono text-white/30">({sortedAuctions.length})</span>
|
|
||||||
</div>
|
|
||||||
<Link href="/terminal/market" className="text-[10px] font-mono text-accent hover:text-white transition-colors flex items-center gap-1">
|
|
||||||
View all
|
|
||||||
<ChevronRight className="w-3 h-3" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loadingData ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : sortedAuctions.length > 0 ? (
|
|
||||||
<>
|
|
||||||
{/* MOBILE Auction List */}
|
|
||||||
<div className="lg:hidden space-y-2">
|
|
||||||
{sortedAuctions.map((auction, i) => (
|
|
||||||
<a
|
|
||||||
key={i}
|
|
||||||
href={auction.affiliate_url || '#'}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="block p-3 bg-[#0A0A0A] border border-white/[0.08] active:bg-white/[0.03] transition-all"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
||||||
<div className="w-8 h-8 bg-white/[0.02] border border-white/[0.06] flex items-center justify-center shrink-0">
|
|
||||||
<Gavel className="w-4 h-4 text-white/40" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-bold text-white font-mono truncate">
|
|
||||||
{auction.domain}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] font-mono text-white/30 uppercase">
|
|
||||||
{auction.platform}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right shrink-0">
|
|
||||||
<div className="text-sm font-bold text-accent font-mono">
|
|
||||||
${auction.current_bid.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] font-mono text-white/40 flex items-center justify-end gap-1">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
{calcTimeRemaining(auction.end_time)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* DESKTOP Table */}
|
|
||||||
<div className="hidden lg:block space-y-px bg-white/[0.04] border border-white/[0.08]">
|
|
||||||
{/* Desktop Table Header */}
|
|
||||||
<div className="grid grid-cols-[1fr_100px_80px_100px_40px] gap-4 px-3 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
|
|
||||||
<button onClick={() => handleAuctionSort('domain')} className="flex items-center gap-1 hover:text-white/60 text-left">
|
|
||||||
Domain
|
|
||||||
{auctionSort === 'domain' && (auctionSortDir === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
|
||||||
</button>
|
|
||||||
<div className="text-center">Platform</div>
|
|
||||||
<button onClick={() => handleAuctionSort('time')} className="flex items-center gap-1 justify-center hover:text-white/60">
|
|
||||||
Time
|
|
||||||
{auctionSort === 'time' && (auctionSortDir === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => handleAuctionSort('bid')} className="flex items-center gap-1 justify-end hover:text-white/60">
|
|
||||||
Bid
|
|
||||||
{auctionSort === 'bid' && (auctionSortDir === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
|
||||||
</button>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sortedAuctions.map((auction, i) => (
|
|
||||||
<a
|
|
||||||
key={i}
|
|
||||||
href={auction.affiliate_url || '#'}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="grid grid-cols-[1fr_100px_80px_100px_40px] gap-4 items-center p-3 bg-[#020202] hover:bg-white/[0.02] active:bg-white/[0.03] transition-all group"
|
|
||||||
>
|
|
||||||
{/* Domain */}
|
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
|
||||||
<div className="w-8 h-8 bg-white/[0.02] border border-white/[0.06] flex items-center justify-center shrink-0">
|
|
||||||
<Gavel className="w-4 h-4 text-white/40 group-hover:text-accent transition-colors" />
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors">
|
|
||||||
{auction.domain}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Platform */}
|
|
||||||
<div className="text-center">
|
|
||||||
<span className="text-[10px] font-mono text-white/40 uppercase">{auction.platform}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Time */}
|
|
||||||
<div className="text-center">
|
|
||||||
<span className="text-xs font-mono text-white/50 flex items-center justify-center gap-1">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
{calcTimeRemaining(auction.end_time)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bid */}
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-sm font-bold text-accent font-mono">
|
|
||||||
${auction.current_bid.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Link */}
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<ExternalLink className="w-4 h-4 text-white/20 group-hover:text-accent transition-colors" />
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12 border border-dashed border-white/[0.08]">
|
|
||||||
<Gavel className="w-8 h-8 text-white/10 mx-auto mb-3" />
|
|
||||||
<p className="text-white/30 text-sm font-mono">No active auctions</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Desktop Quick Links */}
|
|
||||||
<div className="hidden lg:grid grid-cols-3 gap-4 mt-8">
|
|
||||||
{[
|
|
||||||
{ 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) => (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className="flex items-center gap-4 p-5 border border-white/[0.06] hover:border-accent/30 hover:bg-accent/[0.02] transition-all group"
|
|
||||||
>
|
|
||||||
<div className="w-12 h-12 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center group-hover:border-accent/20 group-hover:bg-accent/10 transition-all">
|
|
||||||
<item.icon className="w-5 h-5 text-white/40 group-hover:text-accent transition-colors" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-white group-hover:text-accent transition-colors">{item.label}</div>
|
|
||||||
<div className="text-xs text-white/30">{item.desc}</div>
|
|
||||||
</div>
|
|
||||||
<ArrowRight className="w-4 h-4 text-white/10 group-hover:text-accent group-hover:translate-x-1 transition-all ml-auto" />
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
|
||||||
{/* MOBILE BOTTOM NAV */}
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
|
||||||
<nav
|
|
||||||
className="lg:hidden fixed bottom-0 left-0 right-0 z-50 bg-[#020202] border-t border-white/[0.08]"
|
|
||||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
|
||||||
>
|
|
||||||
<div className="flex items-stretch h-14">
|
|
||||||
{mobileNavItems.map((item) => (
|
|
||||||
<Link
|
|
||||||
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"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.active && (
|
|
||||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-10 h-0.5 bg-accent" />
|
|
||||||
)}
|
|
||||||
<item.icon className="w-5 h-5" />
|
|
||||||
<span className="text-[9px] font-mono uppercase tracking-wider">{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Menu Button */}
|
|
||||||
<button
|
|
||||||
onClick={() => setMenuOpen(true)}
|
|
||||||
className="flex-1 flex flex-col items-center justify-center gap-0.5 text-white/40 active:text-white/80 transition-all"
|
|
||||||
>
|
|
||||||
<Menu className="w-5 h-5" />
|
|
||||||
<span className="text-[9px] font-mono uppercase tracking-wider">Menu</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
|
||||||
{/* MOBILE DRAWER */}
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
|
||||||
{menuOpen && (
|
|
||||||
<div className="lg:hidden fixed inset-0 z-[100]">
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-black/80 animate-in fade-in duration-200"
|
|
||||||
onClick={() => setMenuOpen(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Drawer Panel */}
|
|
||||||
<div className="absolute top-0 right-0 bottom-0 w-[80%] max-w-[300px] bg-[#0A0A0A] border-l border-white/[0.08] animate-in slide-in-from-right duration-300 flex flex-col" style={{ paddingTop: 'env(safe-area-inset-top)', paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
|
||||||
|
|
||||||
{/* Drawer Header */}
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-white/[0.08]">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Image src="/pounce-puma.png" alt="Pounce" width={28} height={28} className="object-contain" />
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-bold text-white tracking-wider">POUNCE</h2>
|
|
||||||
<p className="text-[9px] text-white/40 font-mono uppercase tracking-widest">Terminal v1.0</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setMenuOpen(false)}
|
|
||||||
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/60 hover:text-white active:bg-white/5 transition-all"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation Sections */}
|
|
||||||
<div className="flex-1 overflow-y-auto py-4">
|
|
||||||
{drawerNavSections.map((section) => (
|
|
||||||
<div key={section.title} className="mb-4">
|
|
||||||
<div className="flex items-center gap-2 px-4 mb-2">
|
|
||||||
<div className="w-1 h-3 bg-accent" />
|
|
||||||
<span className="text-[9px] font-bold text-white/30 uppercase tracking-[0.2em]">{section.title}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{section.items.map((item: any) => (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
onClick={() => 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.icon className="w-4 h-4 text-white/30" />
|
|
||||||
<span className="text-sm font-medium flex-1">{item.label}</span>
|
|
||||||
{item.isNew && (
|
|
||||||
<span className="px-1.5 py-0.5 text-[8px] font-bold bg-accent text-black">NEW</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Settings */}
|
|
||||||
<div className="pt-3 border-t border-white/[0.08] mx-4">
|
|
||||||
<Link
|
|
||||||
href="/terminal/settings"
|
|
||||||
onClick={() => setMenuOpen(false)}
|
|
||||||
className="flex items-center gap-3 py-2.5 text-white/50 active:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<Settings className="w-4 h-4" />
|
|
||||||
<span className="text-sm font-medium">Settings</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{user?.is_admin && (
|
|
||||||
<Link
|
|
||||||
href="/admin"
|
|
||||||
onClick={() => setMenuOpen(false)}
|
|
||||||
className="flex items-center gap-3 py-2.5 text-amber-500/70 active:text-amber-400 transition-colors"
|
|
||||||
>
|
|
||||||
<Shield className="w-4 h-4" />
|
|
||||||
<span className="text-sm font-medium">Admin</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User Card */}
|
|
||||||
<div className="p-4 bg-white/[0.02] border-t border-white/[0.08]">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center">
|
|
||||||
<TierIcon className="w-4 h-4 text-accent" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-bold text-white truncate">
|
|
||||||
{user?.name || user?.email?.split('@')[0] || 'User'}
|
|
||||||
</p>
|
|
||||||
<p className="text-[9px] font-mono text-white/40 uppercase tracking-wider">{tierName}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tierName === 'Scout' && (
|
|
||||||
<Link
|
|
||||||
href="/pricing"
|
|
||||||
onClick={() => 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"
|
|
||||||
>
|
|
||||||
<Sparkles className="w-3 h-3" />
|
|
||||||
Upgrade
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => { logout(); setMenuOpen(false) }}
|
|
||||||
className="flex items-center justify-center gap-2 w-full py-2 border border-white/10 text-white/40 text-[10px] font-mono uppercase tracking-wider active:bg-white/5 transition-all"
|
|
||||||
>
|
|
||||||
<LogOut className="w-3 h-3" />
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Toast */}
|
|
||||||
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -270,7 +270,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
// Mobile Nav - same as Intel page
|
// Mobile Nav - same as Intel page
|
||||||
const mobileNavItems = [
|
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/market', label: 'Market', icon: Gavel, active: false },
|
||||||
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
||||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
|
||||||
@ -278,7 +278,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
const drawerNavSections = [
|
const drawerNavSections = [
|
||||||
{ title: 'Discover', items: [
|
{ 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/market', label: 'Market', icon: Gavel },
|
||||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
||||||
]},
|
]},
|
||||||
|
|||||||
@ -145,7 +145,7 @@ export default function SniperAlertsPage() {
|
|||||||
|
|
||||||
// Mobile Nav
|
// Mobile Nav
|
||||||
const mobileNavItems = [
|
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/market', label: 'Market', icon: Gavel, active: false },
|
||||||
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
||||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
|
||||||
@ -156,7 +156,7 @@ export default function SniperAlertsPage() {
|
|||||||
|
|
||||||
const drawerNavSections = [
|
const drawerNavSections = [
|
||||||
{ title: 'Discover', items: [
|
{ 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/market', label: 'Market', icon: Gavel },
|
||||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
||||||
]},
|
]},
|
||||||
|
|||||||
@ -247,9 +247,9 @@ export default function WatchlistPage() {
|
|||||||
|
|
||||||
// Mobile Nav
|
// Mobile Nav
|
||||||
const mobileNavItems = [
|
const mobileNavItems = [
|
||||||
{ href: '/terminal/radar', label: 'Radar', icon: Target, active: false },
|
{ href: '/terminal/hunt', label: 'Hunt', icon: Target, active: false },
|
||||||
{ href: '/terminal/market', label: 'Market', icon: Gavel, active: false },
|
|
||||||
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: true },
|
{ 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 },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -260,8 +260,7 @@ export default function WatchlistPage() {
|
|||||||
{
|
{
|
||||||
title: 'Discover',
|
title: 'Discover',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/terminal/radar', label: 'Radar', icon: Target },
|
{ href: '/terminal/hunt', label: 'Hunt', icon: Target },
|
||||||
{ href: '/terminal/market', label: 'Market', icon: Gavel },
|
|
||||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -188,7 +188,7 @@ export default function WelcomePage() {
|
|||||||
{/* Go to Dashboard */}
|
{/* Go to Dashboard */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Link
|
<Link
|
||||||
href="/terminal/radar"
|
href="/terminal/hunt"
|
||||||
className="inline-flex items-center gap-2 px-8 py-4 bg-accent text-background font-medium rounded-xl
|
className="inline-flex items-center gap-2 px-8 py-4 bg-accent text-background font-medium rounded-xl
|
||||||
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
|
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -360,7 +360,7 @@ export default function YieldPage() {
|
|||||||
const stats = dashboard?.stats
|
const stats = dashboard?.stats
|
||||||
|
|
||||||
const mobileNavItems = [
|
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/market', label: 'Market', icon: Gavel, active: false },
|
||||||
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
||||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
|
||||||
@ -371,7 +371,7 @@ export default function YieldPage() {
|
|||||||
|
|
||||||
const drawerNavSections = [
|
const drawerNavSections = [
|
||||||
{ title: 'Discover', items: [
|
{ 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/market', label: 'Market', icon: Gavel },
|
||||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
||||||
]},
|
]},
|
||||||
|
|||||||
@ -92,7 +92,7 @@ export function AdminLayout({
|
|||||||
<h1 className="text-xl font-semibold text-foreground mb-2">Access Denied</h1>
|
<h1 className="text-xl font-semibold text-foreground mb-2">Access Denied</h1>
|
||||||
<p className="text-foreground-muted mb-4">Admin privileges required</p>
|
<p className="text-foreground-muted mb-4">Admin privileges required</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/terminal/radar')}
|
onClick={() => router.push('/terminal/hunt')}
|
||||||
className="px-4 py-2 bg-accent text-background rounded-lg font-medium"
|
className="px-4 py-2 bg-accent text-background rounded-lg font-medium"
|
||||||
>
|
>
|
||||||
Go to Dashboard
|
Go to Dashboard
|
||||||
@ -288,7 +288,7 @@ function AdminSidebar({
|
|||||||
<div className="border-t border-border/30 py-4 px-3 space-y-2">
|
<div className="border-t border-border/30 py-4 px-3 space-y-2">
|
||||||
{/* Back to User Dashboard */}
|
{/* Back to User Dashboard */}
|
||||||
<Link
|
<Link
|
||||||
href="/terminal/radar"
|
href="/terminal/hunt"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center gap-3 px-3 py-3 rounded-xl transition-all duration-300",
|
"flex items-center gap-3 px-3 py-3 rounded-xl transition-all duration-300",
|
||||||
"text-accent hover:bg-accent/10 border border-transparent hover:border-accent/20"
|
"text-accent hover:bg-accent/10 border border-transparent hover:border-accent/20"
|
||||||
|
|||||||
@ -219,7 +219,7 @@ export function DomainChecker() {
|
|||||||
<span className="text-sm font-mono text-emerald-100/80">Market Open</span>
|
<span className="text-sm font-mono text-emerald-100/80">Market Open</span>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href={isAuthenticated ? '/terminal/radar' : '/register'}
|
href={isAuthenticated ? '/terminal/hunt' : '/register'}
|
||||||
className="group relative px-6 py-3 bg-emerald-500 hover:bg-emerald-400 transition-colors text-black font-bold uppercase tracking-wider text-xs flex items-center gap-2"
|
className="group relative px-6 py-3 bg-emerald-500 hover:bg-emerald-400 transition-colors text-black font-bold uppercase tracking-wider text-xs flex items-center gap-2"
|
||||||
style={{ clipPath: 'polygon(12px 0, 100% 0, 100% 100%, 0 100%, 0 12px)' }}
|
style={{ clipPath: 'polygon(12px 0, 100% 0, 100% 100%, 0 100%, 0 12px)' }}
|
||||||
>
|
>
|
||||||
@ -270,7 +270,7 @@ export function DomainChecker() {
|
|||||||
<div className="p-4 bg-rose-950/[0.05] border-t border-rose-500/10 flex items-center justify-between">
|
<div className="p-4 bg-rose-950/[0.05] border-t border-rose-500/10 flex items-center justify-between">
|
||||||
<span className="text-xs text-rose-500/50 font-mono">Target this asset?</span>
|
<span className="text-xs text-rose-500/50 font-mono">Target this asset?</span>
|
||||||
<Link
|
<Link
|
||||||
href={isAuthenticated ? '/terminal/radar' : '/register'}
|
href={isAuthenticated ? '/terminal/hunt' : '/register'}
|
||||||
className="group relative px-6 py-3 bg-rose-500 hover:bg-rose-400 transition-colors text-black font-bold uppercase tracking-wider text-xs flex items-center gap-2"
|
className="group relative px-6 py-3 bg-rose-500 hover:bg-rose-400 transition-colors text-black font-bold uppercase tracking-wider text-xs flex items-center gap-2"
|
||||||
style={{ clipPath: 'polygon(12px 0, 100% 0, 100% 100%, 0 100%, 0 12px)' }}
|
style={{ clipPath: 'polygon(12px 0, 100% 0, 100% 100%, 0 100%, 0 12px)' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -86,7 +86,7 @@ export function Header() {
|
|||||||
<nav className="hidden sm:flex items-center h-full gap-3">
|
<nav className="hidden sm:flex items-center h-full gap-3">
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<Link
|
<Link
|
||||||
href="/terminal/radar"
|
href="/terminal/hunt"
|
||||||
className="flex items-center gap-2 h-9 px-5 text-xs bg-accent text-black font-bold uppercase tracking-wider hover:bg-white transition-colors"
|
className="flex items-center gap-2 h-9 px-5 text-xs bg-accent text-black font-bold uppercase tracking-wider hover:bg-white transition-colors"
|
||||||
>
|
>
|
||||||
<Target className="w-4 h-4" />
|
<Target className="w-4 h-4" />
|
||||||
@ -194,7 +194,7 @@ export function Header() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/terminal/radar"
|
href="/terminal/hunt"
|
||||||
onClick={() => setMenuOpen(false)}
|
onClick={() => setMenuOpen(false)}
|
||||||
className="flex items-center justify-center gap-2 w-full py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider active:scale-[0.98] transition-all mb-2"
|
className="flex items-center justify-center gap-2 w-full py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider active:scale-[0.98] transition-all mb-2"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -5,9 +5,7 @@ import Image from 'next/image'
|
|||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
|
||||||
Eye,
|
Eye,
|
||||||
Gavel,
|
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Settings,
|
Settings,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@ -22,7 +20,6 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Target,
|
Target,
|
||||||
Coins,
|
Coins,
|
||||||
Radar,
|
|
||||||
Briefcase,
|
Briefcase,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@ -67,7 +64,7 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
|||||||
const availableCount = domains?.filter(d => d.is_available).length || 0
|
const availableCount = domains?.filter(d => d.is_available).length || 0
|
||||||
const isTycoon = tierName.toLowerCase() === 'tycoon'
|
const isTycoon = tierName.toLowerCase() === 'tycoon'
|
||||||
|
|
||||||
// SECTION 1: Discover - Radar first, then external market data
|
// SECTION 1: Discover - Hunt is the main discovery hub
|
||||||
const discoverItems = [
|
const discoverItems = [
|
||||||
{
|
{
|
||||||
href: '/terminal/hunt',
|
href: '/terminal/hunt',
|
||||||
@ -75,18 +72,6 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
|||||||
icon: Crosshair,
|
icon: Crosshair,
|
||||||
badge: null,
|
badge: null,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: '/terminal/radar',
|
|
||||||
label: 'RADAR',
|
|
||||||
icon: Radar,
|
|
||||||
badge: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/terminal/market',
|
|
||||||
label: 'MARKET',
|
|
||||||
icon: Gavel,
|
|
||||||
badge: null,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: '/terminal/intel',
|
href: '/terminal/intel',
|
||||||
label: 'INTEL',
|
label: 'INTEL',
|
||||||
@ -115,12 +100,6 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
|||||||
icon: Briefcase,
|
icon: Briefcase,
|
||||||
badge: null,
|
badge: null,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: '/terminal/cfo',
|
|
||||||
label: 'CFO',
|
|
||||||
icon: Shield,
|
|
||||||
badge: null,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: '/terminal/inbox',
|
href: '/terminal/inbox',
|
||||||
label: 'INBOX',
|
label: 'INBOX',
|
||||||
@ -163,7 +142,7 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
|||||||
]
|
]
|
||||||
|
|
||||||
const isActive = (href: string) => {
|
const isActive = (href: string) => {
|
||||||
if (href === '/terminal/radar') return pathname === '/terminal/radar' || pathname === '/terminal' || pathname === '/terminal/dashboard'
|
if (href === '/terminal/hunt') return pathname === '/terminal/hunt' || pathname === '/terminal' || pathname === '/terminal/dashboard'
|
||||||
return pathname.startsWith(href)
|
return pathname.startsWith(href)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
644
frontend/src/components/hunt/AuctionsTab.tsx
Normal file
644
frontend/src/components/hunt/AuctionsTab.tsx
Normal file
@ -0,0 +1,644 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||||
|
import { useStore } from '@/lib/store'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||||
|
import {
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
Diamond,
|
||||||
|
Zap,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
RefreshCw,
|
||||||
|
Clock,
|
||||||
|
Search,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
ShieldCheck,
|
||||||
|
Ban,
|
||||||
|
X,
|
||||||
|
Filter,
|
||||||
|
Shield,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface MarketItem {
|
||||||
|
id: string
|
||||||
|
domain: string
|
||||||
|
tld: string
|
||||||
|
price: number
|
||||||
|
currency: string
|
||||||
|
price_type: 'bid' | 'fixed' | 'negotiable'
|
||||||
|
status: 'auction' | 'instant'
|
||||||
|
source: string
|
||||||
|
is_pounce: boolean
|
||||||
|
verified: boolean
|
||||||
|
time_remaining?: string
|
||||||
|
end_time?: string
|
||||||
|
num_bids?: number
|
||||||
|
slug?: string
|
||||||
|
seller_verified: boolean
|
||||||
|
url: string
|
||||||
|
is_external: boolean
|
||||||
|
pounce_score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceFilter = 'all' | 'pounce' | 'external'
|
||||||
|
type PriceRange = 'all' | 'low' | 'mid' | 'high'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HELPERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSecondsUntilEnd(endTimeIso?: string): number {
|
||||||
|
if (!endTimeIso) return Infinity
|
||||||
|
const diff = new Date(endTimeIso).getTime() - Date.now()
|
||||||
|
return diff > 0 ? diff / 1000 : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(price: number, currency = 'USD'): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(price)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSpam(domain: string): boolean {
|
||||||
|
const name = domain.split('.')[0]
|
||||||
|
if (name.includes('-')) return true
|
||||||
|
if (name.length > 4 && /\d/.test(name)) return true
|
||||||
|
if (/^\d+$/.test(name)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// COMPONENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface AuctionsTabProps {
|
||||||
|
showToast: (message: string, type?: 'success' | 'error' | 'info') => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuctionsTab({ showToast }: AuctionsTabProps) {
|
||||||
|
const openAnalyze = useAnalyzePanelStore((s) => s.open)
|
||||||
|
|
||||||
|
const [items, setItems] = useState<MarketItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [stats, setStats] = useState({ total: 0, pounceCount: 0, auctionCount: 0, highScore: 0 })
|
||||||
|
|
||||||
|
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [priceRange, setPriceRange] = useState<PriceRange>('all')
|
||||||
|
const [hideSpam, setHideSpam] = useState(true)
|
||||||
|
const [tldFilter, setTldFilter] = useState<string>('all')
|
||||||
|
const [searchFocused, setSearchFocused] = useState(false)
|
||||||
|
|
||||||
|
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set())
|
||||||
|
const [trackingInProgress, setTrackingInProgress] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [totalPages, setTotalPages] = useState(1)
|
||||||
|
const ITEMS_PER_PAGE = 50
|
||||||
|
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||||
|
|
||||||
|
const [sortField, setSortField] = useState<'domain' | 'score' | 'price' | 'time'>('time')
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
|
||||||
|
|
||||||
|
const loadData = useCallback(async (currentPage = 1) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await api.getMarketFeed({
|
||||||
|
source: sourceFilter,
|
||||||
|
keyword: searchQuery || undefined,
|
||||||
|
tld: tldFilter === 'all' ? undefined : tldFilter,
|
||||||
|
minPrice: priceRange === 'low' ? undefined : priceRange === 'mid' ? 100 : priceRange === 'high' ? 1000 : undefined,
|
||||||
|
maxPrice: priceRange === 'low' ? 100 : priceRange === 'mid' ? 1000 : undefined,
|
||||||
|
sortBy: 'time',
|
||||||
|
limit: ITEMS_PER_PAGE,
|
||||||
|
offset: (currentPage - 1) * ITEMS_PER_PAGE,
|
||||||
|
})
|
||||||
|
|
||||||
|
setItems(result.items || [])
|
||||||
|
setStats({
|
||||||
|
total: result.total,
|
||||||
|
pounceCount: result.pounce_direct_count,
|
||||||
|
auctionCount: result.auction_count,
|
||||||
|
highScore: (result.items || []).filter((i: MarketItem) => i.pounce_score >= 80).length,
|
||||||
|
})
|
||||||
|
setTotalPages(Math.ceil((result.total || 0) / ITEMS_PER_PAGE))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load market data:', error)
|
||||||
|
setItems([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [sourceFilter, searchQuery, priceRange, tldFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1)
|
||||||
|
loadData(1)
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
const handlePageChange = useCallback((newPage: number) => {
|
||||||
|
setPage(newPage)
|
||||||
|
loadData(newPage)
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
setRefreshing(true)
|
||||||
|
await loadData()
|
||||||
|
setRefreshing(false)
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTrackedDomains = async () => {
|
||||||
|
try {
|
||||||
|
const result = await api.getDomains(1, 100)
|
||||||
|
const domainSet = new Set(result.domains.map((d: any) => d.name))
|
||||||
|
setTrackedDomains(domainSet)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tracked domains:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadTrackedDomains()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTrack = useCallback(async (domain: string) => {
|
||||||
|
if (trackingInProgress) return
|
||||||
|
setTrackingInProgress(domain)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (trackedDomains.has(domain)) {
|
||||||
|
const result = await api.getDomains(1, 100)
|
||||||
|
const domainToDelete = result.domains.find((d: any) => d.name === domain)
|
||||||
|
if (domainToDelete) {
|
||||||
|
await api.deleteDomain(domainToDelete.id)
|
||||||
|
setTrackedDomains((prev) => {
|
||||||
|
const next = new Set(Array.from(prev))
|
||||||
|
next.delete(domain)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
showToast(`Removed: ${domain}`, 'success')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await api.addDomain(domain)
|
||||||
|
setTrackedDomains((prev) => new Set([...Array.from(prev), domain]))
|
||||||
|
showToast(`Tracking: ${domain}`, 'success')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
showToast(error.message || 'Failed', 'error')
|
||||||
|
} finally {
|
||||||
|
setTrackingInProgress(null)
|
||||||
|
}
|
||||||
|
}, [trackedDomains, trackingInProgress, showToast])
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
let filtered = items
|
||||||
|
|
||||||
|
const nowMs = Date.now()
|
||||||
|
filtered = filtered.filter((item) => {
|
||||||
|
if (item.status !== 'auction') return true
|
||||||
|
if (!item.end_time) return true
|
||||||
|
const t = Date.parse(item.end_time)
|
||||||
|
if (Number.isNaN(t)) return true
|
||||||
|
return t > nowMs - 2000
|
||||||
|
})
|
||||||
|
|
||||||
|
if (searchQuery && !loading) {
|
||||||
|
const query = searchQuery.toLowerCase()
|
||||||
|
filtered = filtered.filter((item) => item.domain.toLowerCase().includes(query))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hideSpam) {
|
||||||
|
filtered = filtered.filter((item) => !isSpam(item.domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
const mult = sortDirection === 'asc' ? 1 : -1
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
switch (sortField) {
|
||||||
|
case 'domain':
|
||||||
|
return mult * a.domain.localeCompare(b.domain)
|
||||||
|
case 'score':
|
||||||
|
return mult * ((a.pounce_score || 0) - (b.pounce_score || 0))
|
||||||
|
case 'price':
|
||||||
|
return mult * (a.price - b.price)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}, [items, searchQuery, loading, hideSpam, sortField, sortDirection])
|
||||||
|
|
||||||
|
const handleSort = useCallback((field: typeof sortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||||
|
} else {
|
||||||
|
setSortField(field)
|
||||||
|
setSortDirection(field === 'time' || field === 'price' ? 'asc' : 'desc')
|
||||||
|
}
|
||||||
|
}, [sortField])
|
||||||
|
|
||||||
|
const activeFiltersCount = [sourceFilter !== 'all', priceRange !== 'all', tldFilter !== 'all', hideSpam].filter(Boolean).length
|
||||||
|
|
||||||
|
if (loading && items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Search */}
|
||||||
|
<div className={clsx(
|
||||||
|
"relative border transition-all duration-200",
|
||||||
|
searchFocused ? "border-accent/50 bg-accent/[0.02]" : "border-white/[0.08] bg-white/[0.02]"
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Search className={clsx("w-4 h-4 ml-3 transition-colors", searchFocused ? "text-accent" : "text-white/30")} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onFocus={() => setSearchFocused(true)}
|
||||||
|
onBlur={() => setSearchFocused(false)}
|
||||||
|
placeholder="Filter auctions..."
|
||||||
|
className="flex-1 bg-transparent px-3 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button onClick={() => setSearchQuery('')} className="p-3 text-white/30 hover:text-white transition-colors">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={handleRefresh} disabled={refreshing} className="p-3 text-white/30 hover:text-white transition-colors">
|
||||||
|
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setFiltersOpen(!filtersOpen)}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center justify-between w-full py-2 px-3 border transition-colors",
|
||||||
|
filtersOpen ? "border-accent/30 bg-accent/[0.05]" : "border-white/[0.08] bg-white/[0.02]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="w-4 h-4 text-white/40" />
|
||||||
|
<span className="text-xs font-mono text-white/60">Filters</span>
|
||||||
|
{activeFiltersCount > 0 && <span className="px-1.5 py-0.5 text-[9px] font-bold bg-accent text-black">{activeFiltersCount}</span>}
|
||||||
|
</div>
|
||||||
|
<ChevronRight className={clsx("w-4 h-4 text-white/30 transition-transform", filtersOpen && "rotate-90")} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Filters Panel */}
|
||||||
|
{filtersOpen && (
|
||||||
|
<div className="p-3 border border-white/[0.08] bg-white/[0.02] space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
|
{/* Source */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider mb-2">Source</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'pounce', label: 'Pounce', icon: Diamond },
|
||||||
|
{ value: 'external', label: 'External' },
|
||||||
|
].map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
onClick={() => setSourceFilter(item.value as SourceFilter)}
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 py-2 text-[10px] font-bold uppercase tracking-wider border transition-colors flex items-center justify-center gap-1",
|
||||||
|
sourceFilter === item.value ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon && <item.icon className="w-3 h-3" />}
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TLD */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider mb-2">TLD</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{['all', 'com', 'ai', 'io', 'net'].map((tld) => (
|
||||||
|
<button
|
||||||
|
key={tld}
|
||||||
|
onClick={() => setTldFilter(tld)}
|
||||||
|
className={clsx(
|
||||||
|
"px-3 py-1.5 text-[10px] font-mono uppercase border transition-colors",
|
||||||
|
tldFilter === tld ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tld === 'all' ? 'All' : `.${tld}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider mb-2">Price</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'low', label: '< $100' },
|
||||||
|
{ value: 'mid', label: '< $1k' },
|
||||||
|
{ value: 'high', label: '$1k+' },
|
||||||
|
].map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
onClick={() => setPriceRange(item.value as PriceRange)}
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 py-1.5 text-[10px] font-mono border transition-colors",
|
||||||
|
priceRange === item.value ? "border-amber-400 bg-amber-400/10 text-amber-400" : "border-white/[0.08] text-white/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spam Filter */}
|
||||||
|
<button
|
||||||
|
onClick={() => setHideSpam(!hideSpam)}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center justify-between w-full py-2 px-3 border transition-colors",
|
||||||
|
hideSpam ? "border-white/20 bg-white/[0.05]" : "border-white/[0.08]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Ban className="w-4 h-4 text-white/40" />
|
||||||
|
<span className="text-xs font-mono text-white/60">Hide spam domains</span>
|
||||||
|
</div>
|
||||||
|
<div className={clsx("w-4 h-4 border flex items-center justify-center", hideSpam ? "border-accent bg-accent" : "border-white/30")}>
|
||||||
|
{hideSpam && <span className="text-black text-[10px] font-bold">✓</span>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Bar */}
|
||||||
|
<div className="flex items-center justify-between text-[10px] font-mono text-white/40">
|
||||||
|
<span>{filteredItems.length} domains shown</span>
|
||||||
|
<span>{filteredItems.filter((i) => i.pounce_score >= 80).length} high score</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{filteredItems.length === 0 ? (
|
||||||
|
<div className="text-center py-16 border border-dashed border-white/[0.08]">
|
||||||
|
<Search className="w-8 h-8 text-white/10 mx-auto mb-3" />
|
||||||
|
<p className="text-white/40 text-sm font-mono">No domains found</p>
|
||||||
|
<p className="text-white/25 text-xs font-mono mt-1">Try adjusting filters</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
|
||||||
|
{/* Desktop Table Header */}
|
||||||
|
<div className="hidden lg:grid grid-cols-[1fr_80px_100px_80px_120px] gap-4 px-3 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
|
||||||
|
<button onClick={() => handleSort('domain')} className="flex items-center gap-1 hover:text-white/60 text-left">
|
||||||
|
Domain
|
||||||
|
{sortField === 'domain' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleSort('score')} className="flex items-center gap-1 justify-center hover:text-white/60">
|
||||||
|
Score
|
||||||
|
{sortField === 'score' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleSort('price')} className="flex items-center gap-1 justify-end hover:text-white/60">
|
||||||
|
Price
|
||||||
|
{sortField === 'price' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleSort('time')} className="flex items-center gap-1 justify-center hover:text-white/60">
|
||||||
|
Time
|
||||||
|
{sortField === 'time' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||||
|
</button>
|
||||||
|
<div className="text-right">Actions</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<div key={item.id} className={clsx("bg-[#020202] hover:bg-white/[0.02] transition-all", isPounce && "bg-accent/[0.02]")}>
|
||||||
|
{/* Mobile Row */}
|
||||||
|
<div className="lg:hidden p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-2">
|
||||||
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
<div className={clsx("w-8 h-8 flex items-center justify-center border shrink-0", isPounce ? "bg-accent/10 border-accent/20" : "bg-white/[0.02] border-white/[0.06]")}>
|
||||||
|
{isPounce ? <Diamond className="w-4 h-4 text-accent" /> : <span className="text-[9px] font-mono text-white/40">{item.source.substring(0, 2).toUpperCase()}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<button onClick={() => openAnalyze(item.domain)} className="text-sm font-bold text-white font-mono truncate text-left">
|
||||||
|
{item.domain}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-mono text-white/30">
|
||||||
|
<span className="uppercase">{item.source}</span>
|
||||||
|
<span className="text-white/10">|</span>
|
||||||
|
<span className={clsx(isUrgent && "text-orange-400")}>{isPounce ? 'Instant' : displayTime || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<div className={clsx("text-base font-bold font-mono", isPounce ? "text-accent" : "text-white")}>{formatPrice(item.price)}</div>
|
||||||
|
<div className={clsx("text-[9px] font-mono px-1 py-0.5 inline-block", item.pounce_score >= 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}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTrack(item.domain)}
|
||||||
|
disabled={isTracking}
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 py-2 text-[10px] font-bold uppercase tracking-wider border flex items-center justify-center gap-1.5 transition-all",
|
||||||
|
isTracked ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isTracking ? <Loader2 className="w-3 h-3 animate-spin" /> : isTracked ? <Eye className="w-3 h-3" /> : <EyeOff className="w-3 h-3" />}
|
||||||
|
{isTracked ? 'Tracked' : 'Track'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => openAnalyze(item.domain)} className="w-10 py-2 text-[10px] font-bold uppercase tracking-wider border border-white/[0.08] text-white/50 flex items-center justify-center transition-all hover:text-white hover:bg-white/5">
|
||||||
|
<Shield className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target={isPounce ? '_self' : '_blank'}
|
||||||
|
rel={isPounce ? undefined : 'noopener noreferrer'}
|
||||||
|
className={clsx("flex-1 py-2 text-[10px] font-bold uppercase tracking-wider flex items-center justify-center gap-1.5 transition-all", isPounce ? "bg-accent text-black" : "bg-white/10 text-white")}
|
||||||
|
>
|
||||||
|
{isPounce ? 'Buy' : 'Bid'}
|
||||||
|
{!isPounce && <ExternalLink className="w-3 h-3" />}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Row */}
|
||||||
|
<div className="hidden lg:grid grid-cols-[1fr_80px_100px_80px_120px] gap-4 items-center p-3 group">
|
||||||
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
<div className={clsx("w-8 h-8 flex items-center justify-center border shrink-0", isPounce ? "bg-accent/10 border-accent/20" : "bg-white/[0.02] border-white/[0.06]")}>
|
||||||
|
{isPounce ? <Diamond className="w-4 h-4 text-accent" /> : <span className="text-[9px] font-mono text-white/40">{item.source.substring(0, 2).toUpperCase()}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<button onClick={() => openAnalyze(item.domain)} className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left">
|
||||||
|
{item.domain}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-mono text-white/30">
|
||||||
|
<span className="uppercase">{item.source}</span>
|
||||||
|
{isPounce && item.verified && (
|
||||||
|
<>
|
||||||
|
<span className="text-white/10">|</span>
|
||||||
|
<span className="text-accent flex items-center gap-0.5">
|
||||||
|
<ShieldCheck className="w-3 h-3" />
|
||||||
|
Verified
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.num_bids ? (
|
||||||
|
<>
|
||||||
|
<span className="text-white/10">|</span>
|
||||||
|
{item.num_bids} bids
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-16 text-center shrink-0">
|
||||||
|
<span className={clsx("text-xs font-mono font-bold px-2 py-0.5", item.pounce_score >= 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}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-24 text-right shrink-0">
|
||||||
|
<div className={clsx("font-mono text-sm font-bold", isPounce ? "text-accent" : "text-white")}>{formatPrice(item.price)}</div>
|
||||||
|
<div className="text-[9px] font-mono text-white/30 uppercase">{item.price_type === 'bid' ? 'Bid' : 'Buy Now'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-20 text-center shrink-0">
|
||||||
|
{isPounce ? (
|
||||||
|
<span className="text-xs text-accent font-mono flex items-center justify-center gap-1">
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
Instant
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={clsx("text-xs font-mono", isUrgent ? "text-orange-400" : "text-white/50")}>{displayTime || 'N/A'}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0 opacity-50 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTrack(item.domain)}
|
||||||
|
disabled={isTracking}
|
||||||
|
className={clsx(
|
||||||
|
"w-7 h-7 flex items-center justify-center border transition-colors",
|
||||||
|
isTracked ? "bg-accent/10 text-accent border-accent/20 hover:bg-red-500/10 hover:text-red-400 hover:border-red-500/20" : "text-white/30 border-white/10 hover:text-accent hover:bg-accent/10 hover:border-accent/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isTracking ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : isTracked ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => openAnalyze(item.domain)} className="w-7 h-7 flex items-center justify-center border transition-colors text-white/30 border-white/10 hover:text-accent hover:bg-accent/10 hover:border-accent/20">
|
||||||
|
<Shield className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target={isPounce ? '_self' : '_blank'}
|
||||||
|
rel={isPounce ? undefined : 'noopener noreferrer'}
|
||||||
|
className={clsx("h-7 px-3 flex items-center gap-1.5 text-xs font-bold transition-colors", isPounce ? "bg-accent text-black hover:bg-white" : "bg-white/10 text-white hover:bg-white/20")}
|
||||||
|
>
|
||||||
|
{isPounce ? 'Buy' : 'Bid'}
|
||||||
|
{!isPounce && <ExternalLink className="w-3 h-3" />}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between pt-4">
|
||||||
|
<div className="text-[10px] text-white/40 font-mono uppercase tracking-wider">
|
||||||
|
Page {page}/{totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(page - 1)}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="text-xs text-white/50 font-mono px-2">
|
||||||
|
{page}/{totalPages}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(page + 1)}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
169
frontend/src/components/hunt/DropsTab.tsx
Normal file
169
frontend/src/components/hunt/DropsTab.tsx
Normal file
@ -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<string>('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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header Controls */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Search */}
|
||||||
|
<div className={clsx(
|
||||||
|
"relative border transition-all duration-200",
|
||||||
|
searchFocused ? "border-accent/50 bg-accent/[0.02]" : "border-white/[0.08] bg-white/[0.02]"
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Search className={clsx("w-4 h-4 ml-3 transition-colors", searchFocused ? "text-accent" : "text-white/30")} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TLD Selector */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{tlds.map((tld) => (
|
||||||
|
<button
|
||||||
|
key={tld}
|
||||||
|
onClick={() => setSelectedTld(tld)}
|
||||||
|
className={clsx(
|
||||||
|
"px-3 py-1.5 text-[10px] font-mono uppercase border transition-colors",
|
||||||
|
selectedTld === tld
|
||||||
|
? "border-accent bg-accent/10 text-accent"
|
||||||
|
: "border-white/[0.08] text-white/40 hover:text-white/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
.{tld}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card - Setup Required */}
|
||||||
|
<div className="border border-amber-500/20 bg-amber-500/[0.05] p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-sm font-bold text-amber-400 mb-1">Zone File Integration</h3>
|
||||||
|
<p className="text-xs text-white/50 leading-relaxed mb-3">
|
||||||
|
This feature analyzes zone files to find freshly dropped domains.
|
||||||
|
Configure your zone file data source to see real-time drops.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider border border-amber-500/30 text-amber-400 hover:bg-amber-500/10 transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
|
||||||
|
Refresh Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature Preview Cards */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||||
|
<div className="border border-white/[0.08] bg-white/[0.02] p-4">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center">
|
||||||
|
<Clock className="w-5 h-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-white">Real-time Drops</div>
|
||||||
|
<div className="text-[10px] font-mono text-white/40">Updated every hour</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-white/30">
|
||||||
|
Monitor domains as they expire and become available for registration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-white/[0.08] bg-white/[0.02] p-4">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-10 h-10 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center">
|
||||||
|
<Filter className="w-5 h-5 text-white/40" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-white">Smart Filters</div>
|
||||||
|
<div className="text-[10px] font-mono text-white/40">Length, keywords, patterns</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-white/30">
|
||||||
|
Filter by domain length, keywords, character patterns, and more.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-white/[0.08] bg-white/[0.02] p-4">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-10 h-10 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center">
|
||||||
|
<Globe className="w-5 h-5 text-white/40" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-white">Multi-TLD</div>
|
||||||
|
<div className="text-[10px] font-mono text-white/40">All major TLDs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-white/30">
|
||||||
|
Track drops across .com, .net, .org, .io, .ai, and more.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Placeholder Table */}
|
||||||
|
<div className="border border-white/[0.08] bg-white/[0.02]">
|
||||||
|
<div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">Freshly Dropped</div>
|
||||||
|
<div className="text-sm font-bold text-white">.{selectedTld.toUpperCase()} Domains</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-mono text-white/30">
|
||||||
|
Awaiting data...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<Globe className="w-12 h-12 text-white/10 mx-auto mb-4" />
|
||||||
|
<p className="text-white/40 text-sm font-mono mb-2">No zone file data available</p>
|
||||||
|
<p className="text-white/25 text-xs font-mono">
|
||||||
|
Configure zone file integration to see dropped domains
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
349
frontend/src/components/hunt/SearchTab.tsx
Normal file
349
frontend/src/components/hunt/SearchTab.tsx
Normal file
@ -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<SearchResult | null>(null)
|
||||||
|
const [addingToWatchlist, setAddingToWatchlist] = useState(false)
|
||||||
|
const [searchFocused, setSearchFocused] = useState(false)
|
||||||
|
const [recentSearches, setRecentSearches] = useState<string[]>([])
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Search Card */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute -inset-6 bg-gradient-to-tr from-accent/5 via-transparent to-accent/5 blur-3xl opacity-50 pointer-events-none hidden lg:block" />
|
||||||
|
|
||||||
|
<div className="relative bg-[#0A0A0A] border border-white/[0.08] overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-white/[0.06] bg-black/40">
|
||||||
|
<span className="text-[10px] font-mono text-white/40 flex items-center gap-2">
|
||||||
|
<Search className="w-3 h-3 text-accent" />
|
||||||
|
Domain Search
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<div className="w-2 h-2 bg-white/10" />
|
||||||
|
<div className="w-2 h-2 bg-white/10" />
|
||||||
|
<div className="w-2 h-2 bg-accent/50" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 lg:p-6">
|
||||||
|
<div className={clsx(
|
||||||
|
"relative border-2 transition-all duration-200",
|
||||||
|
searchFocused ? "border-accent/50 bg-accent/[0.02]" : "border-white/[0.08] bg-white/[0.02]"
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Search className={clsx("w-5 h-5 ml-4 transition-colors", searchFocused ? "text-accent" : "text-white/30")} />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSearchQuery(''); setSearchResult(null) }}
|
||||||
|
className="p-4 text-white/30 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!searchResult && (
|
||||||
|
<p className="text-[10px] text-white/30 mt-3 font-mono">
|
||||||
|
Enter a domain to check availability, WHOIS data, and registration options
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Result */}
|
||||||
|
{searchResult && (
|
||||||
|
<div className="animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||||
|
{searchResult.loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-3 py-12 bg-white/[0.02] border border-white/[0.06]">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-accent" />
|
||||||
|
<span className="text-sm text-white/50 font-mono">Checking availability...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={clsx(
|
||||||
|
"border-2 overflow-hidden",
|
||||||
|
searchResult.is_available ? "border-accent/40 bg-accent/[0.03]" : "border-white/[0.08] bg-white/[0.02]"
|
||||||
|
)}>
|
||||||
|
{/* Result Header */}
|
||||||
|
<div className={clsx(
|
||||||
|
"px-4 py-4 flex items-center justify-between",
|
||||||
|
searchResult.is_available ? "bg-accent/[0.05]" : "bg-white/[0.02]"
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{searchResult.is_available ? (
|
||||||
|
<div className="w-12 h-12 bg-accent/20 border border-accent/30 flex items-center justify-center">
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-accent" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 bg-white/[0.02] border border-white/[0.08] flex items-center justify-center">
|
||||||
|
<XCircle className="w-6 h-6 text-white/30" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-white font-mono">{searchResult.domain}</div>
|
||||||
|
<div className="text-[10px] text-white/40 font-mono uppercase tracking-wider">
|
||||||
|
{searchResult.is_available ? 'Available for registration' : 'Already registered'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={clsx(
|
||||||
|
"text-xs font-bold px-3 py-1.5 uppercase tracking-wider",
|
||||||
|
searchResult.is_available ? "bg-accent text-black" : "bg-white/10 text-white/50"
|
||||||
|
)}>
|
||||||
|
{searchResult.is_available ? 'Available' : 'Taken'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* WHOIS Info (if taken) */}
|
||||||
|
{!searchResult.is_available && (searchResult.registrar || searchResult.expiration_date) && (
|
||||||
|
<div className="px-4 py-3 border-t border-white/[0.06] grid grid-cols-2 gap-4">
|
||||||
|
{searchResult.registrar && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building className="w-4 h-4 text-white/30" />
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] font-mono text-white/30 uppercase">Registrar</div>
|
||||||
|
<div className="text-xs text-white/60 font-mono">{searchResult.registrar}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{searchResult.expiration_date && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-white/30" />
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] font-mono text-white/30 uppercase">Expires</div>
|
||||||
|
<div className="text-xs text-white/60 font-mono">
|
||||||
|
{new Date(searchResult.expiration_date).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="p-4 flex gap-3 border-t border-white/[0.06]">
|
||||||
|
<button
|
||||||
|
onClick={() => openAnalyze(searchResult.domain)}
|
||||||
|
className="flex-1 py-3 text-sm font-bold flex items-center justify-center gap-2 transition-all active:scale-[0.98] border border-white/20 text-white hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
Analyze
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleAddToWatchlist}
|
||||||
|
disabled={addingToWatchlist}
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 py-3 text-sm font-bold flex items-center justify-center gap-2 transition-all active:scale-[0.98]",
|
||||||
|
searchResult.is_available
|
||||||
|
? "border border-white/20 text-white hover:bg-white/5"
|
||||||
|
: "border-2 border-accent text-accent hover:bg-accent/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{addingToWatchlist ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
|
||||||
|
{searchResult.is_available ? 'Track' : 'Monitor'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{searchResult.is_available && (
|
||||||
|
<a
|
||||||
|
href={`https://www.namecheap.com/domains/registration/results/?domain=${searchResult.domain}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex-1 py-3 bg-accent text-black text-sm font-bold flex items-center justify-center gap-2 hover:bg-white active:scale-[0.98] transition-all"
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Searches */}
|
||||||
|
{!searchResult && recentSearches.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="w-1 h-4 bg-white/20" />
|
||||||
|
<span className="text-[10px] font-mono text-white/30 uppercase tracking-[0.2em]">Recent Searches</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{recentSearches.map((domain) => (
|
||||||
|
<button
|
||||||
|
key={domain}
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery(domain)
|
||||||
|
handleSearch(domain)
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 border border-white/[0.08] text-xs font-mono text-white/50 hover:text-white hover:border-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
{domain}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Tips */}
|
||||||
|
{!searchResult && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||||
|
<div className="border border-white/[0.08] bg-white/[0.02] p-4">
|
||||||
|
<Globe className="w-5 h-5 text-accent mb-2" />
|
||||||
|
<div className="text-sm font-bold text-white mb-1">Instant Check</div>
|
||||||
|
<p className="text-xs text-white/30">
|
||||||
|
Check any domain's availability in real-time using RDAP/WHOIS.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-white/[0.08] bg-white/[0.02] p-4">
|
||||||
|
<Eye className="w-5 h-5 text-white/40 mb-2" />
|
||||||
|
<div className="text-sm font-bold text-white mb-1">Track Changes</div>
|
||||||
|
<p className="text-xs text-white/30">
|
||||||
|
Add domains to your watchlist to monitor when they become available.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-white/[0.08] bg-white/[0.02] p-4">
|
||||||
|
<Shield className="w-5 h-5 text-white/40 mb-2" />
|
||||||
|
<div className="text-sm font-bold text-white mb-1">Deep Analysis</div>
|
||||||
|
<p className="text-xs text-white/30">
|
||||||
|
Run full analysis to check backlinks, SEO metrics, and domain history.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -239,7 +239,7 @@ export function useUserShortcuts() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const userShortcuts: Shortcut[] = [
|
const userShortcuts: Shortcut[] = [
|
||||||
// Navigation
|
// 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: '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: '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' },
|
{ 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' },
|
{ key: 'e', label: 'Export', description: 'Export current data', action: () => {}, category: 'actions' },
|
||||||
// Global
|
// Global
|
||||||
{ key: '?', label: 'Show Shortcuts', description: 'Display this help', action: () => setShowHelp(true), category: '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)
|
adminShortcuts.forEach(registerShortcut)
|
||||||
|
|||||||
Reference in New Issue
Block a user