pummmp.fun/components/trade-history.tsx
2026-07-04 12:49:09 -07:00

245 lines
9.3 KiB
TypeScript

'use client'
import { useEffect, useState, useMemo } from 'react'
import useSWR from 'swr'
import { Trade } from '@/types'
import { Socket } from 'socket.io-client'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from '@/components/ui/button'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { LgFolderInvoices, LgExternalLink } from './icons'
import { UserBadges } from '@/components/ui/user-badges'
const fetcher = (url: string) => fetch(url).then((res) => res.json())
interface TradeHistoryProps {
coinId: string
ticker: string
socket?: Socket | null
limit?: number
}
function groupTrades(trades: Trade[]) {
if (!trades) return []
const grouped: any[] = []
trades.forEach((trade) => {
const lastGroup = grouped[grouped.length - 1]
const tradeDate = new Date(trade.timestamp)
const isSameMinute = lastGroup &&
new Date(lastGroup.timestamp).getMinutes() === tradeDate.getMinutes() &&
new Date(lastGroup.timestamp).getHours() === tradeDate.getHours() &&
new Date(lastGroup.timestamp).getDate() === tradeDate.getDate()
if (lastGroup && lastGroup.type === trade.type && isSameMinute) {
lastGroup.count += 1
lastGroup.amount += trade.amount
lastGroup.total += trade.total
if (!lastGroup.images.includes(trade.userImage)) {
lastGroup.images.push(trade.userImage)
}
if (lastGroup.username !== trade.username) {
lastGroup.isMultiUser = true
}
} else {
grouped.push({
...trade,
count: 1,
images: [trade.userImage],
isMultiUser: false
})
}
})
return grouped
}
export function TradeHistory({ coinId, ticker, socket, limit }: TradeHistoryProps) {
const { data: rawTrades, isLoading, mutate } = useSWR<Trade[]>(
`/api/trades?coinId=${coinId}`,
fetcher
)
const trades = useMemo(() => {
const grouped = groupTrades(rawTrades || [])
return limit ? grouped.slice(0, limit) : grouped
}, [rawTrades, limit])
useEffect(() => {
if (!socket) return
const handleNewTrade = (trade: any) => {
mutate((currentTrades) => {
if (!currentTrades) return [trade]
if (currentTrades.some(t => t._id === trade._id)) return currentTrades
return [trade, ...currentTrades]
}, false)
}
socket.on(`trade:${coinId}`, handleNewTrade)
return () => {
socket.off(`trade:${coinId}`, handleNewTrade)
}
}, [socket, coinId, mutate])
if (isLoading) {
return (
<div className="rounded-xl border border-border bg-card/30 p-8 flex justify-center items-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)
}
if (!trades || trades.length === 0) {
return (
<div className="rounded-xl border border-border bg-card/30 p-8 flex flex-col justify-center items-center text-muted-foreground gap-4">
<LgFolderInvoices className="h-12 w-12 opacity-20" />
<p>No trades have been made yet.</p>
</div>
)
}
return (
<div className="rounded-xl border border-border bg-card/30 overflow-hidden">
<div className="p-4 border-b border-border/50 bg-muted/20 flex items-center justify-between">
<h3 className="font-semibold text-sm flex items-center gap-2">
Recent Trades
<span className="px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs font-normal">
{trades.length}
</span>
</h3>
{trades.length > 5 && (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 text-xs gap-1">
View All <LgExternalLink className="h-3 w-3" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Trade History</DialogTitle>
<DialogDescription>
All recorded trades for ${ticker}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto min-h-0 mt-4 border rounded-md">
<TradeTable trades={trades} ticker={ticker} />
</div>
</DialogContent>
</Dialog>
)}
</div>
<div className="overflow-hidden">
<TradeTable trades={trades.slice(0, 5)} ticker={ticker} />
</div>
</div>
)
}
function TradeTable({ trades, ticker }: { trades: any[], ticker: string }) {
return (
<Table>
<TableHeader className="bg-muted/10 sticky top-0 backdrop-blur-sm z-10">
<TableRow className="hover:bg-transparent border-border/50">
<TableHead className="w-[180px]">User</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Total (SOL)</TableHead>
<TableHead className="text-right">{ticker}</TableHead>
<TableHead className="text-right w-[150px]">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{trades.map((trade) => (
<TableRow key={trade._id} className="hover:bg-muted/5 border-border/50">
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="flex -space-x-2 overflow-hidden">
{trade.images.slice(0, 3).map((img: string, i: number) => (
<Avatar key={i} className="h-6 w-6 border-2 border-background ring-2 ring-background">
<AvatarImage src={img} />
<AvatarFallback className="text-[8px] bg-muted">??</AvatarFallback>
</Avatar>
))}
{trade.images.length > 3 && (
<div className="flex h-6 w-6 items-center justify-center rounded-full border-2 border-background bg-muted text-[10px] font-medium text-muted-foreground ring-2 ring-background">
+{trade.images.length - 3}
</div>
)}
</div>
<div className="flex items-center gap-1 max-w-[120px]">
<span className="text-xs font-medium truncate" title={trade.isMultiUser ? 'Multiple Traders' : trade.username}>
{trade.isMultiUser ? 'Various' : trade.username}
</span>
{!trade.isMultiUser && (
<UserBadges
isAdmin={trade.userIsAdmin}
isVerified={trade.userVerified}
isBugHunter={trade.userIsBugHunter}
isBetaTester={trade.userIsBetaTester}
onlyShowHighest
/>
)}
</div>
</div>
</TableCell>
<TableCell>
<span className={`text-xs font-bold px-2 py-1 rounded capitalize flex items-center w-fit gap-1 ${
trade.type === 'buy'
? 'bg-green-500/10 text-green-500'
: trade.type === 'sell'
? 'bg-red-500/10 text-red-500'
: 'bg-purple-500/10 text-purple-500'
}`}>
{trade.type === 'mined' ? 'MINED' : trade.type}
{trade.count > 1 && (
<span className="text-[10px] opacity-70 border-l border-current/20 pl-1 ml-1">
x{trade.count}
</span>
)}
</span>
</TableCell>
<TableCell className="text-right font-mono text-xs">
{trade.total.toFixed(3)}
</TableCell>
<TableCell className="text-right font-mono text-xs">
{formatNumber(trade.amount)}
</TableCell>
<TableCell className="text-right text-muted-foreground text-xs">
{new Date(trade.timestamp).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function formatNumber(num: number) {
if (num >= 1000000) return `${(num / 1000000).toFixed(2)}M`
if (num >= 1000) return `${(num / 1000).toFixed(2)}K`
return num.toLocaleString(undefined, { maximumFractionDigits: 2 })
}