398 lines
17 KiB
TypeScript
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>
|
|
)
|
|
}
|