feat: market tab

This commit is contained in:
Face 2025-05-24 17:50:42 +03:00
parent 35237c3470
commit 800b5d1a09
17 changed files with 1115 additions and 3 deletions

View file

@ -0,0 +1,168 @@
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { coin, user } from '$lib/server/db/schema';
import { eq, desc, asc, like, ilike, and, gte, lte, between, or, count } from 'drizzle-orm';
const VALID_SORT_FIELDS = ['marketCap', 'currentPrice', 'change24h', 'volume24h', 'createdAt'];
const VALID_SORT_ORDERS = ['asc', 'desc'];
const PRICE_FILTERS = {
ALL: 'all',
UNDER_1: 'under1',
BETWEEN_1_TO_10: '1to10',
BETWEEN_10_TO_100: '10to100',
OVER_100: 'over100'
} as const;
const CHANGE_FILTERS = {
ALL: 'all',
GAINERS: 'gainers',
LOSERS: 'losers',
HOT: 'hot',
WILD: 'wild'
} as const;
const PRICE_RANGES = {
ONE: '1.00000000',
TEN: '10.00000000',
HUNDRED: '100.00000000'
} as const;
const CHANGE_THRESHOLDS = {
ZERO: '0.0000',
MODERATE: '10.0000',
MODERATE_NEGATIVE: '-10.0000',
EXTREME: '50.0000',
EXTREME_NEGATIVE: '-50.0000'
} as const;
export async function GET({ url }) {
const searchQuery = url.searchParams.get('search') || '';
const sortBy = url.searchParams.get('sortBy') || 'marketCap';
const sortOrder = url.searchParams.get('sortOrder') || 'desc';
const priceFilter = url.searchParams.get('priceFilter') || 'all';
const changeFilter = url.searchParams.get('changeFilter') || 'all';
const page = parseInt(url.searchParams.get('page') || '1');
const limit = parseInt(url.searchParams.get('limit') || '12');
if (!VALID_SORT_FIELDS.includes(sortBy) || !VALID_SORT_ORDERS.includes(sortOrder)) {
return json({ error: 'Invalid sort parameters' }, { status: 400 });
} if (page < 1 || limit < 1 || limit > 100) {
return json({ error: 'Invalid pagination parameters' }, { status: 400 });
}
if (!Object.values(PRICE_FILTERS).includes(priceFilter as any) || !Object.values(CHANGE_FILTERS).includes(changeFilter as any)) {
return json({ error: 'Invalid filter parameters' }, { status: 400 });
}
try {
const conditions = [eq(coin.isListed, true)];
if (searchQuery) {
conditions.push(
or(
ilike(coin.name, `%${searchQuery}%`),
ilike(coin.symbol, `%${searchQuery}%`)
)!
);
}
switch (priceFilter) {
case PRICE_FILTERS.UNDER_1:
conditions.push(lte(coin.currentPrice, PRICE_RANGES.ONE));
break;
case PRICE_FILTERS.BETWEEN_1_TO_10:
conditions.push(between(coin.currentPrice, PRICE_RANGES.ONE, PRICE_RANGES.TEN));
break;
case PRICE_FILTERS.BETWEEN_10_TO_100:
conditions.push(between(coin.currentPrice, PRICE_RANGES.TEN, PRICE_RANGES.HUNDRED));
break;
case PRICE_FILTERS.OVER_100:
conditions.push(gte(coin.currentPrice, PRICE_RANGES.HUNDRED));
break;
}
switch (changeFilter) {
case CHANGE_FILTERS.GAINERS:
conditions.push(gte(coin.change24h, CHANGE_THRESHOLDS.ZERO));
break;
case CHANGE_FILTERS.LOSERS:
conditions.push(lte(coin.change24h, CHANGE_THRESHOLDS.ZERO));
break;
case CHANGE_FILTERS.HOT:
conditions.push(
or(
gte(coin.change24h, CHANGE_THRESHOLDS.MODERATE),
lte(coin.change24h, CHANGE_THRESHOLDS.MODERATE_NEGATIVE)
)!
);
break;
case CHANGE_FILTERS.WILD:
conditions.push(
or(
gte(coin.change24h, CHANGE_THRESHOLDS.EXTREME),
lte(coin.change24h, CHANGE_THRESHOLDS.EXTREME_NEGATIVE)
)!
);
break;
}
const whereCondition = and(...conditions);
const orderFn = sortOrder === 'asc' ? asc : desc;
const sortColumn = (() => {
switch (sortBy) {
case 'marketCap': return coin.marketCap;
case 'currentPrice': return coin.currentPrice;
case 'change24h': return coin.change24h;
case 'volume24h': return coin.volume24h;
case 'createdAt': return coin.createdAt;
default: return coin.marketCap; // fallback
}
})();
const [[{ total }], coins] = await Promise.all([
db.select({ total: count() }).from(coin).where(whereCondition),
db.select({
symbol: coin.symbol,
name: coin.name,
icon: coin.icon,
currentPrice: coin.currentPrice,
marketCap: coin.marketCap,
volume24h: coin.volume24h,
change24h: coin.change24h,
createdAt: coin.createdAt,
creatorName: user.name
})
.from(coin)
.leftJoin(user, eq(coin.creatorId, user.id))
.where(whereCondition)
.orderBy(orderFn(sortColumn))
.limit(limit)
.offset((page - 1) * limit)
]);
const formattedCoins = coins.map(c => ({
symbol: c.symbol,
name: c.name,
icon: c.icon,
currentPrice: Number(c.currentPrice),
marketCap: Number(c.marketCap),
volume24h: Number(c.volume24h),
change24h: Number(c.change24h),
createdAt: c.createdAt,
creatorName: c.creatorName
}));
return json({
coins: formattedCoins,
total: total || 0,
page,
limit,
totalPages: Math.ceil((total || 0) / limit)
});
} catch (e) {
console.error('Error fetching market data:', e);
return json({ error: 'Failed to fetch market data' }, { status: 500 });
}
}

View file

@ -0,0 +1,15 @@
import type { PageServerLoad } from './$types';
import type { MarketFilters } from '$lib/types/market';
export const load: PageServerLoad = async ({ url }): Promise<{ filters: MarketFilters }> => {
return {
filters: {
searchQuery: url.searchParams.get('search') || '',
sortBy: url.searchParams.get('sortBy') || 'marketCap',
sortOrder: url.searchParams.get('sortOrder') || 'desc',
priceFilter: url.searchParams.get('priceFilter') || 'all',
changeFilter: url.searchParams.get('changeFilter') || 'all',
page: Math.max(1, parseInt(url.searchParams.get('page') || '1') || 1)
}
};
};

View file

@ -0,0 +1,568 @@
<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 DropdownMenu from '$lib/components/ui/dropdown-menu';
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 CoinIcon from '$lib/components/self/CoinIcon.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,
ChevronDown,
DollarSign,
TrendingUp,
ArrowUpDown
} from 'lucide-svelte'; import { formatPrice, formatMarketCap, debounce, formatRelativeTime } from '$lib/utils';
import { MediaQuery } from 'svelte/reactivity';
import type { CoinData, FilterOption, VolatilityBadge, MarketResponse } from '$lib/types/market';
let { data } = $props();
let coins = $state<CoinData[]>([]);
let totalCount = $state(0);
let loading = $state(true);
let searchQuery = $state(data.filters.searchQuery);
let sortBy = $state(data.filters.sortBy);
let sortOrder = $state(data.filters.sortOrder);
let priceFilter = $state(data.filters.priceFilter);
let changeFilter = $state(data.filters.changeFilter);
let showFilterPopover = $state(false);
let currentPage = $state(data.filters.page);
const isDesktop = new MediaQuery('(min-width: 768px)');
let perPage = $derived(isDesktop.current ? 12 : 9);
let siblingCount = $derived(isDesktop.current ? 1 : 0);
const priceFilterOptions: FilterOption[] = [
{ value: 'all', label: 'All prices' },
{ value: 'under1', label: 'Under $1' },
{ value: '1to10', label: '$1 - $10' },
{ value: '10to100', label: '$10 - $100' },
{ value: 'over100', label: 'Over $100' }
];
const changeFilterOptions: FilterOption[] = [
{ value: 'all', label: 'All changes' },
{ value: 'gainers', label: 'Gainers only' },
{ value: 'losers', label: 'Losers only' },
{ value: 'hot', label: 'Hot (±10%)' },
{ value: 'wild', label: 'Wild (±50%)' }
];
const sortOrderOptions: FilterOption[] = [
{ value: 'desc', label: 'High to Low' },
{ value: 'asc', label: 'Low to High' }
];
const debouncedSearch = debounce(performSearch, 300);
let previousSearchQueryForEffect = $state(data.filters.searchQuery);
onMount(() => {
fetchMarketData();
});
function updateURL() {
const url = new URL($page.url);
if (searchQuery) {
url.searchParams.set('search', searchQuery);
} else {
url.searchParams.delete('search');
}
if (sortBy !== 'marketCap') {
url.searchParams.set('sortBy', sortBy);
} else {
url.searchParams.delete('sortBy');
}
if (sortOrder !== 'desc') {
url.searchParams.set('sortOrder', sortOrder);
} else {
url.searchParams.delete('sortOrder');
}
if (priceFilter !== 'all') {
url.searchParams.set('priceFilter', priceFilter);
} else {
url.searchParams.delete('priceFilter');
}
if (changeFilter !== 'all') {
url.searchParams.set('changeFilter', changeFilter);
} else {
url.searchParams.delete('changeFilter');
}
if (currentPage !== 1) {
url.searchParams.set('page', currentPage.toString());
} else {
url.searchParams.delete('page');
}
goto(url.toString(), { noScroll: true, replaceState: true });
}
async function fetchMarketData() {
loading = true;
try {
const params = new URLSearchParams({
search: searchQuery,
sortBy,
sortOrder,
priceFilter,
changeFilter,
page: currentPage.toString(),
limit: perPage.toString()
});
const response = await fetch(`/api/market?${params}`);
if (response.ok) {
const result: MarketResponse = await response.json();
coins = result.coins;
totalCount = result.total;
} else {
toast.error('Failed to load market data');
}
} catch (e) {
console.error('Failed to fetch market data:', e);
toast.error('Failed to load market data');
} finally {
loading = false;
}
}
function performSearch() {
currentPage = 1;
updateURL();
fetchMarketData();
}
$effect(() => {
if (searchQuery !== previousSearchQueryForEffect) {
debouncedSearch();
previousSearchQueryForEffect = searchQuery;
}
});
function handleSortChange(newSortBy: string) {
if (sortBy === newSortBy) {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
} else {
sortBy = newSortBy;
sortOrder = 'desc';
}
currentPage = 1;
updateURL();
fetchMarketData();
}
function handleSortOrderChange(newSortOrder: string) {
sortOrder = newSortOrder;
currentPage = 1;
updateURL();
fetchMarketData();
}
function resetFilters() {
searchQuery = '';
sortBy = 'marketCap';
sortOrder = 'desc';
priceFilter = 'all';
changeFilter = 'all';
currentPage = 1;
goto('/market', { noScroll: true, replaceState: true });
fetchMarketData();
showFilterPopover = false;
}
function applyFilters() {
currentPage = 1;
updateURL();
fetchMarketData();
showFilterPopover = false;
}
function getVolatilityBadge(change: number): VolatilityBadge | null {
const absChange = Math.abs(change);
if (absChange > 50) return { text: '🚀 WILD', variant: 'default' as const };
if (absChange > 20) return { text: '📈 HOT', variant: 'secondary' as const };
if (absChange > 10) return { text: '⚡ ACTIVE', variant: 'outline' as const };
return null;
}
function handlePriceFilterChange(value: string) {
priceFilter = value;
currentPage = 1;
updateURL();
fetchMarketData();
}
function handleChangeFilterChange(value: string) {
changeFilter = value;
currentPage = 1;
updateURL();
fetchMarketData();
}
let hasActiveFilters = $derived(
searchQuery !== '' ||
priceFilter !== 'all' ||
changeFilter !== 'all' ||
sortBy !== 'marketCap' ||
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();
fetchMarketData();
}
let currentPriceFilterLabel = $derived(
priceFilterOptions.find((option) => option.value === priceFilter)?.label || 'All prices'
);
let currentChangeFilterLabel = $derived(
changeFilterOptions.find((option) => option.value === changeFilter)?.label || 'All changes'
);
let currentSortOrderLabel = $derived(
sortOrderOptions.find((option) => option.value === sortOrder)?.label || 'High to Low'
);
</script>
<svelte:head>
<title>Market - Rugplay</title>
<meta
name="description"
content="Discover and trade cryptocurrencies. Search, filter, and sort through all available coins."
/>
</svelte:head>
<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">Market</h1>
<p class="text-muted-foreground mb-6">
Discover coins, track performance, and find your next investment
</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 coins by 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 === 'marketCap' ? 'default' : 'outline'}
size="sm"
onclick={() => handleSortChange('marketCap')}
>
Market Cap
</Button>
<Button
variant={sortBy === 'currentPrice' ? 'default' : 'outline'}
size="sm"
onclick={() => handleSortChange('currentPrice')}
>
Price
</Button>
<Button
variant={sortBy === 'change24h' ? 'default' : 'outline'}
size="sm"
onclick={() => handleSortChange('change24h')}
>
24h Change
</Button>
<Button
variant={sortBy === 'volume24h' ? 'default' : 'outline'}
size="sm"
onclick={() => handleSortChange('volume24h')}
>
Volume
</Button>
</div>
</div>
<div class="space-y-2">
<Label class="text-sm font-medium">Sort Order</Label>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-9 w-full items-center justify-between rounded-md border px-3 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<div class="flex items-center gap-2">
<ArrowUpDown class="h-4 w-4" />
<span>{currentSortOrderLabel}</span>
</div>
<ChevronDown class="h-4 w-4 opacity-50" />
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-56">
{#each sortOrderOptions as option}
<DropdownMenu.Item
onclick={() => handleSortOrderChange(option.value)}
class="cursor-pointer"
>
<ArrowUpDown class="h-4 w-4" />
<span>{option.label}</span>
{#if sortOrder === option.value}
<div class="bg-primary ml-auto h-2 w-2 rounded-full"></div>
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
<div class="space-y-2">
<Label class="text-sm font-medium">Price Range</Label>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-9 w-full items-center justify-between rounded-md border px-3 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<div class="flex items-center gap-2">
<DollarSign class="h-4 w-4" />
<span>{currentPriceFilterLabel}</span>
</div>
<ChevronDown class="h-4 w-4 opacity-50" />
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-56">
{#each priceFilterOptions as option}
<DropdownMenu.Item
onclick={() => handlePriceFilterChange(option.value)}
class="cursor-pointer"
>
<DollarSign class="h-4 w-4" />
<span>{option.label}</span>
{#if priceFilter === option.value}
<div class="bg-primary ml-auto h-2 w-2 rounded-full"></div>
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
<div class="space-y-2">
<Label class="text-sm font-medium">24h Change</Label>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-9 w-full items-center justify-between rounded-md border px-3 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<div class="flex items-center gap-2">
<TrendingUp class="h-4 w-4" />
<span>{currentChangeFilterLabel}</span>
</div>
<ChevronDown class="h-4 w-4 opacity-50" />
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-56">
{#each changeFilterOptions as option}
<DropdownMenu.Item
onclick={() => handleChangeFilterChange(option.value)}
class="cursor-pointer"
>
<TrendingUp class="h-4 w-4" />
<span>{option.label}</span>
{#if changeFilter === option.value}
<div class="bg-primary ml-auto h-2 w-2 rounded-full"></div>
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.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={fetchMarketData} 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} coins
</div>
{#if hasActiveFilters}
<Button variant="link" size="sm" onclick={resetFilters} class="h-auto p-0">
Clear all filters
</Button>
{/if}
</div>
{/if}
<!-- Market Grid -->
{#if loading}
<div class="flex h-96 items-center justify-center">
<div class="text-center">
<div class="mb-4 text-xl">Loading market data...</div>
<div class="text-muted-foreground">Fetching the latest coin prices and chaos levels</div>
</div>
</div>
{:else if coins.length === 0}
<div class="flex h-96 items-center justify-center">
<div class="text-center">
<div class="mb-4 text-xl">No coins found</div>
<div class="text-muted-foreground mb-4">
{#if searchQuery}
No coins match your search "{searchQuery}". Try different keywords or adjust filters.
{:else}
The market seems quiet... <a href="/coin/create" class="text-primary underline"
>create a coin</a
>? :)
{/if}
</div>
{#if hasActiveFilters}
<Button variant="outline" onclick={resetFilters}>Clear all filters</Button>
{/if}
</div>
</div>
{:else}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each coins as coin, index}
{@const volatilityBadge = getVolatilityBadge(coin.change24h)}
{@const globalIndex = (currentPage - 1) * perPage + index + 1}
<Card.Root
class="group cursor-pointer gap-1 transition-all duration-200 hover:shadow-lg"
onclick={() => goto(`/coin/${coin.symbol}`)}
>
<Card.Header>
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<CoinIcon icon={coin.icon} symbol={coin.symbol} size={8} />
<div>
<h3 class="text-lg font-semibold leading-tight">{coin.name}</h3>
<p class="text-muted-foreground text-sm">*{coin.symbol}</p>
</div>
</div>
<div class="text-right">
<span class="text-muted-foreground font-mono text-xs">#{globalIndex}</span>
</div>
</div>
</Card.Header>
<Card.Content>
<div class="space-y-3">
<!-- Price -->
<div>
<div class="font-mono text-2xl font-bold">
${formatPrice(coin.currentPrice)}
</div>
<div class="mt-1 flex items-center gap-2">
<Badge variant={coin.change24h >= 0 ? 'success' : 'destructive'} class="text-xs">
{coin.change24h >= 0 ? '+' : ''}{coin.change24h.toFixed(2)}%
</Badge>
{#if volatilityBadge}
<Badge variant={volatilityBadge.variant} class="text-xs">
{volatilityBadge.text}
</Badge>
{/if}
</div>
</div>
<!-- Stats -->
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Market Cap</span>
<span class="font-mono">{formatMarketCap(coin.marketCap)}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Volume (24h)</span>
<span class="font-mono">{formatMarketCap(coin.volume24h)}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Created</span>
<span class="text-xs">{formatRelativeTime(coin.createdAt)}</span>
</div>
</div>
</div>
</Card.Content>
</Card.Root>
{/each}
</div>
<!-- Pagination -->
{#if 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}
{/if}
</div>