- RADAR: dashboard → /terminal/radar - MARKET: auctions + marketplace → /terminal/market - INTEL: pricing → /terminal/intel - WATCHLIST: watchlist + portfolio → /terminal/watchlist - LISTING: listings → /terminal/listing All redirects configured for backwards compatibility. Updated sidebar navigation with new module names.
952 lines
39 KiB
TypeScript
952 lines
39 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
|
|
import { useStore } from '@/lib/store'
|
|
import { api, PortfolioDomain, PortfolioSummary, DomainValuation, DomainHealthReport, HealthStatus } from '@/lib/api'
|
|
import { TerminalLayout } from '@/components/TerminalLayout'
|
|
import { PremiumTable, StatCard, PageContainer, ActionButton } from '@/components/PremiumTable'
|
|
import { Toast, useToast } from '@/components/Toast'
|
|
import {
|
|
Plus,
|
|
Trash2,
|
|
Edit2,
|
|
DollarSign,
|
|
Calendar,
|
|
Building,
|
|
Loader2,
|
|
ArrowUpRight,
|
|
X,
|
|
Briefcase,
|
|
ShoppingCart,
|
|
Activity,
|
|
Shield,
|
|
AlertTriangle,
|
|
Tag,
|
|
MoreVertical,
|
|
ExternalLink,
|
|
} from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
import Link from 'next/link'
|
|
|
|
// Health status configuration
|
|
const healthStatusConfig: Record<HealthStatus, {
|
|
label: string
|
|
color: string
|
|
bgColor: string
|
|
icon: typeof Activity
|
|
}> = {
|
|
healthy: { label: 'Healthy', color: 'text-accent', bgColor: 'bg-accent/10', icon: Activity },
|
|
weakening: { label: 'Weak', color: 'text-amber-400', bgColor: 'bg-amber-400/10', icon: AlertTriangle },
|
|
parked: { label: 'Parked', color: 'text-orange-400', bgColor: 'bg-orange-400/10', icon: ShoppingCart },
|
|
critical: { label: 'Critical', color: 'text-red-400', bgColor: 'bg-red-400/10', icon: AlertTriangle },
|
|
unknown: { label: 'Unknown', color: 'text-foreground-muted', bgColor: 'bg-foreground/5', icon: Activity },
|
|
}
|
|
|
|
export default function PortfolioPage() {
|
|
const { subscription } = useStore()
|
|
const { toast, showToast, hideToast } = useToast()
|
|
|
|
const [portfolio, setPortfolio] = useState<PortfolioDomain[]>([])
|
|
const [summary, setSummary] = useState<PortfolioSummary | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [showAddModal, setShowAddModal] = useState(false)
|
|
const [showEditModal, setShowEditModal] = useState(false)
|
|
const [showSellModal, setShowSellModal] = useState(false)
|
|
const [showValuationModal, setShowValuationModal] = useState(false)
|
|
const [selectedDomain, setSelectedDomain] = useState<PortfolioDomain | null>(null)
|
|
const [valuation, setValuation] = useState<DomainValuation | null>(null)
|
|
const [valuatingDomain, setValuatingDomain] = useState('')
|
|
const [addingDomain, setAddingDomain] = useState(false)
|
|
const [savingEdit, setSavingEdit] = useState(false)
|
|
const [processingSale, setProcessingSale] = useState(false)
|
|
const [refreshingId, setRefreshingId] = useState<number | null>(null)
|
|
|
|
// Health monitoring state
|
|
const [healthReports, setHealthReports] = useState<Record<string, DomainHealthReport>>({})
|
|
const [loadingHealth, setLoadingHealth] = useState<Record<string, boolean>>({})
|
|
const [selectedHealthDomain, setSelectedHealthDomain] = useState<string | null>(null)
|
|
|
|
// Dropdown menu state
|
|
const [openMenuId, setOpenMenuId] = useState<number | null>(null)
|
|
|
|
const [addForm, setAddForm] = useState({
|
|
domain: '',
|
|
purchase_price: '',
|
|
purchase_date: '',
|
|
registrar: '',
|
|
renewal_date: '',
|
|
renewal_cost: '',
|
|
notes: '',
|
|
})
|
|
|
|
const [editForm, setEditForm] = useState({
|
|
purchase_price: '',
|
|
purchase_date: '',
|
|
registrar: '',
|
|
renewal_date: '',
|
|
renewal_cost: '',
|
|
notes: '',
|
|
})
|
|
|
|
const [sellForm, setSellForm] = useState({
|
|
sale_date: new Date().toISOString().split('T')[0],
|
|
sale_price: '',
|
|
})
|
|
|
|
const loadPortfolio = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const [portfolioData, summaryData] = await Promise.all([
|
|
api.getPortfolio(),
|
|
api.getPortfolioSummary(),
|
|
])
|
|
setPortfolio(portfolioData)
|
|
setSummary(summaryData)
|
|
} catch (error) {
|
|
console.error('Failed to load portfolio:', error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadPortfolio()
|
|
}, [loadPortfolio])
|
|
|
|
const handleAddDomain = useCallback(async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!addForm.domain.trim()) return
|
|
|
|
setAddingDomain(true)
|
|
try {
|
|
await api.addPortfolioDomain({
|
|
domain: addForm.domain.trim(),
|
|
purchase_price: addForm.purchase_price ? parseFloat(addForm.purchase_price) : undefined,
|
|
purchase_date: addForm.purchase_date || undefined,
|
|
registrar: addForm.registrar || undefined,
|
|
renewal_date: addForm.renewal_date || undefined,
|
|
renewal_cost: addForm.renewal_cost ? parseFloat(addForm.renewal_cost) : undefined,
|
|
notes: addForm.notes || undefined,
|
|
})
|
|
showToast(`Added ${addForm.domain} to portfolio`, 'success')
|
|
setAddForm({ domain: '', purchase_price: '', purchase_date: '', registrar: '', renewal_date: '', renewal_cost: '', notes: '' })
|
|
setShowAddModal(false)
|
|
loadPortfolio()
|
|
} catch (err: any) {
|
|
showToast(err.message || 'Failed to add domain', 'error')
|
|
} finally {
|
|
setAddingDomain(false)
|
|
}
|
|
}, [addForm, loadPortfolio, showToast])
|
|
|
|
const handleEditDomain = useCallback(async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!selectedDomain) return
|
|
|
|
setSavingEdit(true)
|
|
try {
|
|
await api.updatePortfolioDomain(selectedDomain.id, {
|
|
purchase_price: editForm.purchase_price ? parseFloat(editForm.purchase_price) : undefined,
|
|
purchase_date: editForm.purchase_date || undefined,
|
|
registrar: editForm.registrar || undefined,
|
|
renewal_date: editForm.renewal_date || undefined,
|
|
renewal_cost: editForm.renewal_cost ? parseFloat(editForm.renewal_cost) : undefined,
|
|
notes: editForm.notes || undefined,
|
|
})
|
|
showToast('Domain updated', 'success')
|
|
setShowEditModal(false)
|
|
loadPortfolio()
|
|
} catch (err: any) {
|
|
showToast(err.message || 'Failed to update', 'error')
|
|
} finally {
|
|
setSavingEdit(false)
|
|
}
|
|
}, [selectedDomain, editForm, loadPortfolio, showToast])
|
|
|
|
const handleSellDomain = useCallback(async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!selectedDomain || !sellForm.sale_price) return
|
|
|
|
setProcessingSale(true)
|
|
try {
|
|
await api.markDomainSold(selectedDomain.id, sellForm.sale_date, parseFloat(sellForm.sale_price))
|
|
showToast(`Marked ${selectedDomain.domain} as sold`, 'success')
|
|
setShowSellModal(false)
|
|
loadPortfolio()
|
|
} catch (err: any) {
|
|
showToast(err.message || 'Failed to process sale', 'error')
|
|
} finally {
|
|
setProcessingSale(false)
|
|
}
|
|
}, [selectedDomain, sellForm, loadPortfolio, showToast])
|
|
|
|
const handleValuate = useCallback(async (domain: PortfolioDomain) => {
|
|
setValuatingDomain(domain.domain)
|
|
setShowValuationModal(true)
|
|
try {
|
|
const result = await api.getDomainValuation(domain.domain)
|
|
setValuation(result)
|
|
} catch (err: any) {
|
|
showToast(err.message || 'Failed to get valuation', 'error')
|
|
setShowValuationModal(false)
|
|
} finally {
|
|
setValuatingDomain('')
|
|
}
|
|
}, [showToast])
|
|
|
|
const handleRefresh = useCallback(async (domain: PortfolioDomain) => {
|
|
setRefreshingId(domain.id)
|
|
try {
|
|
await api.refreshDomainValue(domain.id)
|
|
showToast('Valuation refreshed', 'success')
|
|
loadPortfolio()
|
|
} catch (err: any) {
|
|
showToast(err.message || 'Failed to refresh', 'error')
|
|
} finally {
|
|
setRefreshingId(null)
|
|
}
|
|
}, [loadPortfolio, showToast])
|
|
|
|
const handleHealthCheck = useCallback(async (domainName: string) => {
|
|
if (loadingHealth[domainName]) return
|
|
|
|
setLoadingHealth(prev => ({ ...prev, [domainName]: true }))
|
|
try {
|
|
const report = await api.quickHealthCheck(domainName)
|
|
setHealthReports(prev => ({ ...prev, [domainName]: report }))
|
|
setSelectedHealthDomain(domainName)
|
|
} catch (err: any) {
|
|
showToast(err.message || 'Health check failed', 'error')
|
|
} finally {
|
|
setLoadingHealth(prev => ({ ...prev, [domainName]: false }))
|
|
}
|
|
}, [loadingHealth, showToast])
|
|
|
|
const handleDelete = useCallback(async (domain: PortfolioDomain) => {
|
|
if (!confirm(`Remove ${domain.domain} from your portfolio?`)) return
|
|
|
|
try {
|
|
await api.deletePortfolioDomain(domain.id)
|
|
showToast(`Removed ${domain.domain}`, 'success')
|
|
loadPortfolio()
|
|
} catch (err: any) {
|
|
showToast(err.message || 'Failed to remove', 'error')
|
|
}
|
|
}, [loadPortfolio, showToast])
|
|
|
|
const openEditModal = useCallback((domain: PortfolioDomain) => {
|
|
setSelectedDomain(domain)
|
|
setEditForm({
|
|
purchase_price: domain.purchase_price?.toString() || '',
|
|
purchase_date: domain.purchase_date || '',
|
|
registrar: domain.registrar || '',
|
|
renewal_date: domain.renewal_date || '',
|
|
renewal_cost: domain.renewal_cost?.toString() || '',
|
|
notes: domain.notes || '',
|
|
})
|
|
setShowEditModal(true)
|
|
}, [])
|
|
|
|
const openSellModal = useCallback((domain: PortfolioDomain) => {
|
|
setSelectedDomain(domain)
|
|
setSellForm({
|
|
sale_date: new Date().toISOString().split('T')[0],
|
|
sale_price: '',
|
|
})
|
|
setShowSellModal(true)
|
|
}, [])
|
|
|
|
const portfolioLimit = subscription?.portfolio_limit || 0
|
|
const canAddMore = portfolioLimit === -1 || portfolio.length < portfolioLimit
|
|
|
|
// Memoized stats and subtitle
|
|
const { expiringSoonCount, subtitle } = useMemo(() => {
|
|
const expiring = portfolio.filter(d => {
|
|
if (!d.renewal_date) return false
|
|
const days = Math.ceil((new Date(d.renewal_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
|
return days <= 30 && days > 0
|
|
}).length
|
|
|
|
let sub = ''
|
|
if (loading) sub = 'Loading your portfolio...'
|
|
else if (portfolio.length === 0) sub = 'Start tracking your domains'
|
|
else if (expiring > 0) sub = `${portfolio.length} domain${portfolio.length !== 1 ? 's' : ''} • ${expiring} expiring soon`
|
|
else sub = `Managing ${portfolio.length} domain${portfolio.length !== 1 ? 's' : ''}`
|
|
|
|
return { expiringSoonCount: expiring, subtitle: sub }
|
|
}, [portfolio, loading])
|
|
|
|
return (
|
|
<TerminalLayout
|
|
title="Portfolio"
|
|
subtitle={subtitle}
|
|
actions={
|
|
<ActionButton onClick={() => setShowAddModal(true)} disabled={!canAddMore} icon={Plus}>
|
|
Add Domain
|
|
</ActionButton>
|
|
}
|
|
>
|
|
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
|
|
|
<PageContainer>
|
|
{/* Summary Stats - Only reliable data */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<StatCard title="Total Domains" value={summary?.total_domains || 0} icon={Briefcase} />
|
|
<StatCard title="Expiring Soon" value={expiringSoonCount} icon={Calendar} />
|
|
<StatCard
|
|
title="Need Attention"
|
|
value={Object.values(healthReports).filter(r => r.status !== 'healthy').length}
|
|
icon={AlertTriangle}
|
|
/>
|
|
<StatCard title="Listed for Sale" value={summary?.sold_domains || 0} icon={Tag} />
|
|
</div>
|
|
|
|
{!canAddMore && (
|
|
<div className="flex items-center justify-between p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl">
|
|
<p className="text-sm text-amber-400">
|
|
You've reached your portfolio limit. Upgrade to add more.
|
|
</p>
|
|
<Link
|
|
href="/pricing"
|
|
className="text-sm font-medium text-amber-400 hover:text-amber-300 flex items-center gap-1"
|
|
>
|
|
Upgrade <ArrowUpRight className="w-3 h-3" />
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{/* Portfolio Table */}
|
|
<PremiumTable
|
|
data={portfolio}
|
|
keyExtractor={(d) => d.id}
|
|
loading={loading}
|
|
emptyIcon={<Briefcase className="w-12 h-12 text-foreground-subtle" />}
|
|
emptyTitle="Your portfolio is empty"
|
|
emptyDescription="Add your first domain to start tracking investments"
|
|
columns={[
|
|
{
|
|
key: 'domain',
|
|
header: 'Domain',
|
|
render: (domain) => (
|
|
<div>
|
|
<span className="font-mono font-medium text-foreground">{domain.domain}</span>
|
|
{domain.registrar && (
|
|
<p className="text-xs text-foreground-muted flex items-center gap-1 mt-0.5">
|
|
<Building className="w-3 h-3" /> {domain.registrar}
|
|
</p>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'added',
|
|
header: 'Added',
|
|
hideOnMobile: true,
|
|
hideOnTablet: true,
|
|
render: (domain) => (
|
|
<span className="text-sm text-foreground-muted">
|
|
{domain.purchase_date
|
|
? new Date(domain.purchase_date).toLocaleDateString()
|
|
: new Date(domain.created_at).toLocaleDateString()
|
|
}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'renewal',
|
|
header: 'Expires',
|
|
hideOnMobile: true,
|
|
render: (domain) => {
|
|
if (!domain.renewal_date) {
|
|
return <span className="text-foreground-subtle">—</span>
|
|
}
|
|
const days = Math.ceil((new Date(domain.renewal_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
|
const isExpiringSoon = days <= 30 && days > 0
|
|
const isExpired = days <= 0
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className={clsx(
|
|
"text-sm",
|
|
isExpired && "text-red-400",
|
|
isExpiringSoon && "text-amber-400",
|
|
!isExpired && !isExpiringSoon && "text-foreground-muted"
|
|
)}>
|
|
{new Date(domain.renewal_date).toLocaleDateString()}
|
|
</span>
|
|
{isExpiringSoon && (
|
|
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-amber-400/10 text-amber-400 rounded">
|
|
{days}d
|
|
</span>
|
|
)}
|
|
{isExpired && (
|
|
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-red-400/10 text-red-400 rounded">
|
|
EXPIRED
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
key: 'health',
|
|
header: 'Health',
|
|
hideOnMobile: true,
|
|
render: (domain) => {
|
|
const report = healthReports[domain.domain]
|
|
if (loadingHealth[domain.domain]) {
|
|
return <Loader2 className="w-4 h-4 text-foreground-muted animate-spin" />
|
|
}
|
|
if (report) {
|
|
const config = healthStatusConfig[report.status]
|
|
const Icon = config.icon
|
|
return (
|
|
<button
|
|
onClick={() => setSelectedHealthDomain(domain.domain)}
|
|
className={clsx(
|
|
"inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-medium",
|
|
config.bgColor, config.color
|
|
)}
|
|
>
|
|
<Icon className="w-3 h-3" />
|
|
{config.label}
|
|
</button>
|
|
)
|
|
}
|
|
return (
|
|
<button
|
|
onClick={() => handleHealthCheck(domain.domain)}
|
|
className="text-xs text-foreground-muted hover:text-accent transition-colors flex items-center gap-1"
|
|
>
|
|
<Activity className="w-3.5 h-3.5" />
|
|
Check
|
|
</button>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
align: 'right',
|
|
render: (domain) => (
|
|
<div className="relative">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setOpenMenuId(openMenuId === domain.id ? null : domain.id)
|
|
}}
|
|
className="p-2 text-foreground-muted hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
|
|
>
|
|
<MoreVertical className="w-4 h-4" />
|
|
</button>
|
|
|
|
{openMenuId === domain.id && (
|
|
<>
|
|
{/* Backdrop */}
|
|
<div
|
|
className="fixed inset-0 z-40"
|
|
onClick={() => setOpenMenuId(null)}
|
|
/>
|
|
{/* Menu - opens downward */}
|
|
<div className="absolute right-0 top-full mt-1 z-50 w-48 py-1 bg-background-secondary border border-border/50 rounded-xl shadow-xl">
|
|
<button
|
|
onClick={() => { handleHealthCheck(domain.domain); setOpenMenuId(null) }}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
|
|
>
|
|
<Shield className="w-4 h-4" />
|
|
Health Check
|
|
</button>
|
|
<button
|
|
onClick={() => { openEditModal(domain); setOpenMenuId(null) }}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
|
|
>
|
|
<Edit2 className="w-4 h-4" />
|
|
Edit Details
|
|
</button>
|
|
<div className="my-1 border-t border-border/30" />
|
|
<Link
|
|
href={`/terminal/listing?domain=${encodeURIComponent(domain.domain)}`}
|
|
onClick={() => setOpenMenuId(null)}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-accent hover:bg-accent/5 transition-colors"
|
|
>
|
|
<Tag className="w-4 h-4" />
|
|
List for Sale
|
|
</Link>
|
|
<a
|
|
href={`https://${domain.domain}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={() => setOpenMenuId(null)}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
|
|
>
|
|
<ExternalLink className="w-4 h-4" />
|
|
Visit Website
|
|
</a>
|
|
<div className="my-1 border-t border-border/30" />
|
|
<button
|
|
onClick={() => { openSellModal(domain); setOpenMenuId(null) }}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
|
|
>
|
|
<DollarSign className="w-4 h-4" />
|
|
Record Sale
|
|
</button>
|
|
<button
|
|
onClick={() => { handleDelete(domain); setOpenMenuId(null) }}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-400 hover:bg-red-400/5 transition-colors"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</PageContainer>
|
|
|
|
{/* Add Modal */}
|
|
{showAddModal && (
|
|
<Modal title="Add Domain to Portfolio" onClose={() => setShowAddModal(false)}>
|
|
<form onSubmit={handleAddDomain} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm text-foreground-muted mb-1.5">Domain *</label>
|
|
<input
|
|
type="text"
|
|
value={addForm.domain}
|
|
onChange={(e) => setAddForm({ ...addForm, domain: e.target.value })}
|
|
placeholder="example.com"
|
|
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
|
|
focus:outline-none focus:border-accent/50 transition-all"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm text-foreground-muted mb-1.5">Purchase Price</label>
|
|
<input
|
|
type="number"
|
|
value={addForm.purchase_price}
|
|
onChange={(e) => setAddForm({ ...addForm, purchase_price: e.target.value })}
|
|
placeholder="100"
|
|
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
|
|
focus:outline-none focus:border-accent/50"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm text-foreground-muted mb-1.5">Purchase Date</label>
|
|
<input
|
|
type="date"
|
|
value={addForm.purchase_date}
|
|
onChange={(e) => setAddForm({ ...addForm, purchase_date: e.target.value })}
|
|
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
|
|
focus:outline-none focus:border-accent/50"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm text-foreground-muted mb-1.5">Registrar</label>
|
|
<input
|
|
type="text"
|
|
value={addForm.registrar}
|
|
onChange={(e) => setAddForm({ ...addForm, registrar: e.target.value })}
|
|
placeholder="Namecheap"
|
|
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
|
|
focus:outline-none focus:border-accent/50"
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-3 pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAddModal(false)}
|
|
className="px-4 py-2.5 text-foreground-muted hover:text-foreground transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={addingDomain || !addForm.domain.trim()}
|
|
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-accent to-accent/80
|
|
text-background rounded-xl font-medium disabled:opacity-50 transition-all"
|
|
>
|
|
{addingDomain && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
Add Domain
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
)}
|
|
|
|
{/* Edit Modal */}
|
|
{showEditModal && selectedDomain && (
|
|
<Modal title={`Edit ${selectedDomain.domain}`} onClose={() => setShowEditModal(false)}>
|
|
<form onSubmit={handleEditDomain} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm text-foreground-muted mb-1.5">Purchase Price</label>
|
|
<input
|
|
type="number"
|
|
value={editForm.purchase_price}
|
|
onChange={(e) => setEditForm({ ...editForm, purchase_price: e.target.value })}
|
|
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
|
|
focus:outline-none focus:border-accent/50"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm text-foreground-muted mb-1.5">Registrar</label>
|
|
<input
|
|
type="text"
|
|
value={editForm.registrar}
|
|
onChange={(e) => setEditForm({ ...editForm, registrar: e.target.value })}
|
|
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
|
|
focus:outline-none focus:border-accent/50"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-3 pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowEditModal(false)}
|
|
className="px-4 py-2.5 text-foreground-muted hover:text-foreground transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={savingEdit}
|
|
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-accent to-accent/80
|
|
text-background rounded-xl font-medium disabled:opacity-50 transition-all"
|
|
>
|
|
{savingEdit && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
Save Changes
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
)}
|
|
|
|
{/* Record Sale Modal - for tracking completed sales */}
|
|
{showSellModal && selectedDomain && (
|
|
<Modal title={`Record Sale: ${selectedDomain.domain}`} onClose={() => setShowSellModal(false)}>
|
|
<form onSubmit={handleSellDomain} className="space-y-4">
|
|
<div className="p-3 bg-accent/10 border border-accent/20 rounded-lg text-sm text-foreground-muted">
|
|
<p>Record a completed sale to track your profit/loss. This will mark the domain as sold in your portfolio.</p>
|
|
<p className="mt-2 text-accent">Want to list it for sale instead? Use the <strong>"List"</strong> button.</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm text-foreground-muted mb-1.5">Sale Price *</label>
|
|
<input
|
|
type="number"
|
|
value={sellForm.sale_price}
|
|
onChange={(e) => setSellForm({ ...sellForm, sale_price: e.target.value })}
|
|
placeholder="1000"
|
|
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
|
|
focus:outline-none focus:border-accent/50"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm text-foreground-muted mb-1.5">Sale Date</label>
|
|
<input
|
|
type="date"
|
|
value={sellForm.sale_date}
|
|
onChange={(e) => setSellForm({ ...sellForm, sale_date: e.target.value })}
|
|
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
|
|
focus:outline-none focus:border-accent/50"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-3 pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowSellModal(false)}
|
|
className="px-4 py-2.5 text-foreground-muted hover:text-foreground transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={processingSale || !sellForm.sale_price}
|
|
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-accent to-accent/80
|
|
text-background rounded-xl font-medium disabled:opacity-50 transition-all"
|
|
>
|
|
{processingSale && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
Mark as Sold
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
)}
|
|
|
|
{/* Valuation Modal */}
|
|
{showValuationModal && (
|
|
<Modal title="Domain Valuation" onClose={() => { setShowValuationModal(false); setValuation(null); }}>
|
|
{valuatingDomain ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-8 h-8 animate-spin text-accent" />
|
|
</div>
|
|
) : valuation ? (
|
|
<div className="space-y-4">
|
|
<div className="text-center p-6 bg-accent/5 border border-accent/20 rounded-xl">
|
|
<p className="text-4xl font-semibold text-accent">${valuation.estimated_value.toLocaleString()}</p>
|
|
<p className="text-sm text-foreground-muted mt-1">Pounce Score Estimate</p>
|
|
</div>
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex justify-between items-center p-3 bg-foreground/5 rounded-lg">
|
|
<span className="text-foreground-muted">Confidence Level</span>
|
|
<span className={clsx(
|
|
"px-2 py-0.5 rounded text-xs font-medium capitalize",
|
|
valuation.confidence === 'high' && "bg-accent/20 text-accent",
|
|
valuation.confidence === 'medium' && "bg-amber-400/20 text-amber-400",
|
|
valuation.confidence === 'low' && "bg-foreground/10 text-foreground-muted"
|
|
)}>
|
|
{valuation.confidence}
|
|
</span>
|
|
</div>
|
|
<div className="p-3 bg-amber-400/10 border border-amber-400/20 rounded-lg text-xs text-amber-400">
|
|
<p>This is an algorithmic estimate based on domain length, TLD, and market patterns. Actual market value may vary.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</Modal>
|
|
)}
|
|
|
|
{/* Health Report Modal */}
|
|
{selectedHealthDomain && healthReports[selectedHealthDomain] && (
|
|
<HealthReportModal
|
|
report={healthReports[selectedHealthDomain]}
|
|
onClose={() => setSelectedHealthDomain(null)}
|
|
/>
|
|
)}
|
|
</TerminalLayout>
|
|
)
|
|
}
|
|
|
|
// Health Report Modal Component
|
|
function HealthReportModal({ report, onClose }: { report: DomainHealthReport; onClose: () => void }) {
|
|
const config = healthStatusConfig[report.status]
|
|
const Icon = config.icon
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="w-full max-w-lg bg-background-secondary border border-border/50 rounded-2xl shadow-2xl overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-5 border-b border-border/50">
|
|
<div className="flex items-center gap-3">
|
|
<div className={clsx("p-2 rounded-lg", config.bgColor)}>
|
|
<Icon className={clsx("w-5 h-5", config.color)} />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-mono font-semibold text-foreground">{report.domain}</h3>
|
|
<p className={clsx("text-xs font-medium", config.color)}>{config.label}</p>
|
|
</div>
|
|
</div>
|
|
<button onClick={onClose} className="p-1 text-foreground-muted hover:text-foreground transition-colors">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Score */}
|
|
<div className="p-5 border-b border-border/30">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-foreground-muted">Health Score</span>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-32 h-2 bg-foreground/10 rounded-full overflow-hidden">
|
|
<div
|
|
className={clsx(
|
|
"h-full rounded-full transition-all",
|
|
report.score >= 70 ? "bg-accent" :
|
|
report.score >= 40 ? "bg-amber-400" : "bg-red-400"
|
|
)}
|
|
style={{ width: `${report.score}%` }}
|
|
/>
|
|
</div>
|
|
<span className={clsx(
|
|
"text-lg font-bold tabular-nums",
|
|
report.score >= 70 ? "text-accent" :
|
|
report.score >= 40 ? "text-amber-400" : "text-red-400"
|
|
)}>
|
|
{report.score}/100
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Check Results */}
|
|
<div className="p-5 space-y-4">
|
|
{/* DNS */}
|
|
{report.dns && (
|
|
<div className="p-4 bg-foreground/5 rounded-xl">
|
|
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
|
|
<span className={clsx(
|
|
"w-2 h-2 rounded-full",
|
|
report.dns.has_ns && report.dns.has_a ? "bg-accent" : "bg-red-400"
|
|
)} />
|
|
DNS Infrastructure
|
|
</h4>
|
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className={report.dns.has_ns ? "text-accent" : "text-red-400"}>
|
|
{report.dns.has_ns ? '✓' : '✗'}
|
|
</span>
|
|
<span className="text-foreground-muted">Nameservers</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className={report.dns.has_a ? "text-accent" : "text-red-400"}>
|
|
{report.dns.has_a ? '✓' : '✗'}
|
|
</span>
|
|
<span className="text-foreground-muted">A Record</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className={report.dns.has_mx ? "text-accent" : "text-foreground-muted"}>
|
|
{report.dns.has_mx ? '✓' : '—'}
|
|
</span>
|
|
<span className="text-foreground-muted">MX Record</span>
|
|
</div>
|
|
</div>
|
|
{report.dns.is_parked && (
|
|
<p className="mt-2 text-xs text-orange-400">⚠ Parked at {report.dns.parking_provider || 'unknown provider'}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* HTTP */}
|
|
{report.http && (
|
|
<div className="p-4 bg-foreground/5 rounded-xl">
|
|
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
|
|
<span className={clsx(
|
|
"w-2 h-2 rounded-full",
|
|
report.http.is_reachable && report.http.status_code === 200 ? "bg-accent" :
|
|
report.http.is_reachable ? "bg-amber-400" : "bg-red-400"
|
|
)} />
|
|
Website Status
|
|
</h4>
|
|
<div className="flex items-center gap-4 text-xs">
|
|
<span className={clsx(
|
|
report.http.is_reachable ? "text-accent" : "text-red-400"
|
|
)}>
|
|
{report.http.is_reachable ? 'Reachable' : 'Unreachable'}
|
|
</span>
|
|
{report.http.status_code && (
|
|
<span className="text-foreground-muted">
|
|
HTTP {report.http.status_code}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{report.http.is_parked && (
|
|
<p className="mt-2 text-xs text-orange-400">⚠ Parking page detected</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* SSL */}
|
|
{report.ssl && (
|
|
<div className="p-4 bg-foreground/5 rounded-xl">
|
|
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
|
|
<span className={clsx(
|
|
"w-2 h-2 rounded-full",
|
|
report.ssl.has_certificate && report.ssl.is_valid ? "bg-accent" :
|
|
report.ssl.has_certificate ? "bg-amber-400" : "bg-foreground-muted"
|
|
)} />
|
|
SSL Certificate
|
|
</h4>
|
|
<div className="text-xs">
|
|
{report.ssl.has_certificate ? (
|
|
<div className="space-y-1">
|
|
<p className={report.ssl.is_valid ? "text-accent" : "text-red-400"}>
|
|
{report.ssl.is_valid ? '✓ Valid certificate' : '✗ Certificate invalid/expired'}
|
|
</p>
|
|
{report.ssl.days_until_expiry !== undefined && (
|
|
<p className={clsx(
|
|
report.ssl.days_until_expiry > 30 ? "text-foreground-muted" :
|
|
report.ssl.days_until_expiry > 7 ? "text-amber-400" : "text-red-400"
|
|
)}>
|
|
Expires in {report.ssl.days_until_expiry} days
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="text-foreground-muted">No SSL certificate</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Signals & Recommendations */}
|
|
{((report.signals?.length || 0) > 0 || (report.recommendations?.length || 0) > 0) && (
|
|
<div className="space-y-3">
|
|
{(report.signals?.length || 0) > 0 && (
|
|
<div>
|
|
<h4 className="text-xs font-medium text-foreground-muted uppercase tracking-wider mb-2">Signals</h4>
|
|
<ul className="space-y-1">
|
|
{report.signals?.map((signal, i) => (
|
|
<li key={i} className="text-xs text-foreground flex items-start gap-2">
|
|
<span className="text-accent mt-0.5">•</span>
|
|
{signal}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
{(report.recommendations?.length || 0) > 0 && (
|
|
<div>
|
|
<h4 className="text-xs font-medium text-foreground-muted uppercase tracking-wider mb-2">Recommendations</h4>
|
|
<ul className="space-y-1">
|
|
{report.recommendations?.map((rec, i) => (
|
|
<li key={i} className="text-xs text-foreground flex items-start gap-2">
|
|
<span className="text-amber-400 mt-0.5">→</span>
|
|
{rec}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-4 bg-foreground/5 border-t border-border/30">
|
|
<p className="text-xs text-foreground-subtle text-center">
|
|
Checked at {new Date(report.checked_at).toLocaleString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Modal Component
|
|
function Modal({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="w-full max-w-md bg-background-secondary border border-border/50 rounded-2xl shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center justify-between p-5 border-b border-border/50">
|
|
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
|
<button onClick={onClose} className="p-1 text-foreground-muted hover:text-foreground transition-colors">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<div className="p-5">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|