Backend: - Fixed track endpoint duplicate key error with proper rollback - Returns domain_id for already tracked domains Frontend DropsTab: - Added trackedDrops state to show "Tracked" status - Track button shows checkmark when already in watchlist - Status button shows "In Transition" with countdown AnalyzePanel: - Added dropStatus to store for passing drop info - Shows Drop Status banner with availability - "Buy Now" button for available domains in panel
879 lines
39 KiB
TypeScript
879 lines
39 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||
import { api } from '@/lib/api'
|
||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||
import {
|
||
Globe,
|
||
Loader2,
|
||
Search,
|
||
ChevronRight,
|
||
ChevronLeft,
|
||
ChevronUp,
|
||
ChevronDown,
|
||
RefreshCw,
|
||
X,
|
||
Eye,
|
||
Shield,
|
||
ExternalLink,
|
||
Zap,
|
||
Filter,
|
||
Ban,
|
||
Hash,
|
||
CheckCircle2,
|
||
AlertCircle,
|
||
Clock,
|
||
} from 'lucide-react'
|
||
import clsx from 'clsx'
|
||
|
||
// ============================================================================
|
||
// TYPES
|
||
// ============================================================================
|
||
|
||
type AvailabilityStatus = 'available' | 'dropping_soon' | 'taken' | 'unknown'
|
||
|
||
interface DroppedDomain {
|
||
id: number
|
||
domain: string
|
||
tld: string
|
||
dropped_date: string
|
||
length: number
|
||
is_numeric: boolean
|
||
has_hyphen: boolean
|
||
availability_status: AvailabilityStatus
|
||
last_status_check: string | null
|
||
deletion_date: string | null
|
||
}
|
||
|
||
interface ZoneStats {
|
||
ch: { domain_count: number; last_sync: string | null }
|
||
li: { domain_count: number; last_sync: string | null }
|
||
daily_drops: number
|
||
}
|
||
|
||
type SupportedTld = 'ch' | 'li' | 'xyz' | 'org' | 'online' | 'info' | 'dev' | 'app'
|
||
|
||
const ALL_TLDS: { tld: SupportedTld; flag: string }[] = [
|
||
{ tld: 'ch', flag: '🇨🇭' },
|
||
{ tld: 'li', flag: '🇱🇮' },
|
||
{ tld: 'xyz', flag: '🌐' },
|
||
{ tld: 'org', flag: '🏛️' },
|
||
{ tld: 'online', flag: '💻' },
|
||
{ tld: 'info', flag: 'ℹ️' },
|
||
{ tld: 'dev', flag: '👨💻' },
|
||
{ tld: 'app', flag: '📱' },
|
||
]
|
||
|
||
// ============================================================================
|
||
// COMPONENT
|
||
// ============================================================================
|
||
|
||
interface DropsTabProps {
|
||
showToast: (message: string, type?: 'success' | 'error' | 'info') => void
|
||
}
|
||
|
||
export function DropsTab({ showToast }: DropsTabProps) {
|
||
const openAnalyzePanel = useAnalyzePanelStore((s) => s.open)
|
||
|
||
// Wrapper to open analyze panel with drop status
|
||
const openAnalyze = useCallback((domain: string, item?: DroppedDomain) => {
|
||
if (item) {
|
||
openAnalyzePanel(domain, {
|
||
status: item.availability_status || 'unknown',
|
||
deletion_date: item.deletion_date,
|
||
is_drop: true,
|
||
})
|
||
} else {
|
||
openAnalyzePanel(domain)
|
||
}
|
||
}, [openAnalyzePanel])
|
||
|
||
// Data State
|
||
const [items, setItems] = useState<DroppedDomain[]>([])
|
||
const [stats, setStats] = useState<ZoneStats | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [refreshing, setRefreshing] = useState(false)
|
||
const [total, setTotal] = useState(0)
|
||
|
||
// Filter State
|
||
const [selectedTld, setSelectedTld] = useState<SupportedTld | null>(null)
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [searchFocused, setSearchFocused] = useState(false)
|
||
const [minLength, setMinLength] = useState<number | undefined>(undefined)
|
||
const [maxLength, setMaxLength] = useState<number | undefined>(undefined)
|
||
const [excludeNumeric, setExcludeNumeric] = useState(true)
|
||
const [excludeHyphen, setExcludeHyphen] = useState(true)
|
||
const [showOnlyAvailable, setShowOnlyAvailable] = useState(false)
|
||
const [filtersOpen, setFiltersOpen] = useState(false)
|
||
|
||
// Pagination
|
||
const [page, setPage] = useState(1)
|
||
const ITEMS_PER_PAGE = 50
|
||
|
||
// Sorting
|
||
const [sortField, setSortField] = useState<'domain' | 'length' | 'date'>('length')
|
||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
|
||
|
||
// Status Checking
|
||
const [checkingStatus, setCheckingStatus] = useState<number | null>(null)
|
||
const [trackingDrop, setTrackingDrop] = useState<number | null>(null)
|
||
const [trackedDrops, setTrackedDrops] = useState<Set<number>>(new Set())
|
||
|
||
// Load Stats
|
||
const loadStats = useCallback(async () => {
|
||
try {
|
||
const result = await api.getDropsStats()
|
||
setStats(result)
|
||
} catch (error) {
|
||
console.error('Failed to load zone stats:', error)
|
||
}
|
||
}, [])
|
||
|
||
// Load Drops
|
||
const loadDrops = useCallback(async (currentPage = 1, isRefresh = false) => {
|
||
if (isRefresh) setRefreshing(true)
|
||
else setLoading(true)
|
||
|
||
try {
|
||
const result = await api.getDrops({
|
||
tld: selectedTld || undefined,
|
||
hours: 24,
|
||
min_length: minLength,
|
||
max_length: maxLength,
|
||
exclude_numeric: excludeNumeric,
|
||
exclude_hyphen: excludeHyphen,
|
||
keyword: searchQuery || undefined,
|
||
limit: ITEMS_PER_PAGE,
|
||
offset: (currentPage - 1) * ITEMS_PER_PAGE,
|
||
})
|
||
setItems(result.items)
|
||
setTotal(result.total)
|
||
} catch (error: any) {
|
||
console.error('Failed to load drops:', error)
|
||
if (error.message?.includes('401') || error.message?.includes('auth')) {
|
||
showToast('Login required to view drops', 'info')
|
||
}
|
||
setItems([])
|
||
setTotal(0)
|
||
} finally {
|
||
setLoading(false)
|
||
setRefreshing(false)
|
||
}
|
||
}, [selectedTld, minLength, maxLength, excludeNumeric, excludeHyphen, searchQuery, showToast])
|
||
|
||
useEffect(() => {
|
||
loadStats()
|
||
}, [loadStats])
|
||
|
||
useEffect(() => {
|
||
setPage(1)
|
||
loadDrops(1)
|
||
}, [loadDrops])
|
||
|
||
const handlePageChange = useCallback((newPage: number) => {
|
||
setPage(newPage)
|
||
loadDrops(newPage)
|
||
}, [loadDrops])
|
||
|
||
const handleRefresh = useCallback(async () => {
|
||
await loadDrops(page, true)
|
||
await loadStats()
|
||
}, [loadDrops, loadStats, page])
|
||
|
||
// Check real-time status of a drop
|
||
const checkStatus = useCallback(async (dropId: number, domain: string) => {
|
||
if (checkingStatus) return
|
||
setCheckingStatus(dropId)
|
||
try {
|
||
const result = await api.checkDropStatus(dropId)
|
||
// Update the item in our list
|
||
setItems(prev => prev.map(item =>
|
||
item.id === dropId
|
||
? {
|
||
...item,
|
||
availability_status: result.status,
|
||
last_status_check: new Date().toISOString(),
|
||
deletion_date: result.deletion_date,
|
||
}
|
||
: item
|
||
))
|
||
|
||
showToast(result.message, result.can_register_now ? 'success' : 'info')
|
||
} catch (e) {
|
||
showToast(e instanceof Error ? e.message : 'Status check failed', 'error')
|
||
} finally {
|
||
setCheckingStatus(null)
|
||
}
|
||
}, [checkingStatus, showToast])
|
||
|
||
// Format countdown from deletion date
|
||
const formatCountdown = useCallback((deletionDate: string | null): string | null => {
|
||
if (!deletionDate) return null
|
||
|
||
const del = new Date(deletionDate)
|
||
const now = new Date()
|
||
const diff = del.getTime() - now.getTime()
|
||
|
||
if (diff <= 0) return 'Now'
|
||
|
||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
||
const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
||
|
||
if (days > 0) return `${days}d ${hours}h`
|
||
if (hours > 0) return `${hours}h ${mins}m`
|
||
return `${mins}m`
|
||
}, [])
|
||
|
||
// Track a drop (add to watchlist)
|
||
const trackDrop = useCallback(async (dropId: number, domain: string) => {
|
||
if (trackingDrop) return
|
||
if (trackedDrops.has(dropId)) {
|
||
showToast(`${domain} is already in your Watchlist`, 'info')
|
||
return
|
||
}
|
||
|
||
setTrackingDrop(dropId)
|
||
try {
|
||
const result = await api.trackDrop(dropId)
|
||
// Mark as tracked regardless of status
|
||
setTrackedDrops(prev => new Set(prev).add(dropId))
|
||
|
||
if (result.status === 'already_tracking') {
|
||
showToast(`${domain} is already in your Watchlist`, 'info')
|
||
} else {
|
||
showToast(result.message || `Added ${domain} to Watchlist!`, 'success')
|
||
}
|
||
} catch (e) {
|
||
showToast(e instanceof Error ? e.message : 'Failed to track', 'error')
|
||
} finally {
|
||
setTrackingDrop(null)
|
||
}
|
||
}, [trackingDrop, trackedDrops, showToast])
|
||
|
||
// Check if a drop is already tracked
|
||
const isTracked = useCallback((dropId: number) => trackedDrops.has(dropId), [trackedDrops])
|
||
|
||
// Filtered and Sorted Items
|
||
const sortedItems = useMemo(() => {
|
||
// Filter first if "show only available" is enabled
|
||
let filtered = items
|
||
if (showOnlyAvailable) {
|
||
filtered = items.filter(item => item.availability_status === 'available')
|
||
}
|
||
|
||
// Then sort
|
||
const mult = sortDirection === 'asc' ? 1 : -1
|
||
return [...filtered].sort((a, b) => {
|
||
switch (sortField) {
|
||
case 'domain':
|
||
return mult * a.domain.localeCompare(b.domain)
|
||
case 'length':
|
||
return mult * (a.length - b.length)
|
||
case 'date':
|
||
return mult * (new Date(a.dropped_date).getTime() - new Date(b.dropped_date).getTime())
|
||
default:
|
||
return 0
|
||
}
|
||
})
|
||
}, [items, sortField, sortDirection, showOnlyAvailable])
|
||
|
||
// Count available domains
|
||
const availableCount = useMemo(() =>
|
||
items.filter(item => item.availability_status === 'available').length
|
||
, [items])
|
||
|
||
const handleSort = useCallback((field: typeof sortField) => {
|
||
if (sortField === field) {
|
||
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||
} else {
|
||
setSortField(field)
|
||
setSortDirection(field === 'length' ? 'asc' : 'desc')
|
||
}
|
||
}, [sortField])
|
||
|
||
const totalPages = Math.ceil(total / ITEMS_PER_PAGE)
|
||
const activeFiltersCount = [
|
||
selectedTld !== null,
|
||
minLength !== undefined,
|
||
maxLength !== undefined,
|
||
excludeNumeric,
|
||
excludeHyphen,
|
||
showOnlyAvailable,
|
||
].filter(Boolean).length
|
||
|
||
const formatTime = (iso: string) => {
|
||
const d = new Date(iso)
|
||
const now = new Date()
|
||
const diffH = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60))
|
||
if (diffH < 1) return 'Just now'
|
||
if (diffH === 1) return '1h ago'
|
||
return `${diffH}h ago`
|
||
}
|
||
|
||
// Loading State
|
||
if (loading && items.length === 0) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center py-24">
|
||
<Loader2 className="w-8 h-8 text-accent animate-spin mb-4" />
|
||
<span className="text-xs font-mono text-white/30 uppercase tracking-widest">Loading zone file drops...</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header Stats */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<div className="w-12 h-12 bg-gradient-to-br from-accent/20 to-accent/5 border border-accent/30 flex items-center justify-center">
|
||
<Zap className="w-6 h-6 text-accent" />
|
||
</div>
|
||
<div>
|
||
<div className="text-2xl font-black text-white font-mono tracking-tight">
|
||
{stats?.daily_drops?.toLocaleString() || total.toLocaleString()}
|
||
</div>
|
||
<div className="text-[10px] font-mono text-white/40 uppercase tracking-widest">Fresh drops in last 24h</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={handleRefresh}
|
||
disabled={refreshing}
|
||
className="p-3 border border-white/10 text-white/40 hover:text-accent hover:border-accent/30 hover:bg-accent/5 transition-all"
|
||
title="Refresh drops"
|
||
>
|
||
<RefreshCw className={clsx("w-5 h-5", refreshing && "animate-spin")} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Search */}
|
||
<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
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
onFocus={() => setSearchFocused(true)}
|
||
onBlur={() => setSearchFocused(false)}
|
||
placeholder="Search dropped domains..."
|
||
className="flex-1 bg-transparent px-4 py-4 text-sm text-white placeholder:text-white/20 outline-none font-mono"
|
||
/>
|
||
{searchQuery && (
|
||
<button onClick={() => setSearchQuery('')} className="p-4 text-white/30 hover:text-white transition-colors">
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* TLD Quick Filter */}
|
||
<div className="flex gap-2 flex-wrap">
|
||
<button
|
||
onClick={() => setSelectedTld(null)}
|
||
className={clsx(
|
||
"px-4 py-2.5 text-xs font-mono uppercase tracking-wider border transition-all",
|
||
selectedTld === null
|
||
? "border-accent bg-accent/10 text-accent font-bold"
|
||
: "border-white/[0.08] text-white/40 hover:border-white/20 hover:text-white/60"
|
||
)}
|
||
>
|
||
All TLDs
|
||
</button>
|
||
{ALL_TLDS.map(({ tld }) => (
|
||
<button
|
||
key={tld}
|
||
onClick={() => setSelectedTld(tld)}
|
||
className={clsx(
|
||
"px-4 py-2.5 text-xs font-mono uppercase tracking-wider border transition-all",
|
||
selectedTld === tld
|
||
? "border-accent bg-accent/10 text-accent font-bold"
|
||
: "border-white/[0.08] text-white/40 hover:border-white/20 hover:text-white/60"
|
||
)}
|
||
>
|
||
.{tld}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Advanced Filters */}
|
||
<button
|
||
onClick={() => setFiltersOpen(!filtersOpen)}
|
||
className={clsx(
|
||
"flex items-center justify-between w-full py-3 px-5 border transition-all",
|
||
filtersOpen ? "border-accent/30 bg-accent/[0.03]" : "border-white/[0.08] bg-white/[0.02] hover:border-white/20"
|
||
)}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<Filter className={clsx("w-4 h-4", filtersOpen ? "text-accent" : "text-white/40")} />
|
||
<span className={clsx("text-xs font-mono uppercase tracking-widest", filtersOpen ? "text-accent" : "text-white/50")}>
|
||
Advanced Filters
|
||
</span>
|
||
{activeFiltersCount > 0 && (
|
||
<span className="px-2 py-0.5 text-[9px] font-black bg-accent text-black">{activeFiltersCount}</span>
|
||
)}
|
||
</div>
|
||
<ChevronRight className={clsx("w-4 h-4 transition-transform", filtersOpen ? "rotate-90 text-accent" : "text-white/30")} />
|
||
</button>
|
||
|
||
{filtersOpen && (
|
||
<div className="p-5 border border-white/[0.08] bg-white/[0.01] space-y-5 animate-in fade-in slide-in-from-top-2 duration-200">
|
||
{/* Length Filter */}
|
||
<div>
|
||
<div className="text-[10px] font-mono text-white/40 uppercase tracking-widest mb-3">Domain Length</div>
|
||
<div className="flex gap-3 items-center">
|
||
<input
|
||
type="number"
|
||
value={minLength || ''}
|
||
onChange={(e) => setMinLength(e.target.value ? Number(e.target.value) : undefined)}
|
||
placeholder="Min"
|
||
className="w-24 bg-white/[0.02] border border-white/10 px-4 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono focus:border-accent/30 transition-colors"
|
||
min={1}
|
||
max={63}
|
||
/>
|
||
<span className="text-white/20 font-mono text-xs">to</span>
|
||
<input
|
||
type="number"
|
||
value={maxLength || ''}
|
||
onChange={(e) => setMaxLength(e.target.value ? Number(e.target.value) : undefined)}
|
||
placeholder="Max"
|
||
className="w-24 bg-white/[0.02] border border-white/10 px-4 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono focus:border-accent/30 transition-colors"
|
||
min={1}
|
||
max={63}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Quality Filters */}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<button
|
||
onClick={() => setExcludeNumeric(!excludeNumeric)}
|
||
className={clsx(
|
||
"flex items-center justify-between py-3 px-4 border transition-all",
|
||
excludeNumeric ? "border-accent/30 bg-accent/5" : "border-white/[0.08] hover:border-white/20"
|
||
)}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<Hash className={clsx("w-4 h-4", excludeNumeric ? "text-accent" : "text-white/30")} />
|
||
<span className={clsx("text-xs font-mono uppercase tracking-wider", excludeNumeric ? "text-accent" : "text-white/50")}>
|
||
No Numbers
|
||
</span>
|
||
</div>
|
||
<div className={clsx(
|
||
"w-5 h-5 border flex items-center justify-center transition-all",
|
||
excludeNumeric ? "border-accent bg-accent" : "border-white/20"
|
||
)}>
|
||
{excludeNumeric && <CheckCircle2 className="w-3 h-3 text-black" />}
|
||
</div>
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => setExcludeHyphen(!excludeHyphen)}
|
||
className={clsx(
|
||
"flex items-center justify-between py-3 px-4 border transition-all",
|
||
excludeHyphen ? "border-accent/30 bg-accent/5" : "border-white/[0.08] hover:border-white/20"
|
||
)}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<Ban className={clsx("w-4 h-4", excludeHyphen ? "text-accent" : "text-white/30")} />
|
||
<span className={clsx("text-xs font-mono uppercase tracking-wider", excludeHyphen ? "text-accent" : "text-white/50")}>
|
||
No Hyphens
|
||
</span>
|
||
</div>
|
||
<div className={clsx(
|
||
"w-5 h-5 border flex items-center justify-center transition-all",
|
||
excludeHyphen ? "border-accent bg-accent" : "border-white/20"
|
||
)}>
|
||
{excludeHyphen && <CheckCircle2 className="w-3 h-3 text-black" />}
|
||
</div>
|
||
</button>
|
||
|
||
{/* Show Only Available Filter */}
|
||
<button
|
||
onClick={() => setShowOnlyAvailable(!showOnlyAvailable)}
|
||
className={clsx(
|
||
"flex items-center justify-between py-3 px-4 border transition-all",
|
||
showOnlyAvailable ? "border-accent/30 bg-accent/5" : "border-white/[0.08] hover:border-white/20"
|
||
)}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<Zap className={clsx("w-4 h-4", showOnlyAvailable ? "text-accent" : "text-white/30")} />
|
||
<span className={clsx("text-xs font-mono uppercase tracking-wider", showOnlyAvailable ? "text-accent" : "text-white/50")}>
|
||
Only Available
|
||
</span>
|
||
</div>
|
||
<div className={clsx(
|
||
"w-5 h-5 border flex items-center justify-center transition-all",
|
||
showOnlyAvailable ? "border-accent bg-accent" : "border-white/20"
|
||
)}>
|
||
{showOnlyAvailable && <CheckCircle2 className="w-3 h-3 text-black" />}
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Results Info */}
|
||
<div className="flex items-center justify-between px-1">
|
||
<div className="flex items-center gap-4 text-[11px] font-mono uppercase tracking-widest">
|
||
<div className="flex items-center gap-2 text-white/40">
|
||
<div className="w-1.5 h-1.5 bg-accent rounded-full animate-pulse" />
|
||
<span>{showOnlyAvailable ? sortedItems.length : total.toLocaleString()} domains</span>
|
||
</div>
|
||
{availableCount > 0 && !showOnlyAvailable && (
|
||
<div className="flex items-center gap-2 text-accent">
|
||
<Zap className="w-3 h-3" />
|
||
<span>{availableCount} available now</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{totalPages > 1 && !showOnlyAvailable && (
|
||
<span className="text-[11px] font-mono text-white/30 uppercase tracking-widest">
|
||
Page {page} of {totalPages}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Results Table */}
|
||
{sortedItems.length === 0 ? (
|
||
<div className="text-center py-24 border border-dashed border-white/[0.08] bg-white/[0.01]">
|
||
<Globe className="w-16 h-16 text-white/5 mx-auto mb-6" />
|
||
<p className="text-white/50 text-sm font-mono uppercase tracking-widest font-bold">No drops found</p>
|
||
<p className="text-white/20 text-xs font-mono mt-3 uppercase tracking-wider max-w-sm mx-auto leading-relaxed">
|
||
Zone file comparison runs daily. Try adjusting your filters.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="border border-white/[0.08] bg-[#020202] overflow-hidden">
|
||
{/* Table Header */}
|
||
<div className="hidden lg:grid grid-cols-[1fr_80px_130px_100px_180px] gap-4 px-6 py-4 text-[10px] font-mono text-white/40 uppercase tracking-[0.15em] border-b border-white/[0.08] bg-white/[0.02]">
|
||
<button
|
||
onClick={() => handleSort('domain')}
|
||
className="flex items-center gap-2 hover:text-white transition-colors text-left"
|
||
>
|
||
<span className={clsx(sortField === 'domain' && "text-accent font-bold")}>Domain</span>
|
||
{sortField === 'domain' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3 text-accent" /> : <ChevronDown className="w-3 h-3 text-accent" />)}
|
||
</button>
|
||
<button
|
||
onClick={() => handleSort('length')}
|
||
className="flex items-center gap-2 justify-center hover:text-white transition-colors"
|
||
>
|
||
<span className={clsx(sortField === 'length' && "text-accent font-bold")}>Length</span>
|
||
{sortField === 'length' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3 text-accent" /> : <ChevronDown className="w-3 h-3 text-accent" />)}
|
||
</button>
|
||
<div className="text-center">Status</div>
|
||
<button
|
||
onClick={() => handleSort('date')}
|
||
className="flex items-center gap-2 justify-center hover:text-white transition-colors"
|
||
>
|
||
<span className={clsx(sortField === 'date' && "text-accent font-bold")}>Detected</span>
|
||
{sortField === 'date' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3 text-accent" /> : <ChevronDown className="w-3 h-3 text-accent" />)}
|
||
</button>
|
||
<div className="text-right">Actions</div>
|
||
</div>
|
||
|
||
{/* Table Body */}
|
||
<div className="divide-y divide-white/[0.04]">
|
||
{sortedItems.map((item) => {
|
||
const fullDomain = `${item.domain}.${item.tld}`
|
||
const isChecking = checkingStatus === item.id
|
||
const isTrackingThis = trackingDrop === item.id
|
||
const alreadyTracked = isTracked(item.id)
|
||
const status = item.availability_status || 'unknown'
|
||
|
||
// Status display config with better labels
|
||
const countdown = item.deletion_date ? formatCountdown(item.deletion_date) : null
|
||
const statusConfig = {
|
||
available: {
|
||
label: 'Available Now',
|
||
color: 'text-accent',
|
||
bg: 'bg-accent/10',
|
||
border: 'border-accent/30',
|
||
icon: CheckCircle2,
|
||
showBuy: true,
|
||
},
|
||
dropping_soon: {
|
||
label: countdown ? `In Transition • ${countdown}` : 'In Transition',
|
||
color: 'text-amber-400',
|
||
bg: 'bg-amber-400/10',
|
||
border: 'border-amber-400/30',
|
||
icon: Clock,
|
||
showBuy: false,
|
||
},
|
||
taken: {
|
||
label: 'Re-registered',
|
||
color: 'text-rose-400/60',
|
||
bg: 'bg-rose-400/5',
|
||
border: 'border-rose-400/20',
|
||
icon: Ban,
|
||
showBuy: false,
|
||
},
|
||
unknown: {
|
||
label: 'Check Status',
|
||
color: 'text-white/50',
|
||
bg: 'bg-white/5',
|
||
border: 'border-white/20',
|
||
icon: Search,
|
||
showBuy: false,
|
||
},
|
||
}[status]
|
||
|
||
const StatusIcon = statusConfig.icon
|
||
|
||
return (
|
||
<div key={`${item.id}-${fullDomain}`} className="group hover:bg-white/[0.02] transition-all">
|
||
{/* Mobile Row */}
|
||
<div className="lg:hidden p-5">
|
||
<div className="flex items-start justify-between gap-4 mb-4">
|
||
<div className="min-w-0">
|
||
<button
|
||
onClick={() => openAnalyze(fullDomain, item)}
|
||
className="text-lg font-bold text-white font-mono truncate block text-left hover:text-accent transition-colors"
|
||
>
|
||
{item.domain}<span className="text-white/30">.{item.tld}</span>
|
||
</button>
|
||
<div className="flex items-center gap-3 mt-2">
|
||
<span className={clsx(
|
||
"text-[10px] font-mono font-bold px-2.5 py-1 border",
|
||
item.length <= 4 ? "text-accent border-accent/20 bg-accent/5" :
|
||
item.length <= 6 ? "text-amber-400 border-amber-400/20 bg-amber-400/5" :
|
||
"text-white/40 border-white/10 bg-white/5"
|
||
)}>
|
||
{item.length} chars
|
||
</span>
|
||
<button
|
||
onClick={() => checkStatus(item.id, fullDomain)}
|
||
disabled={isChecking}
|
||
className={clsx(
|
||
"text-[10px] font-mono font-bold px-2.5 py-1 border flex items-center gap-1.5",
|
||
statusConfig.color, statusConfig.bg, statusConfig.border
|
||
)}
|
||
>
|
||
{isChecking ? <Loader2 className="w-3 h-3 animate-spin" /> : <StatusIcon className="w-3 h-3" />}
|
||
{statusConfig.label}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
{/* Track Button - shows "Tracked" if already in watchlist */}
|
||
<button
|
||
onClick={() => trackDrop(item.id, fullDomain)}
|
||
disabled={isTrackingThis || alreadyTracked}
|
||
className={clsx(
|
||
"h-12 px-4 border text-xs font-bold uppercase tracking-widest flex items-center justify-center gap-2 transition-all",
|
||
alreadyTracked
|
||
? "border-accent/30 text-accent bg-accent/5 cursor-default"
|
||
: "border-white/10 text-white/60 hover:bg-white/5 active:scale-[0.98]"
|
||
)}
|
||
>
|
||
{isTrackingThis ? <Loader2 className="w-4 h-4 animate-spin" /> :
|
||
alreadyTracked ? <CheckCircle2 className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||
{alreadyTracked ? 'Tracked' : 'Track'}
|
||
</button>
|
||
|
||
{/* Action Button based on status */}
|
||
{status === 'available' ? (
|
||
<a
|
||
href={`https://www.namecheap.com/domains/registration/results/?domain=${fullDomain}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="flex-1 h-12 bg-accent text-black text-xs font-black uppercase tracking-widest flex items-center justify-center gap-2 hover:bg-white active:scale-[0.98] transition-all"
|
||
>
|
||
<Zap className="w-4 h-4" />
|
||
Buy Now
|
||
</a>
|
||
) : status === 'dropping_soon' ? (
|
||
<div className="flex-1 h-12 border border-amber-400/30 text-amber-400 bg-amber-400/5 text-xs font-bold uppercase tracking-widest flex flex-col items-center justify-center">
|
||
<span className="flex items-center gap-1.5">
|
||
<Clock className="w-3 h-3" />
|
||
In Transition
|
||
</span>
|
||
{countdown && (
|
||
<span className="text-[9px] text-amber-400/70 font-mono">{countdown} until drop</span>
|
||
)}
|
||
</div>
|
||
) : status === 'taken' ? (
|
||
<span className="flex-1 h-12 border border-rose-400/20 text-rose-400/60 text-xs font-bold uppercase tracking-widest flex items-center justify-center gap-2 bg-rose-400/5">
|
||
<Ban className="w-4 h-4" />
|
||
Re-registered
|
||
</span>
|
||
) : (
|
||
<button
|
||
onClick={() => checkStatus(item.id, fullDomain)}
|
||
disabled={isChecking}
|
||
className="flex-1 h-12 border border-accent/30 text-accent text-xs font-bold uppercase tracking-widest flex items-center justify-center gap-2 hover:bg-accent/10 active:scale-[0.98] transition-all"
|
||
>
|
||
{isChecking ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||
Check Status
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => openAnalyze(fullDomain, item)}
|
||
className="w-14 h-12 border border-white/10 text-white/50 flex items-center justify-center hover:text-accent hover:border-accent/30 hover:bg-accent/5 transition-all"
|
||
>
|
||
<Shield className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Desktop Row */}
|
||
<div className="hidden lg:grid grid-cols-[1fr_80px_130px_100px_180px] gap-4 items-center px-6 py-3">
|
||
{/* Domain */}
|
||
<div className="min-w-0">
|
||
<button
|
||
onClick={() => openAnalyze(fullDomain, item)}
|
||
className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left block"
|
||
>
|
||
{item.domain}<span className="text-white/30 group-hover:text-accent/40">.{item.tld}</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Length */}
|
||
<div className="text-center">
|
||
<span className={clsx(
|
||
"text-xs font-mono font-bold px-3 py-1 border inline-block",
|
||
item.length <= 4 ? "text-accent border-accent/20 bg-accent/5" :
|
||
item.length <= 6 ? "text-amber-400 border-amber-400/20 bg-amber-400/5" :
|
||
"text-white/40 border-white/10 bg-white/5"
|
||
)}>
|
||
{item.length}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Status - clickable to refresh */}
|
||
<div className="text-center">
|
||
<button
|
||
onClick={() => checkStatus(item.id, fullDomain)}
|
||
disabled={isChecking}
|
||
className={clsx(
|
||
"text-[10px] font-mono font-bold px-2.5 py-1.5 border inline-flex items-center gap-1.5 transition-all hover:opacity-80",
|
||
statusConfig.color, statusConfig.bg, statusConfig.border
|
||
)}
|
||
title="Click to check real-time status"
|
||
>
|
||
{isChecking ? <Loader2 className="w-3 h-3 animate-spin" /> : <StatusIcon className="w-3 h-3" />}
|
||
<span className="max-w-[100px] truncate">{statusConfig.label}</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Time */}
|
||
<div className="text-center">
|
||
<span className="text-xs font-mono text-white/40 uppercase tracking-wider">
|
||
{formatTime(item.dropped_date)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className="flex items-center justify-end gap-2 opacity-60 group-hover:opacity-100 transition-all">
|
||
{/* Track Button - shows checkmark if tracked */}
|
||
<button
|
||
onClick={() => trackDrop(item.id, fullDomain)}
|
||
disabled={isTrackingThis || alreadyTracked}
|
||
className={clsx(
|
||
"w-9 h-9 flex items-center justify-center border transition-all",
|
||
alreadyTracked
|
||
? "border-accent/30 text-accent bg-accent/5 cursor-default"
|
||
: "border-white/10 text-white/50 hover:text-white hover:bg-white/5"
|
||
)}
|
||
title={alreadyTracked ? "Already in Watchlist" : "Add to Watchlist"}
|
||
>
|
||
{isTrackingThis ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> :
|
||
alreadyTracked ? <CheckCircle2 className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||
</button>
|
||
<button
|
||
onClick={() => openAnalyze(fullDomain, item)}
|
||
className="w-9 h-9 flex items-center justify-center border border-white/10 text-white/50 hover:text-accent hover:border-accent/30 hover:bg-accent/5 transition-all"
|
||
title="Analyze Domain"
|
||
>
|
||
<Shield className="w-3.5 h-3.5" />
|
||
</button>
|
||
|
||
{/* Dynamic Action Button based on status */}
|
||
{status === 'available' ? (
|
||
<a
|
||
href={`https://www.namecheap.com/domains/registration/results/?domain=${fullDomain}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="h-9 px-4 bg-accent text-black text-[10px] font-black uppercase tracking-widest flex items-center gap-1.5 hover:bg-white transition-all"
|
||
title="Register this domain now!"
|
||
>
|
||
<Zap className="w-3 h-3" />
|
||
Buy
|
||
</a>
|
||
) : status === 'dropping_soon' ? (
|
||
alreadyTracked ? (
|
||
<span className="h-9 px-3 text-accent text-[10px] font-bold uppercase tracking-widest flex items-center gap-1.5 border border-accent/30 bg-accent/5">
|
||
<CheckCircle2 className="w-3 h-3" />
|
||
Tracked
|
||
</span>
|
||
) : (
|
||
<button
|
||
onClick={() => trackDrop(item.id, fullDomain)}
|
||
disabled={isTrackingThis}
|
||
className="h-9 px-3 text-amber-400 text-[10px] font-bold uppercase tracking-widest flex items-center gap-1.5 border border-amber-400/30 bg-amber-400/5 hover:bg-amber-400/10 transition-all"
|
||
title={countdown ? `Drops in ${countdown} - Track to get notified!` : 'Track to get notified when available'}
|
||
>
|
||
{isTrackingThis ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />}
|
||
Track
|
||
</button>
|
||
)
|
||
) : status === 'taken' ? (
|
||
<span className="h-9 px-3 text-rose-400/50 text-[10px] font-bold uppercase tracking-widest flex items-center gap-1.5 border border-rose-400/20 bg-rose-400/5">
|
||
<Ban className="w-3 h-3" />
|
||
Taken
|
||
</span>
|
||
) : (
|
||
<button
|
||
onClick={() => checkStatus(item.id, fullDomain)}
|
||
disabled={isChecking}
|
||
className="h-9 px-4 border border-accent/40 text-accent text-[10px] font-bold uppercase tracking-widest flex items-center gap-1.5 hover:bg-accent/10 transition-all"
|
||
title="Check availability status"
|
||
>
|
||
{isChecking ? <Loader2 className="w-3 h-3 animate-spin" /> : <Search className="w-3 h-3" />}
|
||
Check
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-center gap-2 pt-4">
|
||
<button
|
||
onClick={() => handlePageChange(page - 1)}
|
||
disabled={page === 1}
|
||
className="w-12 h-12 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5 disabled:opacity-20 disabled:cursor-not-allowed transition-all"
|
||
>
|
||
<ChevronLeft className="w-5 h-5" />
|
||
</button>
|
||
<div className="flex items-center bg-white/[0.02] border border-white/[0.08] px-6 h-12">
|
||
<span className="text-xs text-white/50 font-mono uppercase tracking-widest">
|
||
Page <span className="text-white font-bold mx-1">{page}</span> / {totalPages}
|
||
</span>
|
||
</div>
|
||
<button
|
||
onClick={() => handlePageChange(page + 1)}
|
||
disabled={page === totalPages}
|
||
className="w-12 h-12 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5 disabled:opacity-20 disabled:cursor-not-allowed transition-all"
|
||
>
|
||
<ChevronRight className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|