pummmp.fun/app/dashboard/page.tsx
2026-07-04 12:49:09 -07:00

398 lines
17 KiB
TypeScript

'use client'
import React, { useMemo } from 'react'
import Link from 'next/link'
import { useSession, signIn } from 'next-auth/react'
import useSWR from 'swr'
import { Header } from '@/components/header'
import { Button } from '@/components/ui/button'
import { CoinCard } from '@/components/coin-card'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { User, Coin, SolPrice } from '@/types'
import {
Area,
AreaChart,
ResponsiveContainer,
XAxis,
YAxis,
Tooltip,
CartesianGrid
} from 'recharts'
import {
LgLock,
LgPlus,
LgBriefcase,
LgFolderInvoices,
LgComboChart,
LgOpenedFolder,
LgTrendingUp,
LgGroups
} from '@/components/icons'
const fetcher = (url: string) => fetch(url).then((res) => res.json())
export default function DashboardPage() {
const { data: session, status } = useSession()
const { data: userData } = useSWR<User & { portfolioValue: number; totalValue: number }>(
session ? '/api/user' : null,
fetcher,
{ refreshInterval: 10000 }
)
const { data: coins } = useSWR<Coin[]>('/api/coins', fetcher)
const { data: solPrice } = useSWR<SolPrice>('/api/sol-price', fetcher, {
refreshInterval: 60000,
})
const portfolioHistory = useMemo(() => {
if (!userData?.portfolio || !coins) return []
const heldCoins = userData.portfolio
.map(p => {
const coin = coins.find(c => c._id === p.coinId)
return coin ? { ...p, coin } : null
})
.filter((item): item is NonNullable<typeof item> => item !== null)
if (heldCoins.length === 0) return []
const historyLength = Math.min(
50,
...heldCoins.map(c => c.coin.priceHistory?.length || 0)
)
if (historyLength === 0) return []
return Array.from({ length: historyLength }).map((_, i) => {
// we look from the end backwards, or just align to the end
// i=0 is the oldest point we are considering (e.g. 50 points ago)
let valueInSol = 0
heldCoins.forEach(item => {
const history = item.coin.priceHistory || []
// get the point relative to the end
// if i=0 (oldest), we want index = len - historyLength
// if i=49 (newest), we want index = len - 1
const index = history.length - historyLength + i
if (index >= 0 && index < history.length) {
valueInSol += item.amount * history[index].price
}
})
return {
index: i,
value: valueInSol,
timestamp: heldCoins[0].coin.priceHistory?.[heldCoins[0].coin.priceHistory.length - historyLength + i]?.timestamp
}
})
}, [userData, coins])
const createdCoins = useMemo(() => {
if (!coins || !session?.user?.id) return []
return coins.filter(c => c.creatorId === session.user.id)
}, [coins, session])
if (status === 'loading') {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="mx-auto max-w-7xl px-4 py-8">
<div className="h-64 mb-8 animate-pulse rounded-2xl bg-card/50" />
<div className="grid gap-6 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-32 animate-pulse rounded-2xl bg-card/50" />
))}
</div>
</div>
</div>
)
}
if (!session) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="flex flex-col items-center justify-center py-32 text-center">
<LgLock className="mb-4 h-16 w-16 opacity-50" />
<h1 className="mb-2 text-2xl font-bold">Sign In Required</h1>
<p className="mb-6 text-muted-foreground">
Connect your Discord account to view your dashboard
</p>
<Button
onClick={() => signIn('discord')}
className="gap-2 bg-[#5865F2] hover:bg-[#4752C4]"
>
<LgLock className="h-4 w-4" />
Sign in with Discord
</Button>
</div>
</div>
)
}
const portfolio = userData?.portfolio || []
const portfolioWithCoins = portfolio.map((item) => {
const coin = coins?.find((c) => c._id === item.coinId)
return { ...item, coin }
}).filter((item) => item.coin)
const totalPnL = portfolioWithCoins.reduce((acc, item) => {
if (!item.coin) return acc
const currentValue = item.amount * item.coin.price
const costBasis = item.amount * item.avgBuyPrice
return acc + (currentValue - costBasis)
}, 0)
const formatNumber = (num: number) => {
if (num >= 1000000) return `${(num / 1000000).toFixed(2)}M`
if (num >= 1000) return `${(num / 1000).toFixed(2)}K`
return num.toFixed(2)
}
return (
<div className="min-h-screen bg-background pb-20">
<Header />
<main className="mx-auto max-w-7xl px-4 py-8">
{/* Welcome Section */}
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Welcome back, {session.user?.name}</h1>
<p className="text-muted-foreground">Here's your portfolio overview</p>
</div>
<Button asChild>
<Link href="/create" className="gap-2">
<LgPlus className="h-5 w-5" />
Create Coin
</Link>
</Button>
</div>
{/* Portfolio History Chart */}
<div className="mb-8 rounded-2xl border border-border bg-card p-6">
<h3 className="mb-6 font-semibold flex items-center gap-2">
<LgTrendingUp className="h-5 w-5" />
Portfolio Performance
</h3>
<div className="h-[300px] w-full">
{portfolioHistory.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={portfolioHistory}>
<defs>
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="oklch(0.75 0.18 160)" stopOpacity={0.3}/>
<stop offset="95%" stopColor="oklch(0.75 0.18 160)" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="oklch(0.9 0 0 / 0.1)" />
<XAxis
dataKey="index"
hide
/>
<YAxis
orientation="right"
tickFormatter={(v) => `${v.toFixed(2)}`}
stroke="oklch(0.6 0 0)"
fontSize={12}
/>
<Tooltip
contentStyle={{ backgroundColor: 'oklch(0.2 0 0)', borderColor: 'oklch(0.3 0 0)' }}
labelStyle={{ display: 'none' }}
formatter={(value: number) => [`${value.toFixed(4)} SOL`, 'Value']}
/>
<Area
type="monotone"
dataKey="value"
stroke="oklch(0.75 0.18 160)"
fillOpacity={1}
fill="url(#colorValue)"
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
No history data available
</div>
)}
</div>
</div>
{/* Stats Grid */}
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="relative overflow-hidden rounded-2xl border border-border bg-card p-6">
<div className="absolute -right-4 -top-4 h-24 w-24 rounded-full bg-primary/10 blur-2xl" />
<div className="relative">
<div className="mb-2 flex items-center gap-2">
<LgBriefcase className="h-5 w-5" />
<span className="text-sm text-muted-foreground">Total Value</span>
</div>
<p className="font-mono text-3xl font-bold flex items-center gap-2">
{userData?.totalValue?.toFixed(4) ?? '0'} <span className="text-lg text-muted-foreground flex items-center gap-1"><img src="/solana.svg" className="w-5 h-5" alt="SOL" /></span>
</p>
{solPrice && (
<p className="mt-1 text-sm text-muted-foreground">
${((userData?.totalValue ?? 0) * solPrice.price).toFixed(2)} USD
</p>
)}
</div>
</div>
<div className="relative overflow-hidden rounded-2xl border border-border bg-card p-6">
<div className="absolute -right-4 -top-4 h-24 w-24 rounded-full bg-accent/10 blur-2xl" />
<div className="relative">
<div className="mb-2 flex items-center gap-2">
<LgFolderInvoices className="h-5 w-5" />
<span className="text-sm text-muted-foreground">Cash Balance</span>
</div>
<p className="font-mono text-3xl font-bold flex items-center gap-2">
{userData?.balance?.toFixed(4) ?? '0'} <span className="text-lg text-muted-foreground flex items-center gap-1"><img src="/solana.svg" className="w-5 h-5" alt="SOL" /></span>
</p>
</div>
</div>
<div className="relative overflow-hidden rounded-2xl border border-border bg-card p-6">
<div className="absolute -right-4 -top-4 h-24 w-24 rounded-full bg-chart-3/10 blur-2xl" />
<div className="relative">
<div className="mb-2 flex items-center gap-2">
<LgComboChart className="h-5 w-5" />
<span className="text-sm text-muted-foreground">Portfolio Value</span>
</div>
<p className="font-mono text-3xl font-bold flex items-center gap-2">
{userData?.portfolioValue?.toFixed(4) ?? '0'} <span className="text-lg text-muted-foreground flex items-center gap-1"><img src="/solana.svg" className="w-5 h-5" alt="SOL" /></span>
</p>
</div>
</div>
<div className="relative overflow-hidden rounded-2xl border border-border bg-card p-6">
<div className={`absolute -right-4 -top-4 h-24 w-24 rounded-full blur-2xl ${totalPnL >= 0 ? 'bg-primary/10' : 'bg-destructive/10'}`} />
<div className="relative">
<div className="mb-2 flex items-center gap-2">
<LgTrendingUp className="h-5 w-5" />
<span className="text-sm text-muted-foreground">Total P&L</span>
</div>
<p className={`font-mono text-3xl font-bold flex items-center gap-2 ${totalPnL >= 0 ? 'text-primary' : 'text-destructive'}`}>
{totalPnL >= 0 ? '+' : ''}{totalPnL.toFixed(4)} <span className="text-lg opacity-50 flex items-center gap-1"><img src="/solana.svg" className="w-5 h-5" alt="SOL" /></span>
</p>
</div>
</div>
</div>
<Tabs defaultValue="holdings" className="w-full">
<TabsList className="mb-4 bg-muted/50 p-1">
<TabsTrigger value="holdings" className="gap-2 px-4">
<LgBriefcase className="h-4 w-4" />
Holdings
</TabsTrigger>
<TabsTrigger value="created" className="gap-2 px-4">
<LgGroups className="h-4 w-4" />
Coins Created
</TabsTrigger>
</TabsList>
<TabsContent value="holdings">
<div className="rounded-2xl border border-border bg-card">
<div className="p-6 border-b border-border">
<h2 className="text-xl font-semibold">Your Holdings</h2>
</div>
{portfolioWithCoins.length > 0 ? (
<div className="divide-y divide-border/50">
{portfolioWithCoins.map((item) => {
const coin = item.coin!
const currentValue = item.amount * coin.price
const costBasis = item.amount * item.avgBuyPrice
const pnl = currentValue - costBasis
const pnlPercent = costBasis > 0 ? (pnl / costBasis) * 100 : 0
const chartData = coin.priceHistory?.slice(-12).map((point, index) => ({
value: point.price,
index,
})) || []
return (
<Link
key={item.coinId}
href={`/coin/${item.coinId}`}
className="flex items-center gap-4 p-6 transition-colors hover:bg-muted/30"
>
<img
src={coin.image || "/placeholder.svg"}
alt={coin.name}
className="h-12 w-12 rounded-xl bg-muted object-cover"
/>
<div className="flex-1 min-w-0">
<p className="font-semibold truncate">{coin.name}</p>
<p className="text-sm text-muted-foreground">${coin.ticker}</p>
</div>
<div className="hidden h-12 w-24 md:block">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<Area
type="monotone"
dataKey="value"
stroke={pnl >= 0 ? 'oklch(0.75 0.18 160)' : 'oklch(0.55 0.22 25)'}
strokeWidth={1.5}
fill="transparent"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<div className="text-right">
<p className="font-mono font-medium">{formatNumber(item.amount)}</p>
<p className="text-sm text-muted-foreground">{coin.ticker}</p>
</div>
<div className="w-28 text-right">
<p className="font-mono font-medium">{currentValue.toFixed(4)}</p>
<p className={`text-sm ${pnl >= 0 ? 'text-primary' : 'text-destructive'}`}>
{pnl >= 0 ? '+' : ''}{pnlPercent.toFixed(2)}%
</p>
</div>
</Link>
)
})}
</div>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<LgOpenedFolder className="mb-4 h-16 w-16 opacity-50" />
<h3 className="mb-2 text-lg font-medium">No holdings yet</h3>
<p className="mb-6 text-muted-foreground">
Start trading to build your portfolio
</p>
<Button asChild>
<Link href="/">Explore Coins</Link>
</Button>
</div>
)}
</div>
</TabsContent>
<TabsContent value="created">
{createdCoins.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{createdCoins.map((coin) => (
<CoinCard key={coin._id} coin={coin} />
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center rounded-2xl border border-border bg-card">
<LgPlus className="mb-4 h-16 w-16 opacity-50" />
<h3 className="mb-2 text-lg font-medium">No coins created</h3>
<p className="mb-6 text-muted-foreground">
You haven't created any coins yet.
</p>
<Button asChild>
<Link href="/create">Create Your First Coin</Link>
</Button>
</div>
)}
</TabsContent>
</Tabs>
</main>
</div>
)
}