feat: transactions page

This commit is contained in:
Face 2025-05-31 13:38:17 +03:00
parent 2c98047ec0
commit c1bab8ac4a
2 changed files with 543 additions and 25 deletions

View file

@ -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
}
}))
}); });
} }

View 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>