feat: transactions page
This commit is contained in:
parent
2c98047ec0
commit
c1bab8ac4a
2 changed files with 543 additions and 25 deletions
|
|
@ -2,18 +2,63 @@ import { auth } from '$lib/auth';
|
||||||
import { error, json } from '@sveltejs/kit';
|
import { error, json } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { transaction, coin } from '$lib/server/db/schema';
|
import { transaction, coin } from '$lib/server/db/schema';
|
||||||
import { eq, desc } from 'drizzle-orm';
|
import { eq, desc, asc, and, or, ilike, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
export async function GET({ request }) {
|
export async function GET({ request, url }) {
|
||||||
const session = await auth.api.getSession({
|
const authSession = await auth.api.getSession({
|
||||||
headers: request.headers
|
headers: request.headers
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!authSession?.user) {
|
||||||
throw error(401, 'Not authenticated');
|
throw error(401, 'Not authenticated');
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = Number(session.user.id);
|
const userId = Number(authSession.user.id);
|
||||||
|
const searchQuery = url.searchParams.get('search') || '';
|
||||||
|
const typeFilter = url.searchParams.get('type') || 'all';
|
||||||
|
const sortBy = url.searchParams.get('sortBy') || 'timestamp';
|
||||||
|
const sortOrder = url.searchParams.get('sortOrder') || 'desc';
|
||||||
|
const page = parseInt(url.searchParams.get('page') || '1');
|
||||||
|
const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 50);
|
||||||
|
|
||||||
|
let whereConditions = and(eq(transaction.userId, userId));
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
whereConditions = and(
|
||||||
|
whereConditions,
|
||||||
|
or(
|
||||||
|
ilike(coin.name, `%${searchQuery}%`),
|
||||||
|
ilike(coin.symbol, `%${searchQuery}%`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeFilter !== 'all') {
|
||||||
|
whereConditions = and(whereConditions, eq(transaction.type, typeFilter as 'BUY' | 'SELL'));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortColumn;
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'totalBaseCurrencyAmount':
|
||||||
|
sortColumn = transaction.totalBaseCurrencyAmount;
|
||||||
|
break;
|
||||||
|
case 'quantity':
|
||||||
|
sortColumn = transaction.quantity;
|
||||||
|
break;
|
||||||
|
case 'pricePerCoin':
|
||||||
|
sortColumn = transaction.pricePerCoin;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
sortColumn = transaction.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderBy = sortOrder === 'asc' ? asc(sortColumn) : desc(sortColumn);
|
||||||
|
|
||||||
|
const [{ count }] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(transaction)
|
||||||
|
.leftJoin(coin, eq(transaction.coinId, coin.id))
|
||||||
|
.where(whereConditions);
|
||||||
|
|
||||||
const transactions = await db
|
const transactions = await db
|
||||||
.select({
|
.select({
|
||||||
|
|
@ -23,29 +68,31 @@ export async function GET({ request }) {
|
||||||
pricePerCoin: transaction.pricePerCoin,
|
pricePerCoin: transaction.pricePerCoin,
|
||||||
totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount,
|
totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount,
|
||||||
timestamp: transaction.timestamp,
|
timestamp: transaction.timestamp,
|
||||||
coinSymbol: coin.symbol,
|
coin: {
|
||||||
coinName: coin.name,
|
id: coin.id,
|
||||||
coinIcon: coin.icon
|
name: coin.name,
|
||||||
|
symbol: coin.symbol,
|
||||||
|
icon: coin.icon
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.from(transaction)
|
.from(transaction)
|
||||||
.innerJoin(coin, eq(transaction.coinId, coin.id))
|
.leftJoin(coin, eq(transaction.coinId, coin.id))
|
||||||
.where(eq(transaction.userId, userId))
|
.where(whereConditions)
|
||||||
.orderBy(desc(transaction.timestamp))
|
.orderBy(orderBy)
|
||||||
.limit(100);
|
.limit(limit)
|
||||||
|
.offset((page - 1) * limit);
|
||||||
|
|
||||||
|
const formattedTransactions = transactions.map(tx => ({
|
||||||
|
...tx,
|
||||||
|
quantity: Number(tx.quantity),
|
||||||
|
pricePerCoin: Number(tx.pricePerCoin),
|
||||||
|
totalBaseCurrencyAmount: Number(tx.totalBaseCurrencyAmount)
|
||||||
|
}));
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
transactions: transactions.map(t => ({
|
transactions: formattedTransactions,
|
||||||
id: t.id,
|
total: count,
|
||||||
type: t.type,
|
page,
|
||||||
quantity: Number(t.quantity),
|
limit
|
||||||
pricePerCoin: Number(t.pricePerCoin),
|
|
||||||
totalBaseCurrencyAmount: Number(t.totalBaseCurrencyAmount),
|
|
||||||
timestamp: t.timestamp,
|
|
||||||
coin: {
|
|
||||||
symbol: t.coinSymbol,
|
|
||||||
name: t.coinName,
|
|
||||||
icon: t.coinIcon
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
471
website/src/routes/transactions/+page.svelte
Normal file
471
website/src/routes/transactions/+page.svelte
Normal file
|
|
@ -0,0 +1,471 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
|
import * as Pagination from '$lib/components/ui/pagination';
|
||||||
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import DataTable from '$lib/components/self/DataTable.svelte';
|
||||||
|
import SEO from '$lib/components/self/SEO.svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
SlidersHorizontal,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Receipt
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import { formatPrice, formatValue, formatQuantity, formatDate, debounce } from '$lib/utils';
|
||||||
|
import { MediaQuery } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
let transactions = $state<any[]>([]);
|
||||||
|
let totalCount = $state(0);
|
||||||
|
let loading = $state(true);
|
||||||
|
let searchQuery = $state($page.url.searchParams.get('search') || '');
|
||||||
|
let typeFilter = $state($page.url.searchParams.get('type') || 'all');
|
||||||
|
let sortBy = $state($page.url.searchParams.get('sortBy') || 'timestamp');
|
||||||
|
let sortOrder = $state($page.url.searchParams.get('sortOrder') || 'desc');
|
||||||
|
let showFilterPopover = $state(false);
|
||||||
|
let currentPage = $state(parseInt($page.url.searchParams.get('page') || '1'));
|
||||||
|
|
||||||
|
const isDesktop = new MediaQuery('(min-width: 768px)');
|
||||||
|
let perPage = $derived(isDesktop.current ? 20 : 15);
|
||||||
|
let siblingCount = $derived(isDesktop.current ? 1 : 0);
|
||||||
|
|
||||||
|
const typeFilterOptions = [
|
||||||
|
{ value: 'all', label: 'All transactions' },
|
||||||
|
{ value: 'BUY', label: 'Buys only' },
|
||||||
|
{ value: 'SELL', label: 'Sells only' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const sortOrderOptions = [
|
||||||
|
{ value: 'desc', label: 'Newest first' },
|
||||||
|
{ value: 'asc', label: 'Oldest first' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const debouncedSearch = debounce(performSearch, 300);
|
||||||
|
let previousSearchQueryForEffect = $state(searchQuery);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetchTransactions();
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateURL() {
|
||||||
|
const url = new URL($page.url);
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
url.searchParams.set('search', searchQuery);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('search');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeFilter !== 'all') {
|
||||||
|
url.searchParams.set('type', typeFilter);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy !== 'timestamp') {
|
||||||
|
url.searchParams.set('sortBy', sortBy);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('sortBy');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortOrder !== 'desc') {
|
||||||
|
url.searchParams.set('sortOrder', sortOrder);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('sortOrder');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage !== 1) {
|
||||||
|
url.searchParams.set('page', currentPage.toString());
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('page');
|
||||||
|
}
|
||||||
|
|
||||||
|
goto(url.toString(), { noScroll: true, replaceState: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTransactions() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
search: searchQuery,
|
||||||
|
type: typeFilter,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
page: currentPage.toString(),
|
||||||
|
limit: perPage.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/transactions?${params}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
transactions = result.transactions;
|
||||||
|
totalCount = result.total;
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to load transactions');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch transactions:', e);
|
||||||
|
toast.error('Failed to load transactions');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function performSearch() {
|
||||||
|
currentPage = 1;
|
||||||
|
updateURL();
|
||||||
|
fetchTransactions();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (searchQuery !== previousSearchQueryForEffect) {
|
||||||
|
debouncedSearch();
|
||||||
|
previousSearchQueryForEffect = searchQuery;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSortChange(newSortBy: string) {
|
||||||
|
if (sortBy === newSortBy) {
|
||||||
|
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
sortBy = newSortBy;
|
||||||
|
sortOrder = newSortBy === 'timestamp' ? 'desc' : 'desc';
|
||||||
|
}
|
||||||
|
currentPage = 1;
|
||||||
|
updateURL();
|
||||||
|
fetchTransactions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTypeFilterChange() {
|
||||||
|
currentPage = 1;
|
||||||
|
updateURL();
|
||||||
|
fetchTransactions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSortOrderChange() {
|
||||||
|
currentPage = 1;
|
||||||
|
updateURL();
|
||||||
|
fetchTransactions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
searchQuery = '';
|
||||||
|
typeFilter = 'all';
|
||||||
|
sortBy = 'timestamp';
|
||||||
|
sortOrder = 'desc';
|
||||||
|
currentPage = 1;
|
||||||
|
|
||||||
|
goto('/transactions', { noScroll: true, replaceState: true });
|
||||||
|
fetchTransactions();
|
||||||
|
showFilterPopover = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
currentPage = 1;
|
||||||
|
updateURL();
|
||||||
|
fetchTransactions();
|
||||||
|
showFilterPopover = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasActiveFilters = $derived(
|
||||||
|
searchQuery !== '' || typeFilter !== 'all' || sortBy !== 'timestamp' || sortOrder !== 'desc'
|
||||||
|
);
|
||||||
|
|
||||||
|
let totalPages = $derived(Math.ceil(totalCount / perPage));
|
||||||
|
let startIndex = $derived((currentPage - 1) * perPage + 1);
|
||||||
|
let endIndex = $derived(Math.min(currentPage * perPage, totalCount));
|
||||||
|
|
||||||
|
function handlePageChange(page: number) {
|
||||||
|
currentPage = page;
|
||||||
|
updateURL();
|
||||||
|
fetchTransactions();
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentTypeFilterLabel = $derived(
|
||||||
|
typeFilterOptions.find((option) => option.value === typeFilter)?.label || 'All transactions'
|
||||||
|
);
|
||||||
|
let currentSortOrderLabel = $derived(
|
||||||
|
sortOrderOptions.find((option) => option.value === sortOrder)?.label || 'Newest first'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Column configurations for transactions table
|
||||||
|
let transactionsColumns = $derived([
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
label: 'Type',
|
||||||
|
class: 'w-[10%] min-w-[60px]',
|
||||||
|
render: (value: any) => ({
|
||||||
|
component: 'badge',
|
||||||
|
variant: value === 'BUY' ? 'success' : 'destructive',
|
||||||
|
text: value === 'BUY' ? 'Buy' : 'Sell',
|
||||||
|
class: 'text-xs'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'coin',
|
||||||
|
label: 'Coin',
|
||||||
|
class: 'w-[20%] min-w-[120px]',
|
||||||
|
render: (value: any, row: any) => ({
|
||||||
|
component: 'coin',
|
||||||
|
icon: row.coin.icon,
|
||||||
|
symbol: row.coin.symbol,
|
||||||
|
name: `*${row.coin.symbol}`,
|
||||||
|
size: 6
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'quantity',
|
||||||
|
label: 'Quantity',
|
||||||
|
class: 'w-[15%] min-w-[100px] font-mono',
|
||||||
|
render: (value: any) => formatQuantity(value)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pricePerCoin',
|
||||||
|
label: 'Price',
|
||||||
|
class: 'w-[15%] min-w-[80px] font-mono',
|
||||||
|
render: (value: any) => `$${formatPrice(value)}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'totalBaseCurrencyAmount',
|
||||||
|
label: 'Total',
|
||||||
|
class: 'w-[15%] min-w-[80px] font-mono font-medium',
|
||||||
|
render: (value: any) => formatValue(value)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'timestamp',
|
||||||
|
label: 'Date',
|
||||||
|
class: 'w-[25%] min-w-[120px] text-muted-foreground',
|
||||||
|
render: (value: any) => formatDate(value)
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SEO
|
||||||
|
title="Transactions - Rugplay"
|
||||||
|
description="View your complete trading history and transaction records in the Rugplay cryptocurrency simulation game."
|
||||||
|
noindex={true}
|
||||||
|
keywords="trading history game, transaction records simulator, crypto trading log, virtual trading history"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="container mx-auto max-w-7xl p-6">
|
||||||
|
<header class="mb-8">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="mb-2 text-3xl font-bold">Transactions</h1>
|
||||||
|
<p class="text-muted-foreground mb-6">
|
||||||
|
Complete record of your trading activity and transactions
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mx-auto flex max-w-2xl items-center justify-center gap-2">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<Search class="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
bind:value={searchQuery}
|
||||||
|
placeholder="Search by coin name or symbol..."
|
||||||
|
class="pl-10 pr-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Popover.Root bind:open={showFilterPopover}>
|
||||||
|
<Popover.Trigger>
|
||||||
|
<Button variant="outline" size="default" class="flex items-center gap-2">
|
||||||
|
<SlidersHorizontal class="h-4 w-4" />
|
||||||
|
Filters
|
||||||
|
{#if hasActiveFilters}
|
||||||
|
<Badge variant="secondary" class="h-5 w-5 rounded-full p-0 text-xs">•</Badge>
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content class="w-80 p-4" align="end">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label class="text-sm font-medium">Sort By</Label>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
variant={sortBy === 'timestamp' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => handleSortChange('timestamp')}
|
||||||
|
>
|
||||||
|
Date
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={sortBy === 'totalBaseCurrencyAmount' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => handleSortChange('totalBaseCurrencyAmount')}
|
||||||
|
>
|
||||||
|
Amount
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={sortBy === 'quantity' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => handleSortChange('quantity')}
|
||||||
|
>
|
||||||
|
Quantity
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={sortBy === 'pricePerCoin' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => handleSortChange('pricePerCoin')}
|
||||||
|
>
|
||||||
|
Price
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label class="text-sm font-medium">Sort Order</Label>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
bind:value={sortOrder}
|
||||||
|
onValueChange={handleSortOrderChange}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-full">
|
||||||
|
{currentSortOrderLabel}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
<Select.Group>
|
||||||
|
{#each sortOrderOptions as option}
|
||||||
|
<Select.Item value={option.value} label={option.label}>
|
||||||
|
{option.label}
|
||||||
|
</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Group>
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label class="text-sm font-medium">Transaction Type</Label>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
bind:value={typeFilter}
|
||||||
|
onValueChange={handleTypeFilterChange}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-full">
|
||||||
|
{currentTypeFilterLabel}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
<Select.Group>
|
||||||
|
{#each typeFilterOptions as option}
|
||||||
|
<Select.Item value={option.value} label={option.label}>
|
||||||
|
{option.label}
|
||||||
|
</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Group>
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 pt-2">
|
||||||
|
<Button variant="outline" size="sm" onclick={resetFilters} class="flex-1">
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onclick={applyFilters} class="flex-1">Apply</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
|
||||||
|
<Button variant="outline" size="default" onclick={fetchTransactions} disabled={loading}>
|
||||||
|
<RefreshCw class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Pagination Info -->
|
||||||
|
{#if !loading && totalCount > 0}
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<div class="text-muted-foreground text-sm">
|
||||||
|
Showing {startIndex}-{endIndex} of {totalCount} transactions
|
||||||
|
</div>
|
||||||
|
{#if hasActiveFilters}
|
||||||
|
<Button variant="link" size="sm" onclick={resetFilters} class="h-auto p-0">
|
||||||
|
Clear all filters
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Transactions Table -->
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="flex items-center gap-2">
|
||||||
|
<Receipt class="h-5 w-5" />
|
||||||
|
History
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>Complete record of your trading activity</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if loading}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each Array(10) as _}
|
||||||
|
<div class="bg-muted h-16 animate-pulse rounded-lg"></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<DataTable
|
||||||
|
columns={transactionsColumns}
|
||||||
|
data={transactions}
|
||||||
|
onRowClick={(tx) => goto(`/coin/${tx.coin.symbol}`)}
|
||||||
|
emptyIcon={Receipt}
|
||||||
|
emptyTitle="No transactions found"
|
||||||
|
emptyDescription={hasActiveFilters
|
||||||
|
? 'No transactions match your current filters. Try adjusting your search criteria.'
|
||||||
|
: "You haven't made any trades yet. Start by buying or selling coins."}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{#if !loading && totalPages > 1}
|
||||||
|
<div class="mt-8 flex justify-center">
|
||||||
|
<Pagination.Root
|
||||||
|
count={totalCount}
|
||||||
|
{perPage}
|
||||||
|
{siblingCount}
|
||||||
|
page={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
>
|
||||||
|
{#snippet children({ pages, currentPage: paginationCurrentPage })}
|
||||||
|
<Pagination.Content>
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.PrevButton>
|
||||||
|
<ChevronLeft class="h-4 w-4" />
|
||||||
|
<span class="hidden sm:block">Previous</span>
|
||||||
|
</Pagination.PrevButton>
|
||||||
|
</Pagination.Item>
|
||||||
|
{#each pages as page (page.key)}
|
||||||
|
{#if page.type === 'ellipsis'}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.Ellipsis />
|
||||||
|
</Pagination.Item>
|
||||||
|
{:else}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.Link {page} isActive={paginationCurrentPage === page.value}>
|
||||||
|
{page.value}
|
||||||
|
</Pagination.Link>
|
||||||
|
</Pagination.Item>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.NextButton>
|
||||||
|
<span class="hidden sm:block">Next</span>
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</Pagination.NextButton>
|
||||||
|
</Pagination.Item>
|
||||||
|
</Pagination.Content>
|
||||||
|
{/snippet}
|
||||||
|
</Pagination.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Reference in a new issue