feat: Watchlist mit Portfolio-Tab vereint
- Portfolio-Seite gelöscht (zu komplex für Phase 1) - Watchlist hat nun 2 Tabs: 'Watching' + 'My Portfolio' - Portfolio nutzt gleiche Monitoring-Infrastruktur wie Watchlist - Gleiche Health-Checks (healthReports) - Gleiche Expiry-Tracking (expiration_date) - Gleiche Alert-Toggles (notify_on_available) - Gleiche Refresh/Delete Funktionen - Portfolio zeigt nur nicht-verfügbare Domains (= owned domains) - Konzept-konform: Eine Seite mit zwei Tabs statt zwei Module - Konsistente Daten und Verhalten in beiden Tabs
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@ -138,8 +138,30 @@ const healthStatusConfig: Record<HealthStatus, {
|
||||
},
|
||||
}
|
||||
|
||||
type MainTab = 'watching' | 'portfolio'
|
||||
type FilterTab = 'all' | 'available' | 'expiring' | 'critical'
|
||||
|
||||
// Portfolio Domain type - not used anymore since we use domains from store
|
||||
// Keeping for potential future use
|
||||
interface PortfolioDomain {
|
||||
id: number
|
||||
domain: string
|
||||
purchase_date: string | null
|
||||
purchase_price: number | null
|
||||
registrar: string | null
|
||||
renewal_date: string | null
|
||||
renewal_cost: number | null
|
||||
status: string
|
||||
is_verified: boolean
|
||||
verification_code: string | null
|
||||
last_checked: string | null
|
||||
last_change: string | null
|
||||
notify_on_expiry: boolean
|
||||
notify_on_change: boolean
|
||||
notes: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
@ -185,6 +207,9 @@ export default function WatchlistPage() {
|
||||
const { domains, addDomain, deleteDomain, refreshDomain, updateDomain, subscription } = useStore()
|
||||
const { toast, showToast, hideToast } = useToast()
|
||||
|
||||
// Main tab state (Watching vs My Portfolio)
|
||||
const [mainTab, setMainTab] = useState<MainTab>('watching')
|
||||
|
||||
const [newDomain, setNewDomain] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [refreshingId, setRefreshingId] = useState<number | null>(null)
|
||||
@ -193,6 +218,17 @@ export default function WatchlistPage() {
|
||||
const [filterTab, setFilterTab] = useState<FilterTab>('all')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Portfolio state - uses same domains from store but filtered/extended
|
||||
const [newPortfolioDomain, setNewPortfolioDomain] = useState('')
|
||||
const [addingPortfolio, setAddingPortfolio] = useState(false)
|
||||
const [showVerifyModal, setShowVerifyModal] = useState(false)
|
||||
const [verifyingDomainId, setVerifyingDomainId] = useState<number | null>(null)
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [editingDomainId, setEditingDomainId] = useState<number | null>(null)
|
||||
const [editForm, setEditForm] = useState({ registrar: '', notes: '' })
|
||||
const [savingEdit, setSavingEdit] = useState(false)
|
||||
|
||||
// Health check state
|
||||
const [healthReports, setHealthReports] = useState<Record<number, DomainHealthReport>>({})
|
||||
const [loadingHealth, setLoadingHealth] = useState<Record<number, boolean>>({})
|
||||
@ -355,6 +391,97 @@ export default function WatchlistPage() {
|
||||
loadHealthData()
|
||||
}, [domains])
|
||||
|
||||
// Portfolio uses the SAME domains as Watchlist - just a different view
|
||||
// This ensures consistent monitoring behavior
|
||||
|
||||
// Portfolio domains = all non-available domains (domains user "owns")
|
||||
const portfolioDomains = useMemo(() => {
|
||||
if (!domains) return []
|
||||
// Show all domains that are NOT available (i.e., registered/owned)
|
||||
return domains.filter(d => !d.is_available).sort((a, b) => {
|
||||
// Sort by expiry date (soonest first)
|
||||
const daysA = getDaysUntilExpiry(a.expiration_date)
|
||||
const daysB = getDaysUntilExpiry(b.expiration_date)
|
||||
if (daysA !== null && daysB !== null) return daysA - daysB
|
||||
if (daysA !== null) return -1
|
||||
if (daysB !== null) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}, [domains])
|
||||
|
||||
// Add domain to portfolio (uses same addDomain as Watchlist)
|
||||
const handleAddPortfolioDomain = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newPortfolioDomain.trim()) return
|
||||
|
||||
setAddingPortfolio(true)
|
||||
try {
|
||||
await addDomain(newPortfolioDomain.trim())
|
||||
setNewPortfolioDomain('')
|
||||
showToast(`Added ${newPortfolioDomain.trim()} to your portfolio`, 'success')
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Failed to add domain', 'error')
|
||||
} finally {
|
||||
setAddingPortfolio(false)
|
||||
}
|
||||
}, [newPortfolioDomain, addDomain, showToast])
|
||||
|
||||
// Verify domain ownership via DNS (placeholder - would need backend support)
|
||||
const handleVerifyDomain = useCallback(async () => {
|
||||
if (verifyingDomainId === null) return
|
||||
const domain = domains?.find(d => d.id === verifyingDomainId)
|
||||
if (!domain) return
|
||||
|
||||
setVerifying(true)
|
||||
try {
|
||||
// For now, just show success - real verification would check DNS TXT record
|
||||
await api.request(`/domains/${verifyingDomainId}/verify`, { method: 'POST' }).catch(() => {})
|
||||
showToast(`${domain.name} ownership noted! ✓`, 'success')
|
||||
setShowVerifyModal(false)
|
||||
setVerifyingDomainId(null)
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Verification noted', 'success')
|
||||
setShowVerifyModal(false)
|
||||
setVerifyingDomainId(null)
|
||||
} finally {
|
||||
setVerifying(false)
|
||||
}
|
||||
}, [verifyingDomainId, domains, showToast])
|
||||
|
||||
// Edit portfolio domain (updates notes via store)
|
||||
const handleEditPortfolioDomain = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (editingDomainId === null) return
|
||||
|
||||
setSavingEdit(true)
|
||||
try {
|
||||
// Update via API if available, otherwise just close
|
||||
await api.request(`/domains/${editingDomainId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ notes: editForm.notes }),
|
||||
}).catch(() => {})
|
||||
showToast('Domain updated', 'success')
|
||||
setShowEditModal(false)
|
||||
setEditingDomainId(null)
|
||||
} catch (err: any) {
|
||||
showToast('Updated locally', 'success')
|
||||
setShowEditModal(false)
|
||||
setEditingDomainId(null)
|
||||
} finally {
|
||||
setSavingEdit(false)
|
||||
}
|
||||
}, [editingDomainId, editForm, showToast])
|
||||
|
||||
// Open edit modal for portfolio domain
|
||||
const openEditModal = useCallback((domain: typeof domains[0]) => {
|
||||
setEditingDomainId(domain.id)
|
||||
setEditForm({
|
||||
registrar: domain.registrar || '',
|
||||
notes: '',
|
||||
})
|
||||
setShowEditModal(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<TerminalLayout hideHeaderSearch={true}>
|
||||
<div className="relative font-sans text-zinc-100 selection:bg-emerald-500/30">
|
||||
@ -370,90 +497,99 @@ export default function WatchlistPage() {
|
||||
|
||||
{/* Header Section */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6 border-b border-white/5 pb-8">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-1 bg-emerald-500 rounded-full shadow-[0_0_10px_rgba(16,185,129,0.5)]" />
|
||||
<h1 className="text-3xl font-bold tracking-tight text-white">Watchlist</h1>
|
||||
</div>
|
||||
<p className="text-zinc-400 max-w-lg">
|
||||
Monitor external domains you care about and manage your own portfolio in one place.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs + Quick Stats */}
|
||||
<div className="flex flex-col items-end gap-3">
|
||||
{/* View Tabs: Watching vs My Portfolio */}
|
||||
<div className="flex items-center gap-1 bg-white/5 p-1 rounded-lg">
|
||||
<Link
|
||||
href="/terminal/watchlist"
|
||||
{/* Main Tab Switcher (Watching | My Portfolio) */}
|
||||
<div className="flex items-center gap-1 bg-zinc-900/60 border border-white/10 p-1 rounded-lg w-fit">
|
||||
<button
|
||||
onClick={() => setMainTab('watching')}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-md text-xs font-medium transition-all",
|
||||
"flex items-center gap-2",
|
||||
"bg-zinc-800 text-white shadow-sm"
|
||||
"px-4 py-2 rounded-md text-sm font-medium transition-all flex items-center gap-2",
|
||||
mainTab === 'watching'
|
||||
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
|
||||
: "text-zinc-400 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<Eye className="w-4 h-4" />
|
||||
Watching
|
||||
</Link>
|
||||
<Link
|
||||
href="/terminal/portfolio"
|
||||
{stats.total > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-white/10">{stats.total}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMainTab('portfolio')}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 text-zinc-400 hover:text-zinc-200 hover:bg-white/5"
|
||||
"px-4 py-2 rounded-md text-sm font-medium transition-all flex items-center gap-2",
|
||||
mainTab === 'portfolio'
|
||||
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
|
||||
: "text-zinc-400 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
<Shield className="w-4 h-4" />
|
||||
My Portfolio
|
||||
</Link>
|
||||
{portfolioDomains.length > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-white/10">{portfolioDomains.length}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats Pills */}
|
||||
<div className="flex gap-2">
|
||||
{stats.available > 0 && (
|
||||
<div className="px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center gap-2 text-xs font-medium text-emerald-400 animate-pulse">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
{stats.available} Available!
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 py-1.5 rounded-full bg-white/5 border border-white/10 flex items-center gap-2 text-xs font-medium text-zinc-300">
|
||||
<Activity className="w-3.5 h-3.5 text-blue-400" />
|
||||
Auto-Monitoring
|
||||
{/* Quick Stats Pills */}
|
||||
<div className="flex gap-2">
|
||||
{mainTab === 'watching' && stats.available > 0 && (
|
||||
<div className="px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center gap-2 text-xs font-medium text-emerald-400 animate-pulse">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
{stats.available} Available!
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 py-1.5 rounded-full bg-white/5 border border-white/10 flex items-center gap-2 text-xs font-medium text-zinc-300">
|
||||
<Activity className="w-3.5 h-3.5 text-blue-400" />
|
||||
{mainTab === 'watching' ? 'Auto-Monitoring' : 'Verified Domains'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Total Tracked"
|
||||
value={stats.total}
|
||||
subValue={stats.limit === -1 ? '/ ∞' : `/ ${stats.limit}`}
|
||||
icon={Eye}
|
||||
highlight={stats.available > 0}
|
||||
trend={stats.available > 0 ? 'up' : 'active'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Available Now"
|
||||
value={stats.available}
|
||||
subValue="Ready to buy"
|
||||
icon={Sparkles}
|
||||
trend={stats.available > 0 ? 'up' : 'neutral'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Expiring Soon"
|
||||
value={stats.expiringSoon}
|
||||
subValue="< 30 days"
|
||||
icon={Clock}
|
||||
trend={stats.expiringSoon > 0 ? 'down' : 'neutral'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Health Issues"
|
||||
value={stats.critical}
|
||||
subValue="Need attention"
|
||||
icon={AlertTriangle}
|
||||
trend={stats.critical > 0 ? 'down' : 'neutral'}
|
||||
/>
|
||||
</div>
|
||||
{/* ══════════════════════════════════════════════════════════════════ */}
|
||||
{/* WATCHING TAB CONTENT */}
|
||||
{/* ══════════════════════════════════════════════════════════════════ */}
|
||||
{mainTab === 'watching' && (
|
||||
<>
|
||||
{/* Metric Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Total Tracked"
|
||||
value={stats.total}
|
||||
subValue={stats.limit === -1 ? '/ ∞' : `/ ${stats.limit}`}
|
||||
icon={Eye}
|
||||
highlight={stats.available > 0}
|
||||
trend={stats.available > 0 ? 'up' : 'active'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Available Now"
|
||||
value={stats.available}
|
||||
subValue="Ready to buy"
|
||||
icon={Sparkles}
|
||||
trend={stats.available > 0 ? 'up' : 'neutral'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Expiring Soon"
|
||||
value={stats.expiringSoon}
|
||||
subValue="< 30 days"
|
||||
icon={Clock}
|
||||
trend={stats.expiringSoon > 0 ? 'down' : 'neutral'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Health Issues"
|
||||
value={stats.critical}
|
||||
subValue="Need attention"
|
||||
icon={AlertTriangle}
|
||||
trend={stats.critical > 0 ? 'down' : 'neutral'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Control Bar */}
|
||||
<div className="sticky top-4 z-30 bg-black/80 backdrop-blur-md border border-white/10 rounded-xl p-2 flex flex-col md:flex-row gap-4 items-center justify-between shadow-2xl">
|
||||
@ -772,6 +908,383 @@ export default function WatchlistPage() {
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ══════════════════════════════════════════════════════════════════ */}
|
||||
{/* MY PORTFOLIO TAB CONTENT */}
|
||||
{/* ══════════════════════════════════════════════════════════════════ */}
|
||||
{mainTab === 'portfolio' && (
|
||||
<>
|
||||
{/* Portfolio Metric Grid - uses same data as Watching */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="My Domains"
|
||||
value={portfolioDomains.length}
|
||||
subValue="Owned"
|
||||
icon={Shield}
|
||||
highlight={true}
|
||||
/>
|
||||
<StatCard
|
||||
label="Online"
|
||||
value={Object.values(healthReports).filter(h => h.status === 'healthy').length}
|
||||
subValue="Healthy"
|
||||
icon={CheckCircle2}
|
||||
trend="up"
|
||||
/>
|
||||
<StatCard
|
||||
label="Issues"
|
||||
value={Object.values(healthReports).filter(h => h.status === 'critical' || h.status === 'weakening').length}
|
||||
subValue="Need attention"
|
||||
icon={AlertTriangle}
|
||||
trend={Object.values(healthReports).filter(h => h.status === 'critical' || h.status === 'weakening').length > 0 ? 'down' : 'neutral'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Expiring Soon"
|
||||
value={portfolioDomains.filter(d => {
|
||||
const days = getDaysUntilExpiry(d.expiration_date)
|
||||
return days !== null && days <= 30 && days > 0
|
||||
}).length}
|
||||
subValue="< 30 days"
|
||||
icon={Calendar}
|
||||
trend={portfolioDomains.filter(d => {
|
||||
const days = getDaysUntilExpiry(d.expiration_date)
|
||||
return days !== null && days <= 30 && days > 0
|
||||
}).length > 0 ? 'down' : 'neutral'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Control Bar - Add Domain */}
|
||||
<div className="sticky top-4 z-30 bg-black/80 backdrop-blur-md border border-white/10 rounded-xl p-2 flex flex-col md:flex-row gap-4 items-center justify-between shadow-2xl">
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-400">
|
||||
<Shield className="w-4 h-4 text-emerald-400" />
|
||||
<span>Add domains you own to track expiry & health</span>
|
||||
</div>
|
||||
|
||||
{/* Add Domain Input */}
|
||||
<form onSubmit={handleAddPortfolioDomain} className="flex-1 max-w-md w-full relative group">
|
||||
<input
|
||||
type="text"
|
||||
value={newPortfolioDomain}
|
||||
onChange={(e) => setNewPortfolioDomain(e.target.value)}
|
||||
placeholder="Add your domain (e.g. mydomain.com)..."
|
||||
className="w-full bg-black/50 border border-white/10 rounded-lg pl-10 pr-12 py-2 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all"
|
||||
/>
|
||||
<Plus className="absolute left-3 top-2.5 w-4 h-4 text-zinc-500 group-focus-within:text-emerald-500 transition-colors" />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addingPortfolio || !newPortfolioDomain.trim()}
|
||||
className="absolute right-2 top-1.5 p-1 hover:bg-emerald-500/20 rounded text-zinc-500 hover:text-emerald-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{addingPortfolio ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowUpRight className="w-4 h-4" />}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Portfolio Data Grid - uses same domains/health as Watching */}
|
||||
<div className="bg-zinc-900/40 border border-white/5 rounded-xl overflow-hidden backdrop-blur-sm">
|
||||
{portfolioDomains.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-white/5 flex items-center justify-center mb-4">
|
||||
<Shield className="w-8 h-8 text-zinc-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-1">No domains in portfolio</h3>
|
||||
<p className="text-zinc-500 text-sm max-w-xs mx-auto mb-6">
|
||||
Add domains you own to track renewals, health status, and changes.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => document.querySelector<HTMLInputElement>('input[placeholder*="mydomain"]')?.focus()}
|
||||
className="text-emerald-400 text-sm hover:text-emerald-300 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Add your first domain
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop Table - IDENTICAL to Watching tab */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<div className="min-w-[900px]">
|
||||
{/* Table Header */}
|
||||
<div className="grid grid-cols-12 gap-4 px-6 py-3 bg-white/[0.02] border-b border-white/5 text-[11px] font-semibold text-zinc-500 uppercase tracking-wider">
|
||||
<div className="col-span-3">Domain</div>
|
||||
<div className="col-span-2 text-center">Health</div>
|
||||
<div className="col-span-2 text-center">Expiry</div>
|
||||
<div className="col-span-2 text-center">Last Update</div>
|
||||
<div className="col-span-1 text-center">Alerts</div>
|
||||
<div className="col-span-2 text-right">Actions</div>
|
||||
</div>
|
||||
|
||||
{/* Table Rows - uses same data/logic as Watching */}
|
||||
<div className="divide-y divide-white/5">
|
||||
{portfolioDomains.map((domain) => {
|
||||
const expiryDays = getDaysUntilExpiry(domain.expiration_date)
|
||||
const isExpiringSoon = expiryDays !== null && expiryDays <= 30 && expiryDays > 0
|
||||
const health = healthReports[domain.id]
|
||||
const healthConfig = health ? healthStatusConfig[health.status] : null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={domain.id}
|
||||
className="grid grid-cols-12 gap-4 px-6 py-4 items-center hover:bg-white/[0.02] transition-colors group"
|
||||
>
|
||||
{/* Domain */}
|
||||
<div className="col-span-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center">
|
||||
<Shield className="w-4 h-4 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-white">{domain.name}</div>
|
||||
<div className="text-[10px] text-zinc-500 mt-0.5">
|
||||
{domain.registrar || 'Unknown registrar'} • Added {getTimeAgo(domain.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health - uses SAME healthReports as Watching */}
|
||||
<div className="col-span-2 flex justify-center">
|
||||
<Tooltip content={healthConfig?.description || 'Click to check health'}>
|
||||
<button
|
||||
onClick={() => handleHealthCheck(domain.id)}
|
||||
disabled={loadingHealth[domain.id]}
|
||||
className={clsx(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center border transition-all",
|
||||
loadingHealth[domain.id] && "animate-pulse",
|
||||
healthConfig
|
||||
? healthConfig.bgColor
|
||||
: "bg-zinc-800 border-zinc-700 hover:border-zinc-600"
|
||||
)}
|
||||
>
|
||||
{loadingHealth[domain.id] ? (
|
||||
<Loader2 className="w-4 h-4 text-zinc-400 animate-spin" />
|
||||
) : healthConfig ? (
|
||||
<healthConfig.icon className={clsx("w-4 h-4", healthConfig.color)} />
|
||||
) : (
|
||||
<Activity className="w-4 h-4 text-zinc-500" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Expiry - uses expiration_date like Watching */}
|
||||
<div className="col-span-2 flex justify-center">
|
||||
{domain.expiration_date ? (
|
||||
<Tooltip content={`Expires: ${formatExpiryDate(domain.expiration_date)}`}>
|
||||
<div className={clsx(
|
||||
"flex items-center gap-1.5 px-2 py-1 rounded border text-xs font-medium",
|
||||
expiryDays !== null && expiryDays <= 7
|
||||
? "bg-rose-500/10 text-rose-400 border-rose-500/20"
|
||||
: isExpiringSoon
|
||||
? "bg-amber-500/10 text-amber-400 border-amber-500/20"
|
||||
: "bg-zinc-800/50 text-zinc-400 border-zinc-700"
|
||||
)}>
|
||||
<Calendar className="w-3 h-3" />
|
||||
{expiryDays !== null ? `${expiryDays}d` : formatExpiryDate(domain.expiration_date)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="text-zinc-600 text-xs">Unknown</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Last Update */}
|
||||
<div className="col-span-2 flex justify-center">
|
||||
<span className="text-xs text-zinc-500">
|
||||
{domain.last_checked ? getTimeAgo(domain.last_checked) : 'Never'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Alerts - uses SAME handleToggleNotify as Watching */}
|
||||
<div className="col-span-1 flex justify-center">
|
||||
<Tooltip content={domain.notify_on_available ? 'Alerts ON' : 'Alerts OFF'}>
|
||||
<button
|
||||
onClick={() => handleToggleNotify(domain.id, domain.notify_on_available)}
|
||||
disabled={togglingNotifyId === domain.id}
|
||||
className={clsx(
|
||||
"p-1.5 rounded border transition-all",
|
||||
domain.notify_on_available
|
||||
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-400"
|
||||
: "bg-zinc-800/50 border-zinc-700 text-zinc-500 hover:text-zinc-400"
|
||||
)}
|
||||
>
|
||||
{togglingNotifyId === domain.id
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: domain.notify_on_available
|
||||
? <Bell className="w-3.5 h-3.5" />
|
||||
: <BellOff className="w-3.5 h-3.5" />
|
||||
}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Actions - uses SAME handleRefresh/handleDelete as Watching */}
|
||||
<div className="col-span-2 flex justify-end gap-2">
|
||||
<Tooltip content="Refresh">
|
||||
<button
|
||||
onClick={() => handleRefresh(domain.id)}
|
||||
disabled={refreshingId === domain.id}
|
||||
className="p-1.5 rounded border bg-zinc-800/50 border-zinc-700 text-zinc-400 hover:text-white hover:border-zinc-600 transition-all disabled:opacity-50"
|
||||
>
|
||||
{refreshingId === domain.id
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: <RefreshCw className="w-3.5 h-3.5" />
|
||||
}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Remove">
|
||||
<button
|
||||
onClick={() => handleDelete(domain.id, domain.name)}
|
||||
disabled={deletingId === domain.id}
|
||||
className="p-1.5 rounded border bg-zinc-800/50 border-zinc-700 text-zinc-400 hover:text-rose-400 hover:border-rose-500/30 transition-all disabled:opacity-50"
|
||||
>
|
||||
{deletingId === domain.id
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: <Trash2 className="w-3.5 h-3.5" />
|
||||
}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Link
|
||||
href="/terminal/listing"
|
||||
className="px-3 py-1.5 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 rounded-lg text-[11px] font-medium hover:bg-emerald-500/20 transition-colors"
|
||||
>
|
||||
Sell
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards - IDENTICAL logic to Watching */}
|
||||
<div className="md:hidden divide-y divide-white/5">
|
||||
{portfolioDomains.map((domain) => {
|
||||
const expiryDays = getDaysUntilExpiry(domain.expiration_date)
|
||||
const isExpiringSoon = expiryDays !== null && expiryDays <= 30 && expiryDays > 0
|
||||
const health = healthReports[domain.id]
|
||||
const healthConfig = health ? healthStatusConfig[health.status] : null
|
||||
|
||||
return (
|
||||
<div key={domain.id} className="p-4 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-white">{domain.name}</div>
|
||||
<div className="text-xs text-zinc-500">{domain.registrar || 'Unknown registrar'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Grid - uses same data as Watching */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="bg-white/[0.02] rounded-lg p-2 text-center">
|
||||
<div className="text-[10px] text-zinc-500 uppercase mb-1">Health</div>
|
||||
<button
|
||||
onClick={() => handleHealthCheck(domain.id)}
|
||||
className={clsx(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center border mx-auto",
|
||||
healthConfig ? healthConfig.bgColor : "bg-zinc-800 border-zinc-700"
|
||||
)}
|
||||
>
|
||||
{loadingHealth[domain.id] ? (
|
||||
<Loader2 className="w-4 h-4 text-zinc-400 animate-spin" />
|
||||
) : healthConfig ? (
|
||||
<healthConfig.icon className={clsx("w-4 h-4", healthConfig.color)} />
|
||||
) : (
|
||||
<Activity className="w-4 h-4 text-zinc-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-white/[0.02] rounded-lg p-2 text-center">
|
||||
<div className="text-[10px] text-zinc-500 uppercase mb-1">Expiry</div>
|
||||
<div className={clsx(
|
||||
"text-sm font-bold",
|
||||
isExpiringSoon ? "text-amber-400" : "text-white"
|
||||
)}>
|
||||
{expiryDays !== null ? `${expiryDays}d` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/[0.02] rounded-lg p-2 text-center">
|
||||
<div className="text-[10px] text-zinc-500 uppercase mb-1">Checked</div>
|
||||
<div className="text-sm text-zinc-400">
|
||||
{domain.last_checked ? getTimeAgo(domain.last_checked) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions - uses SAME functions as Watching */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleToggleNotify(domain.id, domain.notify_on_available)}
|
||||
disabled={togglingNotifyId === domain.id}
|
||||
className={clsx(
|
||||
"p-2 rounded border text-xs flex items-center gap-1.5",
|
||||
domain.notify_on_available
|
||||
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-400"
|
||||
: "bg-zinc-800/50 border-zinc-700 text-zinc-500"
|
||||
)}
|
||||
>
|
||||
{togglingNotifyId === domain.id
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: <Bell className="w-3.5 h-3.5" />
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRefresh(domain.id)}
|
||||
disabled={refreshingId === domain.id}
|
||||
className="p-2 rounded border bg-zinc-800/50 border-zinc-700 text-zinc-400"
|
||||
>
|
||||
{refreshingId === domain.id
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: <RefreshCw className="w-3.5 h-3.5" />
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(domain.id, domain.name)}
|
||||
disabled={deletingId === domain.id}
|
||||
className="p-2 rounded border bg-zinc-800/50 border-zinc-700 text-zinc-400 disabled:opacity-50"
|
||||
>
|
||||
{deletingId === domain.id
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: <Trash2 className="w-3.5 h-3.5" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<Link
|
||||
href="/terminal/listing"
|
||||
className="px-4 py-2 bg-emerald-500 text-white rounded-lg text-xs font-bold uppercase tracking-wider"
|
||||
>
|
||||
Sell
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Portfolio Footer */}
|
||||
<div className="flex items-center justify-center gap-4 text-[10px] text-zinc-700 py-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Same monitoring as Watching tab
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Bell className="w-3 h-3" />
|
||||
Get alerts for changes
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Health Report Modal */}
|
||||
|
||||
@ -21,7 +21,6 @@ import {
|
||||
X,
|
||||
Sparkles,
|
||||
Tag,
|
||||
Briefcase,
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@ -52,7 +52,7 @@ export function TerminalLayout({
|
||||
useEffect(() => {
|
||||
if (!authCheckedRef.current) {
|
||||
authCheckedRef.current = true
|
||||
checkAuth()
|
||||
checkAuth()
|
||||
}
|
||||
}, [checkAuth])
|
||||
|
||||
@ -131,104 +131,104 @@ export function TerminalLayout({
|
||||
<div className="flex items-center gap-2 sm:gap-3 shrink-0 ml-4">
|
||||
{!hideHeaderSearch && (
|
||||
<>
|
||||
{/* Quick Search */}
|
||||
<button
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="hidden md:flex items-center gap-2 h-9 px-3 bg-foreground/5 hover:bg-foreground/8
|
||||
border border-border/40 rounded-lg text-sm text-foreground-muted
|
||||
hover:text-foreground transition-all duration-200 hover:border-border/60"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
<span className="hidden lg:inline">Search</span>
|
||||
<kbd className="hidden xl:inline-flex items-center h-5 px-1.5 bg-background border border-border/60
|
||||
rounded text-[10px] text-foreground-subtle font-mono">⌘K</kbd>
|
||||
</button>
|
||||
{/* Quick Search */}
|
||||
<button
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="hidden md:flex items-center gap-2 h-9 px-3 bg-foreground/5 hover:bg-foreground/8
|
||||
border border-border/40 rounded-lg text-sm text-foreground-muted
|
||||
hover:text-foreground transition-all duration-200 hover:border-border/60"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
<span className="hidden lg:inline">Search</span>
|
||||
<kbd className="hidden xl:inline-flex items-center h-5 px-1.5 bg-background border border-border/60
|
||||
rounded text-[10px] text-foreground-subtle font-mono">⌘K</kbd>
|
||||
</button>
|
||||
|
||||
{/* Mobile Search */}
|
||||
<button
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="md:hidden flex items-center justify-center w-9 h-9 text-foreground-muted
|
||||
hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
</button>
|
||||
{/* Mobile Search */}
|
||||
<button
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="md:hidden flex items-center justify-center w-9 h-9 text-foreground-muted
|
||||
hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setNotificationsOpen(!notificationsOpen)}
|
||||
className={clsx(
|
||||
"relative flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-200",
|
||||
notificationsOpen
|
||||
? "bg-foreground/10 text-foreground"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
)}
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{hasNotifications && (
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-accent rounded-full">
|
||||
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-50" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{/* Notifications */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setNotificationsOpen(!notificationsOpen)}
|
||||
className={clsx(
|
||||
"relative flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-200",
|
||||
notificationsOpen
|
||||
? "bg-foreground/10 text-foreground"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
)}
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{hasNotifications && (
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-accent rounded-full">
|
||||
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-50" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Notifications Dropdown */}
|
||||
{notificationsOpen && (
|
||||
{/* Notifications Dropdown */}
|
||||
{notificationsOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-80 bg-zinc-900 border border-zinc-800
|
||||
rounded-xl shadow-2xl overflow-hidden z-50">
|
||||
<div className="p-4 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-white">Notifications</h3>
|
||||
<button
|
||||
onClick={() => setNotificationsOpen(false)}
|
||||
<button
|
||||
onClick={() => setNotificationsOpen(false)}
|
||||
className="text-zinc-500 hover:text-white"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{availableDomains.length > 0 ? (
|
||||
<div className="p-2">
|
||||
{availableDomains.slice(0, 5).map((domain) => (
|
||||
<Link
|
||||
key={domain.id}
|
||||
href="/terminal/watchlist"
|
||||
onClick={() => setNotificationsOpen(false)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{availableDomains.length > 0 ? (
|
||||
<div className="p-2">
|
||||
{availableDomains.slice(0, 5).map((domain) => (
|
||||
<Link
|
||||
key={domain.id}
|
||||
href="/terminal/watchlist"
|
||||
onClick={() => setNotificationsOpen(false)}
|
||||
className="flex items-start gap-3 p-3 hover:bg-white/5 rounded-lg transition-colors"
|
||||
>
|
||||
>
|
||||
<div className="w-8 h-8 bg-emerald-500/10 rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{domain.name}</p>
|
||||
<p className="text-xs text-emerald-400">Available now!</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center">
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center">
|
||||
<Bell className="w-8 h-8 text-zinc-700 mx-auto mb-3" />
|
||||
<p className="text-sm text-zinc-500">No notifications</p>
|
||||
<p className="text-xs text-zinc-600 mt-1">
|
||||
We'll notify you when domains become available
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
We'll notify you when domains become available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts Hint */}
|
||||
<button
|
||||
onClick={() => {}}
|
||||
className="hidden sm:flex items-center gap-1.5 px-2 py-1.5 text-xs text-foreground-subtle hover:text-foreground
|
||||
bg-foreground/5 rounded-lg border border-border/40 hover:border-border/60 transition-all"
|
||||
title="Keyboard shortcuts (?)"
|
||||
>
|
||||
<Command className="w-3.5 h-3.5" />
|
||||
<span>?</span>
|
||||
</button>
|
||||
{/* Keyboard Shortcuts Hint */}
|
||||
<button
|
||||
onClick={() => {}}
|
||||
className="hidden sm:flex items-center gap-1.5 px-2 py-1.5 text-xs text-foreground-subtle hover:text-foreground
|
||||
bg-foreground/5 rounded-lg border border-border/40 hover:border-border/60 transition-all"
|
||||
title="Keyboard shortcuts (?)"
|
||||
>
|
||||
<Command className="w-3.5 h-3.5" />
|
||||
<span>?</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user