- New Sidebar component with collapsible navigation - New CommandCenterLayout for logged-in users - Separate routes: /watchlist, /portfolio, /market, /intelligence - Dashboard with Activity Feed and Market Pulse - Traffic light status indicators for domain status - Updated Header for public/logged-in state separation - Settings page uses new Command Center layout
530 lines
20 KiB
TypeScript
530 lines
20 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { useStore } from '@/lib/store'
|
|
import { api, PortfolioDomain, PortfolioSummary, DomainValuation } from '@/lib/api'
|
|
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
|
import { Toast, useToast } from '@/components/Toast'
|
|
import {
|
|
Plus,
|
|
Trash2,
|
|
Edit2,
|
|
DollarSign,
|
|
Calendar,
|
|
Building,
|
|
RefreshCw,
|
|
Loader2,
|
|
TrendingUp,
|
|
TrendingDown,
|
|
Tag,
|
|
ExternalLink,
|
|
Sparkles,
|
|
ArrowUpRight,
|
|
X,
|
|
} from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
import Link from 'next/link'
|
|
|
|
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)
|
|
|
|
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: '',
|
|
})
|
|
|
|
useEffect(() => {
|
|
loadPortfolio()
|
|
}, [])
|
|
|
|
const loadPortfolio = 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)
|
|
}
|
|
}
|
|
|
|
const handleAddDomain = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!addForm.domain.trim()) return
|
|
|
|
setAddingDomain(true)
|
|
try {
|
|
await api.addToPortfolio({
|
|
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)
|
|
}
|
|
}
|
|
|
|
const handleEditDomain = 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)
|
|
}
|
|
}
|
|
|
|
const handleSellDomain = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!selectedDomain || !sellForm.sale_price) return
|
|
|
|
setProcessingSale(true)
|
|
try {
|
|
await api.markAsSold(selectedDomain.id, {
|
|
sale_date: sellForm.sale_date,
|
|
sale_price: 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)
|
|
}
|
|
}
|
|
|
|
const handleValuate = async (domain: PortfolioDomain) => {
|
|
setValuatingDomain(domain.domain)
|
|
setShowValuationModal(true)
|
|
try {
|
|
const result = await api.getValuation(domain.domain)
|
|
setValuation(result)
|
|
} catch (err: any) {
|
|
showToast(err.message || 'Failed to get valuation', 'error')
|
|
setShowValuationModal(false)
|
|
} finally {
|
|
setValuatingDomain('')
|
|
}
|
|
}
|
|
|
|
const handleRefresh = async (domain: PortfolioDomain) => {
|
|
setRefreshingId(domain.id)
|
|
try {
|
|
await api.refreshPortfolioValuation(domain.id)
|
|
showToast('Valuation refreshed', 'success')
|
|
loadPortfolio()
|
|
} catch (err: any) {
|
|
showToast(err.message || 'Failed to refresh', 'error')
|
|
} finally {
|
|
setRefreshingId(null)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (domain: PortfolioDomain) => {
|
|
if (!confirm(`Remove ${domain.domain} from your portfolio?`)) return
|
|
|
|
try {
|
|
await api.removeFromPortfolio(domain.id)
|
|
showToast(`Removed ${domain.domain}`, 'success')
|
|
loadPortfolio()
|
|
} catch (err: any) {
|
|
showToast(err.message || 'Failed to remove', 'error')
|
|
}
|
|
}
|
|
|
|
const openEditModal = (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 = (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
|
|
|
|
return (
|
|
<CommandCenterLayout
|
|
title="Portfolio"
|
|
subtitle={`Track your domain investments`}
|
|
actions={
|
|
<button
|
|
onClick={() => setShowAddModal(true)}
|
|
disabled={!canAddMore}
|
|
className="flex items-center gap-2 h-9 px-4 bg-accent text-background rounded-lg
|
|
font-medium text-sm hover:bg-accent-hover transition-colors
|
|
disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Add Domain
|
|
</button>
|
|
}
|
|
>
|
|
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
|
|
|
<div className="max-w-7xl mx-auto space-y-6">
|
|
{/* Summary Stats */}
|
|
{summary && (
|
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
|
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl">
|
|
<p className="text-sm text-foreground-muted mb-1">Total Domains</p>
|
|
<p className="text-2xl font-display text-foreground">{summary.total_domains}</p>
|
|
</div>
|
|
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl">
|
|
<p className="text-sm text-foreground-muted mb-1">Total Invested</p>
|
|
<p className="text-2xl font-display text-foreground">${summary.total_invested?.toLocaleString() || 0}</p>
|
|
</div>
|
|
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl">
|
|
<p className="text-sm text-foreground-muted mb-1">Est. Value</p>
|
|
<p className="text-2xl font-display text-foreground">${summary.total_value?.toLocaleString() || 0}</p>
|
|
</div>
|
|
<div className={clsx(
|
|
"p-5 border rounded-xl",
|
|
(summary.total_profit || 0) >= 0
|
|
? "bg-accent/5 border-accent/20"
|
|
: "bg-red-500/5 border-red-500/20"
|
|
)}>
|
|
<p className="text-sm text-foreground-muted mb-1">Profit/Loss</p>
|
|
<p className={clsx(
|
|
"text-2xl font-display",
|
|
(summary.total_profit || 0) >= 0 ? "text-accent" : "text-red-400"
|
|
)}>
|
|
{(summary.total_profit || 0) >= 0 ? '+' : ''}${summary.total_profit?.toLocaleString() || 0}
|
|
</p>
|
|
</div>
|
|
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl">
|
|
<p className="text-sm text-foreground-muted mb-1">Sold</p>
|
|
<p className="text-2xl font-display text-foreground">{summary.sold_domains || 0}</p>
|
|
</div>
|
|
</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>
|
|
)}
|
|
|
|
{/* Domain List */}
|
|
{loading ? (
|
|
<div className="space-y-3">
|
|
{[...Array(3)].map((_, i) => (
|
|
<div key={i} className="h-24 bg-background-secondary/50 border border-border rounded-xl animate-pulse" />
|
|
))}
|
|
</div>
|
|
) : portfolio.length === 0 ? (
|
|
<div className="text-center py-16 bg-background-secondary/30 border border-border rounded-xl">
|
|
<div className="w-16 h-16 bg-foreground/5 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
|
<Plus className="w-8 h-8 text-foreground-subtle" />
|
|
</div>
|
|
<p className="text-foreground-muted mb-2">Your portfolio is empty</p>
|
|
<p className="text-sm text-foreground-subtle mb-4">Add your first domain to start tracking investments</p>
|
|
<button
|
|
onClick={() => setShowAddModal(true)}
|
|
className="inline-flex items-center gap-2 px-4 py-2 bg-accent text-background rounded-lg font-medium"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Add Domain
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{portfolio.map((domain) => (
|
|
<div
|
|
key={domain.id}
|
|
className="group p-5 bg-background-secondary/50 border border-border rounded-xl
|
|
hover:border-foreground/20 transition-all"
|
|
>
|
|
<div className="flex flex-col lg:flex-row lg:items-center gap-4">
|
|
{/* Domain Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="text-lg font-semibold text-foreground mb-2">{domain.domain}</h3>
|
|
<div className="flex flex-wrap gap-4 text-sm text-foreground-muted">
|
|
{domain.purchase_price && (
|
|
<span className="flex items-center gap-1.5">
|
|
<DollarSign className="w-3.5 h-3.5" />
|
|
Bought: ${domain.purchase_price}
|
|
</span>
|
|
)}
|
|
{domain.registrar && (
|
|
<span className="flex items-center gap-1.5">
|
|
<Building className="w-3.5 h-3.5" />
|
|
{domain.registrar}
|
|
</span>
|
|
)}
|
|
{domain.renewal_date && (
|
|
<span className="flex items-center gap-1.5">
|
|
<Calendar className="w-3.5 h-3.5" />
|
|
Renews: {new Date(domain.renewal_date).toLocaleDateString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Valuation */}
|
|
{domain.current_valuation && (
|
|
<div className="text-right">
|
|
<p className="text-xl font-semibold text-foreground">
|
|
${domain.current_valuation.toLocaleString()}
|
|
</p>
|
|
<p className="text-xs text-foreground-subtle">Est. Value</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => handleValuate(domain)}
|
|
className="p-2 text-foreground-muted hover:text-accent hover:bg-accent/10 rounded-lg transition-colors"
|
|
title="Get valuation"
|
|
>
|
|
<Sparkles className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleRefresh(domain)}
|
|
disabled={refreshingId === domain.id}
|
|
className="p-2 text-foreground-muted hover:bg-foreground/5 rounded-lg transition-colors"
|
|
title="Refresh valuation"
|
|
>
|
|
<RefreshCw className={clsx("w-4 h-4", refreshingId === domain.id && "animate-spin")} />
|
|
</button>
|
|
<button
|
|
onClick={() => openEditModal(domain)}
|
|
className="p-2 text-foreground-muted hover:bg-foreground/5 rounded-lg transition-colors"
|
|
title="Edit"
|
|
>
|
|
<Edit2 className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => openSellModal(domain)}
|
|
className="px-3 py-2 text-sm font-medium text-accent hover:bg-accent/10 rounded-lg transition-colors"
|
|
>
|
|
Sell
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(domain)}
|
|
className="p-2 text-foreground-muted hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-colors"
|
|
title="Remove"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 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">Domain *</label>
|
|
<input
|
|
type="text"
|
|
value={addForm.domain}
|
|
onChange={(e) => setAddForm({ ...addForm, domain: e.target.value })}
|
|
placeholder="example.com"
|
|
className="w-full h-10 px-3 bg-background border border-border rounded-lg text-foreground"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm text-foreground-muted mb-1">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-10 px-3 bg-background border border-border rounded-lg text-foreground"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm text-foreground-muted mb-1">Purchase Date</label>
|
|
<input
|
|
type="date"
|
|
value={addForm.purchase_date}
|
|
onChange={(e) => setAddForm({ ...addForm, purchase_date: e.target.value })}
|
|
className="w-full h-10 px-3 bg-background border border-border rounded-lg text-foreground"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm text-foreground-muted mb-1">Registrar</label>
|
|
<input
|
|
type="text"
|
|
value={addForm.registrar}
|
|
onChange={(e) => setAddForm({ ...addForm, registrar: e.target.value })}
|
|
placeholder="Namecheap"
|
|
className="w-full h-10 px-3 bg-background border border-border rounded-lg text-foreground"
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAddModal(false)}
|
|
className="px-4 py-2 text-foreground-muted hover:text-foreground"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={addingDomain || !addForm.domain.trim()}
|
|
className="flex items-center gap-2 px-4 py-2 bg-accent text-background rounded-lg font-medium
|
|
disabled:opacity-50"
|
|
>
|
|
{addingDomain && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
Add Domain
|
|
</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-8">
|
|
<Loader2 className="w-6 h-6 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-display text-accent">${valuation.estimated_value.toLocaleString()}</p>
|
|
<p className="text-sm text-foreground-muted mt-1">Estimated Value</p>
|
|
</div>
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-foreground-muted">Confidence</span>
|
|
<span className="text-foreground capitalize">{valuation.confidence}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-foreground-muted">Formula</span>
|
|
<span className="text-foreground font-mono text-xs">{valuation.valuation_formula}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</Modal>
|
|
)}
|
|
</CommandCenterLayout>
|
|
)
|
|
}
|
|
|
|
// Simple 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 rounded-2xl shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
|
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
|
<button onClick={onClose} className="text-foreground-muted hover:text-foreground">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<div className="p-4">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|