245 lines
9.3 KiB
TypeScript
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 })
|
|
}
|