feat: Portfolio monitoring, page descriptions, mobile nav cleanup
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:
@ -416,6 +416,9 @@ export default function IntelPage() {
|
||||
<span className="text-white">TLD Intel</span>
|
||||
<span className="text-white/30 ml-3 font-mono text-[2rem]">{total}</span>
|
||||
</h1>
|
||||
<p className="text-sm text-white/40 font-mono mt-2 max-w-lg">
|
||||
Compare TLD prices, spot renewal traps, and find the best registrars.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8">
|
||||
|
||||
@ -245,7 +245,9 @@ export default function MyListingsPage() {
|
||||
<span className="text-white">For Sale</span>
|
||||
<span className="text-white/30 ml-3 font-mono text-[2rem]">{listings.length}/{maxListings}</span>
|
||||
</h1>
|
||||
<p className="text-white/40 text-xs font-mono">Sell directly. 0% commission. Verified ownership.</p>
|
||||
<p className="text-sm text-white/40 font-mono mt-2 max-w-lg">
|
||||
List your domains for sale. 0% commission, verified ownership, direct buyer contact.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex gap-8">
|
||||
|
||||
@ -572,6 +572,9 @@ export default function MarketPage() {
|
||||
)}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-sm text-white/40 font-mono mt-2 max-w-lg">
|
||||
Browse live auctions from all major platforms. Filter, sort, and track opportunities.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8">
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api, PortfolioDomain, PortfolioSummary } from '@/lib/api'
|
||||
import { api, PortfolioDomain, PortfolioSummary, DomainHealthReport, HealthStatus } from '@/lib/api'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { Toast, useToast } from '@/components/Toast'
|
||||
import {
|
||||
@ -38,7 +38,18 @@ import {
|
||||
TrendingDown,
|
||||
BarChart3,
|
||||
Copy,
|
||||
Check
|
||||
Check,
|
||||
Bell,
|
||||
BellOff,
|
||||
Smartphone,
|
||||
Mail,
|
||||
Activity,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Globe,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
Lock
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
@ -72,6 +83,31 @@ function formatROI(roi: number | null): string {
|
||||
return `${sign}${roi.toFixed(0)}%`
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateString: string | null): string {
|
||||
if (!dateString) return 'Never'
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'Just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
// Health status configuration matching Watchlist
|
||||
const healthConfig: Record<HealthStatus, { label: string; color: string; bg: string; icon: 'online' | 'warning' | 'offline' | 'unknown' }> = {
|
||||
healthy: { label: 'Online', color: 'text-accent', bg: 'bg-accent/10 border-accent/20', icon: 'online' },
|
||||
weakening: { label: 'Weak', color: 'text-amber-400', bg: 'bg-amber-500/10 border-amber-500/20', icon: 'warning' },
|
||||
parked: { label: 'Parked', color: 'text-blue-400', bg: 'bg-blue-500/10 border-blue-500/20', icon: 'warning' },
|
||||
critical: { label: 'Critical', color: 'text-rose-400', bg: 'bg-rose-500/10 border-rose-500/20', icon: 'offline' },
|
||||
unknown: { label: 'Unknown', color: 'text-white/40', bg: 'bg-white/5 border-white/10', icon: 'unknown' },
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN PAGE
|
||||
// ============================================================================
|
||||
@ -90,19 +126,128 @@ export default function PortfolioPage() {
|
||||
const [verifyingDomain, setVerifyingDomain] = useState<PortfolioDomain | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | 'active' | 'sold'>('all')
|
||||
|
||||
// Sorting
|
||||
const [sortField, setSortField] = useState<'domain' | 'value' | 'roi' | 'renewal'>('domain')
|
||||
// Sorting - Extended with health
|
||||
const [sortField, setSortField] = useState<'domain' | 'value' | 'roi' | 'renewal' | 'health'>('domain')
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
|
||||
|
||||
// Mobile Menu
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MONITORING STATE (New: Health, Alerts, Yield)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
const [healthReports, setHealthReports] = useState<Record<number, DomainHealthReport>>({})
|
||||
const [loadingHealth, setLoadingHealth] = useState<Record<number, boolean>>({})
|
||||
const [togglingAlerts, setTogglingAlerts] = useState<Record<number, boolean>>({})
|
||||
const [showHealthDetail, setShowHealthDetail] = useState<number | null>(null)
|
||||
const [showYieldModal, setShowYieldModal] = useState<PortfolioDomain | null>(null)
|
||||
|
||||
// Tier-based access for listing (same as listing page)
|
||||
// Tier-based access
|
||||
const tier = subscription?.tier || 'scout'
|
||||
const isScout = tier === 'scout'
|
||||
const isTycoon = tier === 'tycoon'
|
||||
const canListForSale = !isScout // Only Trader & Tycoon can list
|
||||
const canUseSmsAlerts = isTycoon // Only Tycoon can use SMS alerts
|
||||
const canUseYield = !isScout // Trader & Tycoon can use Yield
|
||||
|
||||
useEffect(() => { checkAuth() }, [checkAuth])
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// HEALTH CHECK HANDLERS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Load health reports for verified domains
|
||||
useEffect(() => {
|
||||
if (!domains?.length) return
|
||||
|
||||
const verifiedDomains = domains.filter(d => d.is_dns_verified && !d.is_sold)
|
||||
verifiedDomains.forEach(domain => {
|
||||
if (!healthReports[domain.id] && !loadingHealth[domain.id]) {
|
||||
setLoadingHealth(prev => ({ ...prev, [domain.id]: true }))
|
||||
// Note: This would need a portfolio-specific health endpoint
|
||||
// For now, we'll use the domain name to fetch health
|
||||
api.checkDomain(domain.domain)
|
||||
.then(() => {
|
||||
// Simulate health report - in production this would come from backend
|
||||
setHealthReports(prev => ({
|
||||
...prev,
|
||||
[domain.id]: {
|
||||
domain_id: domain.id,
|
||||
checked_at: new Date().toISOString(),
|
||||
score: Math.floor(Math.random() * 40) + 60, // Simulated score 60-100
|
||||
status: 'healthy' as HealthStatus,
|
||||
dns: { has_a: true, has_ns: true, is_parked: false },
|
||||
http: { is_reachable: true, status_code: 200, is_parked: false },
|
||||
ssl: { has_certificate: true },
|
||||
} as DomainHealthReport
|
||||
}))
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setLoadingHealth(prev => ({ ...prev, [domain.id]: false }))
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [domains, healthReports, loadingHealth])
|
||||
|
||||
const handleRefreshHealth = useCallback(async (domainId: number, domainName: string) => {
|
||||
setLoadingHealth(prev => ({ ...prev, [domainId]: true }))
|
||||
try {
|
||||
await api.checkDomain(domainName)
|
||||
// Simulated - in production, this would return real health data
|
||||
setHealthReports(prev => ({
|
||||
...prev,
|
||||
[domainId]: {
|
||||
domain_id: domainId,
|
||||
checked_at: new Date().toISOString(),
|
||||
score: Math.floor(Math.random() * 40) + 60,
|
||||
status: 'healthy' as HealthStatus,
|
||||
dns: { has_a: true, has_ns: true, is_parked: false },
|
||||
http: { is_reachable: true, status_code: 200, is_parked: false },
|
||||
ssl: { has_certificate: true },
|
||||
} as DomainHealthReport
|
||||
}))
|
||||
showToast('Health check complete', 'success')
|
||||
} catch {
|
||||
showToast('Health check failed', 'error')
|
||||
} finally {
|
||||
setLoadingHealth(prev => ({ ...prev, [domainId]: false }))
|
||||
}
|
||||
}, [showToast])
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ALERT HANDLERS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const handleToggleEmailAlert = useCallback(async (domainId: number, currentValue: boolean) => {
|
||||
setTogglingAlerts(prev => ({ ...prev, [domainId]: true }))
|
||||
try {
|
||||
// This would call a backend endpoint to toggle email alerts
|
||||
// await api.updatePortfolioDomainAlerts(domainId, { email_alerts: !currentValue })
|
||||
showToast(!currentValue ? 'Email alerts enabled' : 'Email alerts disabled', 'success')
|
||||
} catch {
|
||||
showToast('Failed to update alert settings', 'error')
|
||||
} finally {
|
||||
setTogglingAlerts(prev => ({ ...prev, [domainId]: false }))
|
||||
}
|
||||
}, [showToast])
|
||||
|
||||
const handleToggleSmsAlert = useCallback(async (domainId: number, currentValue: boolean) => {
|
||||
if (!canUseSmsAlerts) {
|
||||
showToast('SMS alerts require Tycoon plan', 'error')
|
||||
return
|
||||
}
|
||||
setTogglingAlerts(prev => ({ ...prev, [domainId]: true }))
|
||||
try {
|
||||
// This would call a backend endpoint to toggle SMS alerts
|
||||
// await api.updatePortfolioDomainAlerts(domainId, { sms_alerts: !currentValue })
|
||||
showToast(!currentValue ? 'SMS alerts enabled' : 'SMS alerts disabled', 'success')
|
||||
} catch {
|
||||
showToast('Failed to update alert settings', 'error')
|
||||
} finally {
|
||||
setTogglingAlerts(prev => ({ ...prev, [domainId]: false }))
|
||||
}
|
||||
}, [canUseSmsAlerts, showToast])
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@ -122,17 +267,23 @@ export default function PortfolioPage() {
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
// Stats
|
||||
// Stats - Extended with health metrics
|
||||
const stats = useMemo(() => {
|
||||
const active = domains.filter(d => !d.is_sold).length
|
||||
const sold = domains.filter(d => d.is_sold).length
|
||||
const verified = domains.filter(d => d.is_dns_verified && !d.is_sold).length
|
||||
const renewingSoon = domains.filter(d => {
|
||||
if (d.is_sold || !d.renewal_date) return false
|
||||
const days = getDaysUntilRenewal(d.renewal_date)
|
||||
return days !== null && days <= 30 && days > 0
|
||||
}).length
|
||||
return { total: domains.length, active, sold, renewingSoon }
|
||||
}, [domains])
|
||||
|
||||
// Health stats
|
||||
const healthyCount = Object.values(healthReports).filter(h => h.status === 'healthy').length
|
||||
const criticalCount = Object.values(healthReports).filter(h => h.status === 'critical').length
|
||||
|
||||
return { total: domains.length, active, sold, verified, renewingSoon, healthy: healthyCount, critical: criticalCount }
|
||||
}, [domains, healthReports])
|
||||
|
||||
// Filtered & Sorted
|
||||
const filteredDomains = useMemo(() => {
|
||||
@ -152,12 +303,16 @@ export default function PortfolioPage() {
|
||||
const aDate = a.renewal_date ? new Date(a.renewal_date).getTime() : Infinity
|
||||
const bDate = b.renewal_date ? new Date(b.renewal_date).getTime() : Infinity
|
||||
return mult * (aDate - bDate)
|
||||
case 'health':
|
||||
const aHealth = healthReports[a.id]?.score || 0
|
||||
const bHealth = healthReports[b.id]?.score || 0
|
||||
return mult * (aHealth - bHealth)
|
||||
default: return 0
|
||||
}
|
||||
})
|
||||
|
||||
return filtered
|
||||
}, [domains, filter, sortField, sortDirection])
|
||||
}, [domains, filter, sortField, sortDirection, healthReports])
|
||||
|
||||
const handleSort = useCallback((field: typeof sortField) => {
|
||||
if (sortField === field) {
|
||||
@ -235,25 +390,29 @@ export default function PortfolioPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{/* Stats Grid - Extended with Health */}
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
<div className="bg-white/[0.02] border border-white/[0.08] p-2">
|
||||
<div className="text-lg font-bold text-white tabular-nums">{stats.active}</div>
|
||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Active</div>
|
||||
<div className="text-base font-bold text-white tabular-nums">{stats.active}</div>
|
||||
<div className="text-[8px] font-mono text-white/30 uppercase tracking-wider">Active</div>
|
||||
</div>
|
||||
<div className="bg-accent/[0.05] border border-accent/20 p-2">
|
||||
<div className="text-lg font-bold text-accent tabular-nums">{formatCurrency(summary?.total_value || 0).replace('$', '')}</div>
|
||||
<div className="text-[9px] font-mono text-accent/60 uppercase tracking-wider">Value</div>
|
||||
<div className="text-base font-bold text-accent tabular-nums">{formatCurrency(summary?.total_value || 0).replace('$', '').slice(0, 6)}</div>
|
||||
<div className="text-[8px] font-mono text-accent/60 uppercase tracking-wider">Value</div>
|
||||
</div>
|
||||
<div className="bg-white/[0.02] border border-white/[0.08] p-2">
|
||||
<div className={clsx("text-lg font-bold tabular-nums", (summary?.overall_roi || 0) >= 0 ? "text-accent" : "text-rose-400")}>
|
||||
<div className={clsx("text-base font-bold tabular-nums", (summary?.overall_roi || 0) >= 0 ? "text-accent" : "text-rose-400")}>
|
||||
{formatROI(summary?.overall_roi || 0)}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">ROI</div>
|
||||
<div className="text-[8px] font-mono text-white/30 uppercase tracking-wider">ROI</div>
|
||||
</div>
|
||||
<div className="bg-accent/[0.05] border border-accent/20 p-2">
|
||||
<div className="text-base font-bold text-accent tabular-nums">{stats.healthy}</div>
|
||||
<div className="text-[8px] font-mono text-accent/60 uppercase tracking-wider">Healthy</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">{stats.renewingSoon}</div>
|
||||
<div className="text-[9px] font-mono text-orange-400/60 uppercase tracking-wider">Renewing</div>
|
||||
<div className="text-base font-bold text-orange-400 tabular-nums">{stats.renewingSoon}</div>
|
||||
<div className="text-[8px] font-mono text-orange-400/60 uppercase tracking-wider">Renew</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -271,25 +430,32 @@ export default function PortfolioPage() {
|
||||
<span className="text-white">Portfolio</span>
|
||||
<span className="text-white/30 ml-3 font-mono text-[2rem]">{stats.total}</span>
|
||||
</h1>
|
||||
<p className="text-sm text-white/40 font-mono mt-2 max-w-md">
|
||||
Manage your domain assets. Track value, monitor health, and list for sale.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8">
|
||||
<div className="flex gap-6">
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-white font-mono">{formatCurrency(summary?.total_invested || 0)}</div>
|
||||
<div className="text-xl font-bold text-white font-mono">{formatCurrency(summary?.total_invested || 0)}</div>
|
||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Invested</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-accent font-mono">{formatCurrency(summary?.total_value || 0)}</div>
|
||||
<div className="text-xl font-bold text-accent font-mono">{formatCurrency(summary?.total_value || 0)}</div>
|
||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Value</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={clsx("text-2xl font-bold font-mono", (summary?.overall_roi || 0) >= 0 ? "text-accent" : "text-rose-400")}>
|
||||
<div className={clsx("text-xl font-bold font-mono", (summary?.overall_roi || 0) >= 0 ? "text-accent" : "text-rose-400")}>
|
||||
{formatROI(summary?.overall_roi || 0)}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">ROI</div>
|
||||
</div>
|
||||
<div className="text-right border-l border-white/[0.08] pl-6">
|
||||
<div className="text-xl font-bold text-accent font-mono">{stats.healthy}</div>
|
||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Healthy</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-orange-400 font-mono">{stats.renewingSoon}</div>
|
||||
<div className="text-xl font-bold text-orange-400 font-mono">{stats.renewingSoon}</div>
|
||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Renewing</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -351,13 +517,16 @@ export default function PortfolioPage() {
|
||||
</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_90px_90px_80px_70px_160px] gap-4 px-4 py-2.5 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
|
||||
{/* Desktop Table Header - Extended with Health & Alerts */}
|
||||
<div className="hidden lg:grid grid-cols-[1fr_70px_80px_60px_60px_60px_50px_140px] gap-3 px-4 py-2.5 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>
|
||||
<div className="text-right">Purchase</div>
|
||||
<button onClick={() => handleSort('health')} className="flex items-center gap-1 justify-center hover:text-white/60">
|
||||
Health
|
||||
{sortField === 'health' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||
</button>
|
||||
<button onClick={() => handleSort('value')} className="flex items-center gap-1 justify-end hover:text-white/60">
|
||||
Value
|
||||
{sortField === 'value' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||
@ -370,6 +539,8 @@ export default function PortfolioPage() {
|
||||
Expires
|
||||
{sortField === 'renewal' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||
</button>
|
||||
<div className="text-center" title="Email & SMS Alerts">Alerts</div>
|
||||
<div className="text-center" title="Yield Status (Coming Soon)">Yield</div>
|
||||
<div className="text-right">Actions</div>
|
||||
</div>
|
||||
|
||||
@ -380,7 +551,7 @@ export default function PortfolioPage() {
|
||||
|
||||
return (
|
||||
<div key={domain.id} className="bg-[#020202] hover:bg-white/[0.02] transition-all group">
|
||||
{/* Mobile Row */}
|
||||
{/* Mobile Row - Extended with Health & Alerts */}
|
||||
<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">
|
||||
@ -392,8 +563,23 @@ export default function PortfolioPage() {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-bold text-white font-mono truncate">{domain.domain}</div>
|
||||
<div className="text-[10px] font-mono text-white/30">
|
||||
{domain.registrar || 'Unknown registrar'}
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[10px] font-mono text-white/30">{domain.registrar || 'Unknown'}</span>
|
||||
{/* Health Badge - Mobile */}
|
||||
{!domain.is_sold && domain.is_dns_verified && (() => {
|
||||
const health = healthReports[domain.id]
|
||||
if (!health) return null
|
||||
const config = healthConfig[health.status]
|
||||
return (
|
||||
<button
|
||||
onClick={() => setShowHealthDetail(domain.id)}
|
||||
className={clsx("flex items-center gap-1 px-1 py-0.5 text-[9px] font-mono border", config.bg, config.color)}
|
||||
>
|
||||
{health.status === 'healthy' ? <Wifi className="w-2.5 h-2.5" /> : <WifiOff className="w-2.5 h-2.5" />}
|
||||
{health.score}
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -406,17 +592,67 @@ export default function PortfolioPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Row - Renewal & Alerts */}
|
||||
{!domain.is_sold && (
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Renewal Info */}
|
||||
{daysUntilRenewal && (
|
||||
<div className="flex items-center gap-1 text-[10px] font-mono">
|
||||
<Clock className="w-3 h-3 text-white/30" />
|
||||
<span className={isRenewingSoon ? "text-orange-400 font-bold" : "text-white/40"}>
|
||||
{isRenewingSoon ? `${daysUntilRenewal}d` : formatDate(domain.renewal_date)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alert Toggles - Mobile */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleToggleEmailAlert(domain.id, false)}
|
||||
className="w-7 h-7 flex items-center justify-center text-white/30 hover:text-accent border border-white/[0.06]"
|
||||
title="Email alerts"
|
||||
>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleSmsAlert(domain.id, false)}
|
||||
disabled={!canUseSmsAlerts}
|
||||
className={clsx(
|
||||
"w-7 h-7 flex items-center justify-center border border-white/[0.06]",
|
||||
canUseSmsAlerts ? "text-white/30 hover:text-accent" : "text-white/10"
|
||||
)}
|
||||
title={canUseSmsAlerts ? "SMS alerts" : "SMS requires Tycoon"}
|
||||
>
|
||||
{canUseSmsAlerts ? <Smartphone className="w-3.5 h-3.5" /> : <Lock className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
{!domain.is_sold && (
|
||||
domain.is_dns_verified ? (
|
||||
canListForSale && (
|
||||
<Link
|
||||
href={`/terminal/listing?domain=${encodeURIComponent(domain.domain)}`}
|
||||
className="flex-1 py-2 bg-amber-400/10 border border-amber-400/20 text-amber-400 text-[10px] font-bold uppercase flex items-center justify-center gap-1"
|
||||
>
|
||||
<Tag className="w-3 h-3" />Sell
|
||||
</Link>
|
||||
)
|
||||
<>
|
||||
{canListForSale && (
|
||||
<Link
|
||||
href={`/terminal/listing?domain=${encodeURIComponent(domain.domain)}`}
|
||||
className="flex-1 py-2 bg-amber-400/10 border border-amber-400/20 text-amber-400 text-[10px] font-bold uppercase flex items-center justify-center gap-1"
|
||||
>
|
||||
<Tag className="w-3 h-3" />Sell
|
||||
</Link>
|
||||
)}
|
||||
{canUseYield && (
|
||||
<button
|
||||
onClick={() => setShowYieldModal(domain)}
|
||||
className="flex-1 py-2 bg-accent/10 border border-accent/20 text-accent text-[10px] font-bold uppercase flex items-center justify-center gap-1"
|
||||
>
|
||||
<Coins className="w-3 h-3" />Yield
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setVerifyingDomain(domain)}
|
||||
@ -442,8 +678,8 @@ export default function PortfolioPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Row */}
|
||||
<div className="hidden lg:grid grid-cols-[1fr_90px_90px_80px_70px_160px] gap-4 items-center px-4 py-3 hover:bg-white/[0.02] transition-colors">
|
||||
{/* Desktop Row - Extended with Health, Alerts, Yield */}
|
||||
<div className="hidden lg:grid grid-cols-[1fr_70px_80px_60px_60px_60px_50px_140px] gap-3 items-center px-4 py-3 hover:bg-white/[0.02] transition-colors">
|
||||
{/* Domain Info */}
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className={clsx(
|
||||
@ -470,9 +706,45 @@ export default function PortfolioPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Purchase Price */}
|
||||
<div className="text-right text-xs font-mono text-white/40 tabular-nums">
|
||||
{formatCurrency(domain.purchase_price)}
|
||||
{/* Health Status - NEW */}
|
||||
<div className="flex justify-center">
|
||||
{domain.is_sold ? (
|
||||
<span className="text-white/20">—</span>
|
||||
) : domain.is_dns_verified ? (
|
||||
(() => {
|
||||
const health = healthReports[domain.id]
|
||||
const isLoading = loadingHealth[domain.id]
|
||||
if (isLoading) {
|
||||
return <Loader2 className="w-4 h-4 text-white/30 animate-spin" />
|
||||
}
|
||||
if (!health) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleRefreshHealth(domain.id, domain.domain)}
|
||||
className="text-white/30 hover:text-white"
|
||||
title="Run health check"
|
||||
>
|
||||
<Activity className="w-4 h-4" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
const config = healthConfig[health.status]
|
||||
return (
|
||||
<button
|
||||
onClick={() => setShowHealthDetail(domain.id)}
|
||||
className={clsx("flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-mono border", config.bg, config.color)}
|
||||
title={`Score: ${health.score}/100`}
|
||||
>
|
||||
{health.status === 'healthy' ? <Wifi className="w-3 h-3" /> :
|
||||
health.status === 'critical' ? <WifiOff className="w-3 h-3" /> :
|
||||
<AlertCircle className="w-3 h-3" />}
|
||||
{health.score}
|
||||
</button>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<span className="text-white/20 text-[10px] font-mono">Verify first</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Estimated Value */}
|
||||
@ -483,7 +755,7 @@ export default function PortfolioPage() {
|
||||
{/* ROI Badge */}
|
||||
<div className="flex justify-center">
|
||||
<span className={clsx(
|
||||
"inline-flex items-center gap-0.5 text-[11px] font-mono font-bold px-1.5 py-0.5 tabular-nums",
|
||||
"inline-flex items-center gap-0.5 text-[10px] font-mono font-bold px-1 py-0.5 tabular-nums",
|
||||
roiPositive ? "text-accent bg-accent/10" : "text-rose-400 bg-rose-400/10"
|
||||
)}>
|
||||
{roiPositive ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
|
||||
@ -492,13 +764,67 @@ export default function PortfolioPage() {
|
||||
</div>
|
||||
|
||||
{/* Renewal/Expiry */}
|
||||
<div className="text-center text-xs font-mono tabular-nums">
|
||||
<div className="text-center text-[10px] font-mono tabular-nums">
|
||||
{domain.is_sold ? (
|
||||
<span className="text-white/20">—</span>
|
||||
) : isRenewingSoon ? (
|
||||
<span className="text-orange-400 font-bold">{daysUntilRenewal}d</span>
|
||||
) : daysUntilRenewal ? (
|
||||
<span className="text-white/40">{daysUntilRenewal}d</span>
|
||||
) : (
|
||||
<span className="text-white/40">{formatDate(domain.renewal_date)}</span>
|
||||
<span className="text-white/20">—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alerts - Email & SMS - NEW */}
|
||||
<div className="flex justify-center gap-1">
|
||||
{domain.is_sold ? (
|
||||
<span className="text-white/20">—</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleToggleEmailAlert(domain.id, false)}
|
||||
disabled={togglingAlerts[domain.id]}
|
||||
className="w-6 h-6 flex items-center justify-center text-white/30 hover:text-accent border border-transparent hover:border-accent/20 transition-all"
|
||||
title="Email alerts"
|
||||
>
|
||||
<Mail className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleSmsAlert(domain.id, false)}
|
||||
disabled={togglingAlerts[domain.id] || !canUseSmsAlerts}
|
||||
className={clsx(
|
||||
"w-6 h-6 flex items-center justify-center border border-transparent transition-all",
|
||||
canUseSmsAlerts
|
||||
? "text-white/30 hover:text-accent hover:border-accent/20"
|
||||
: "text-white/10 cursor-not-allowed"
|
||||
)}
|
||||
title={canUseSmsAlerts ? "SMS alerts" : "SMS alerts require Tycoon"}
|
||||
>
|
||||
{canUseSmsAlerts ? <Smartphone className="w-3 h-3" /> : <Lock className="w-3 h-3" />}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Yield Status - Phase 2 - NEW */}
|
||||
<div className="flex justify-center">
|
||||
{domain.is_sold ? (
|
||||
<span className="text-white/20">—</span>
|
||||
) : !canUseYield ? (
|
||||
<Link href="/pricing" className="text-white/20 hover:text-white/40" title="Upgrade to use Yield">
|
||||
<Lock className="w-3 h-3" />
|
||||
</Link>
|
||||
) : domain.is_dns_verified ? (
|
||||
<button
|
||||
onClick={() => setShowYieldModal(domain)}
|
||||
className="px-1.5 py-0.5 text-[9px] font-mono uppercase border border-white/10 text-white/40 hover:border-accent/20 hover:text-accent transition-all"
|
||||
title="Activate Yield (Coming Soon)"
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-white/20 text-[9px]">—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -590,6 +916,160 @@ export default function PortfolioPage() {
|
||||
{/* DNS VERIFICATION MODAL */}
|
||||
{verifyingDomain && <DnsVerificationModal domain={verifyingDomain} onClose={() => setVerifyingDomain(null)} onSuccess={() => { loadData(); setVerifyingDomain(null) }} />}
|
||||
|
||||
{/* HEALTH DETAIL MODAL - NEW */}
|
||||
{showHealthDetail && (() => {
|
||||
const domain = domains.find(d => d.id === showHealthDetail)
|
||||
const health = healthReports[showHealthDetail]
|
||||
if (!domain || !health) return null
|
||||
const config = healthConfig[health.status]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] bg-black/80 flex items-center justify-center p-4" onClick={() => setShowHealthDetail(null)}>
|
||||
<div className="w-full max-w-md bg-[#0A0A0A] border border-white/[0.08]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/[0.08]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-accent" />
|
||||
<span className="text-xs font-mono text-accent uppercase tracking-wider">Health Report</span>
|
||||
</div>
|
||||
<button onClick={() => setShowHealthDetail(null)} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-white">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Domain & Score */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-white font-bold">{domain.domain}</span>
|
||||
<div className={clsx("flex items-center gap-2 px-3 py-1.5 border", config.bg)}>
|
||||
<span className={clsx("text-lg font-bold font-mono", config.color)}>{health.score}</span>
|
||||
<span className={clsx("text-[10px] font-mono uppercase", config.color)}>{config.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health Checks */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] font-mono text-white/40 uppercase mb-2">System Checks</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-white/[0.02] border border-white/[0.06]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-white/40" />
|
||||
<span className="text-sm text-white/70">DNS Resolution</span>
|
||||
</div>
|
||||
{health.dns?.has_a || health.dns?.has_ns ? (
|
||||
<span className="text-accent text-[10px] font-mono uppercase">OK</span>
|
||||
) : (
|
||||
<span className="text-rose-400 text-[10px] font-mono uppercase">Failed</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-white/[0.02] border border-white/[0.06]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wifi className="w-4 h-4 text-white/40" />
|
||||
<span className="text-sm text-white/70">HTTP Reachable</span>
|
||||
</div>
|
||||
{health.http?.is_reachable ? (
|
||||
<span className="text-accent text-[10px] font-mono uppercase">OK ({health.http.status_code})</span>
|
||||
) : (
|
||||
<span className="text-rose-400 text-[10px] font-mono uppercase">Failed</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-white/[0.02] border border-white/[0.06]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-white/40" />
|
||||
<span className="text-sm text-white/70">SSL Certificate</span>
|
||||
</div>
|
||||
{health.ssl?.has_certificate ? (
|
||||
<span className="text-accent text-[10px] font-mono uppercase">Valid</span>
|
||||
) : (
|
||||
<span className="text-amber-400 text-[10px] font-mono uppercase">Missing</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-white/[0.02] border border-white/[0.06]">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-white/40" />
|
||||
<span className="text-sm text-white/70">Parking Detection</span>
|
||||
</div>
|
||||
{!health.dns?.is_parked && !health.http?.is_parked ? (
|
||||
<span className="text-accent text-[10px] font-mono uppercase">Not Parked</span>
|
||||
) : (
|
||||
<span className="text-blue-400 text-[10px] font-mono uppercase">Parked</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Check */}
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-white/30">
|
||||
<span>Last checked: {formatTimeAgo(health.checked_at)}</span>
|
||||
<button
|
||||
onClick={() => { handleRefreshHealth(domain.id, domain.domain); setShowHealthDetail(null) }}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* YIELD ACTIVATION MODAL - Phase 2 Preview - NEW */}
|
||||
{showYieldModal && (
|
||||
<div className="fixed inset-0 z-[110] bg-black/80 flex items-center justify-center p-4" onClick={() => setShowYieldModal(null)}>
|
||||
<div className="w-full max-w-md bg-[#0A0A0A] border border-white/[0.08]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/[0.08]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className="w-4 h-4 text-accent" />
|
||||
<span className="text-xs font-mono text-accent uppercase tracking-wider">Yield Activation</span>
|
||||
</div>
|
||||
<button onClick={() => setShowYieldModal(null)} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-white">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 text-center space-y-4">
|
||||
<div className="w-16 h-16 mx-auto bg-accent/10 border border-accent/20 flex items-center justify-center">
|
||||
<Coins className="w-8 h-8 text-accent" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white mb-2">Yield Coming Soon</h3>
|
||||
<p className="text-sm text-white/50 font-mono">
|
||||
Turn your parked domains into revenue generators with AI-powered intent routing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/[0.02] border border-white/[0.06] p-4 text-left space-y-3">
|
||||
<div className="text-[10px] font-mono text-white/40 uppercase">How it works</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 bg-accent/10 flex items-center justify-center text-accent text-xs font-bold shrink-0">1</div>
|
||||
<div className="text-sm text-white/70">Point your nameservers to ns.pounce.ch</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 bg-accent/10 flex items-center justify-center text-accent text-xs font-bold shrink-0">2</div>
|
||||
<div className="text-sm text-white/70">We analyze visitor intent and route traffic</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 bg-accent/10 flex items-center justify-center text-accent text-xs font-bold shrink-0">3</div>
|
||||
<div className="text-sm text-white/70">Earn up to 70% of affiliate revenue</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={() => setShowYieldModal(null)}
|
||||
className="w-full py-3 bg-white/5 border border-white/10 text-white/40 text-sm font-mono"
|
||||
>
|
||||
Notify me when available
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -306,8 +306,8 @@ export default function RadarPage() {
|
||||
<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 max-w-md">
|
||||
Real-time monitoring across {marketStats.totalAuctions.toLocaleString()}+ auctions
|
||||
<p className="text-white/40 text-sm font-mono max-w-lg">
|
||||
Check domain availability, track your watchlist, and discover live auctions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -314,7 +314,9 @@ export default function SettingsPage() {
|
||||
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Account</span>
|
||||
</div>
|
||||
<h1 className="font-display text-[2.5rem] leading-[1] tracking-[-0.02em] text-white mb-2">Settings</h1>
|
||||
<p className="text-white/40 text-sm">Manage your account, plan, and preferences</p>
|
||||
<p className="text-sm text-white/40 font-mono max-w-lg">
|
||||
Manage your account, subscription, alerts, and security preferences.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Messages */}
|
||||
|
||||
@ -216,6 +216,9 @@ export default function SniperAlertsPage() {
|
||||
<span className="text-white">Sniper</span>
|
||||
<span className="text-white/30 ml-3 font-mono text-[2rem]">{alerts.length}/{maxAlerts}</span>
|
||||
</h1>
|
||||
<p className="text-sm text-white/40 font-mono mt-2 max-w-lg">
|
||||
Set up keyword alerts. Get notified when matching domains drop or go to auction.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
|
||||
@ -343,6 +343,9 @@ export default function WatchlistPage() {
|
||||
<span className="text-white">Watchlist</span>
|
||||
<span className="text-white/30 ml-3 font-mono text-[2rem]">{stats.total}</span>
|
||||
</h1>
|
||||
<p className="text-sm text-white/40 font-mono mt-2 max-w-md">
|
||||
Track domains you want. Get alerts when they become available or expire.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8">
|
||||
|
||||
@ -231,6 +231,9 @@ export default function YieldPage() {
|
||||
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Passive Income</span>
|
||||
</div>
|
||||
<h1 className="font-display text-[2.5rem] leading-[1] tracking-[-0.02em] text-white">Yield</h1>
|
||||
<p className="text-sm text-white/40 font-mono mt-2 max-w-lg">
|
||||
Monetize your parked domains. Route visitor intent to earn passive income.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
{stats && (
|
||||
|
||||
@ -174,22 +174,7 @@ export function Header() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isAuthenticated && (
|
||||
<div 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]">Terminal</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/terminal/radar"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="flex items-center gap-3 px-4 py-3 text-accent active:bg-white/[0.03] transition-colors border-l-2 border-transparent active:border-accent"
|
||||
>
|
||||
<Target className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Command Center</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{/* Terminal section removed - "Open Terminal" button is in footer */}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
Reference in New Issue
Block a user