This repository has been archived on 2025-08-19. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
coinstorge/website/src/routes/transactions/+page.svelte

472 lines
13 KiB
Svelte
Raw Normal View History

2025-05-31 13:38:17 +03:00
<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>