yves.gugger 848b87dd5e fix: Portfolio API calls + Landing Page layout
PORTFOLIO API FIX:
- addToPortfolio → addPortfolioDomain
- removeFromPortfolio → deletePortfolioDomain
- markAsSold → markDomainSold
- getValuation → getDomainValuation
- refreshPortfolioValuation → refreshDomainValue

LANDING PAGE:
- Changed 'Beyond Hunting' section to 3-column grid
- Portfolio now displayed equally with Sell Domains & Sniper Alerts
- Compact card design for all three features
- Consistent sizing and spacing
2025-12-10 14:59:55 +01:00

614 lines
24 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 { PremiumTable, StatCard, PageContainer, TableActionButton } from '@/components/PremiumTable'
import { Toast, useToast } from '@/components/Toast'
import {
Plus,
Trash2,
Edit2,
DollarSign,
Calendar,
Building,
RefreshCw,
Loader2,
TrendingUp,
Sparkles,
ArrowUpRight,
X,
Briefcase,
PiggyBank,
ShoppingCart,
} 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.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)
}
}
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.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)
}
}
const handleValuate = 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('')
}
}
const handleRefresh = 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)
}
}
const handleDelete = 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')
}
}
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
// Dynamic subtitle
const getSubtitle = () => {
if (loading) return 'Loading your portfolio...'
if (portfolio.length === 0) return 'Start tracking your domain investments'
const profit = summary?.total_profit || 0
if (profit > 0) return `${portfolio.length} domains • +$${profit.toLocaleString()} profit`
if (profit < 0) return `${portfolio.length} domains • -$${Math.abs(profit).toLocaleString()} loss`
return `Managing ${portfolio.length} domain${portfolio.length !== 1 ? 's' : ''}`
}
return (
<CommandCenterLayout
title="Portfolio"
subtitle={getSubtitle()}
actions={
<button
onClick={() => setShowAddModal(true)}
disabled={!canAddMore}
className="flex items-center gap-2 h-9 px-4 bg-gradient-to-r from-accent to-accent/80 text-background
rounded-lg font-medium text-sm hover:shadow-[0_0_20px_-5px_rgba(16,185,129,0.4)]
transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">Add Domain</span>
</button>
}
>
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
<PageContainer>
{/* Summary Stats */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<StatCard title="Total Domains" value={summary?.total_domains || 0} icon={Briefcase} />
<StatCard title="Total Invested" value={`$${(summary?.total_invested || 0).toLocaleString()}`} icon={DollarSign} />
<StatCard title="Est. Value" value={`$${(summary?.total_value || 0).toLocaleString()}`} icon={TrendingUp} />
<StatCard
title="Profit/Loss"
value={`${(summary?.total_profit || 0) >= 0 ? '+' : ''}$${(summary?.total_profit || 0).toLocaleString()}`}
icon={PiggyBank}
accent={(summary?.total_profit || 0) >= 0}
/>
<StatCard title="Sold" value={summary?.sold_domains || 0} icon={ShoppingCart} />
</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: 'purchase',
header: 'Purchase',
hideOnMobile: true,
render: (domain) => (
<div>
{domain.purchase_price && (
<span className="font-medium text-foreground">${domain.purchase_price.toLocaleString()}</span>
)}
{domain.purchase_date && (
<p className="text-xs text-foreground-muted flex items-center gap-1 mt-0.5">
<Calendar className="w-3 h-3" /> {new Date(domain.purchase_date).toLocaleDateString()}
</p>
)}
</div>
),
},
{
key: 'valuation',
header: 'Est. Value',
align: 'right',
render: (domain) => (
domain.current_valuation ? (
<span className="font-semibold text-foreground">${domain.current_valuation.toLocaleString()}</span>
) : (
<span className="text-foreground-muted">—</span>
)
),
},
{
key: 'renewal',
header: 'Renewal',
hideOnMobile: true,
hideOnTablet: true,
render: (domain) => (
domain.renewal_date ? (
<span className="text-sm text-foreground-muted">
{new Date(domain.renewal_date).toLocaleDateString()}
</span>
) : (
<span className="text-foreground-subtle">—</span>
)
),
},
{
key: 'actions',
header: '',
align: 'right',
render: (domain) => (
<div className="flex items-center gap-1 justify-end">
<TableActionButton
icon={Sparkles}
onClick={() => handleValuate(domain)}
title="Get valuation"
/>
<TableActionButton
icon={RefreshCw}
onClick={() => handleRefresh(domain)}
loading={refreshingId === domain.id}
title="Refresh valuation"
/>
<TableActionButton
icon={Edit2}
onClick={() => openEditModal(domain)}
title="Edit"
/>
<button
onClick={(e) => { e.stopPropagation(); openSellModal(domain) }}
className="px-3 py-2 text-xs font-medium text-accent hover:bg-accent/10 rounded-lg transition-colors"
>
Sell
</button>
<TableActionButton
icon={Trash2}
onClick={() => handleDelete(domain)}
variant="danger"
title="Remove"
/>
</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>
)}
{/* Sell Modal */}
{showSellModal && selectedDomain && (
<Modal title={`Sell ${selectedDomain.domain}`} onClose={() => setShowSellModal(false)}>
<form onSubmit={handleSellDomain} className="space-y-4">
<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-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 p-3 bg-foreground/5 rounded-lg">
<span className="text-foreground-muted">Confidence</span>
<span className="text-foreground capitalize font-medium">{valuation.confidence}</span>
</div>
<div className="flex justify-between p-3 bg-foreground/5 rounded-lg">
<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>
)
}
// 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>
)
}