feat: market tab
This commit is contained in:
parent
35237c3470
commit
800b5d1a09
17 changed files with 1115 additions and 3 deletions
|
|
@ -23,6 +23,8 @@
|
|||
src={getPublicUrl(icon)}
|
||||
alt={name}
|
||||
class="{sizeClass} rounded-full object-cover {className}"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
|
|
|
|||
25
website/src/lib/components/ui/pagination/index.ts
Normal file
25
website/src/lib/components/ui/pagination/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import Root from "./pagination.svelte";
|
||||
import Content from "./pagination-content.svelte";
|
||||
import Item from "./pagination-item.svelte";
|
||||
import Link from "./pagination-link.svelte";
|
||||
import PrevButton from "./pagination-prev-button.svelte";
|
||||
import NextButton from "./pagination-next-button.svelte";
|
||||
import Ellipsis from "./pagination-ellipsis.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Item,
|
||||
Link,
|
||||
PrevButton,
|
||||
NextButton,
|
||||
Ellipsis,
|
||||
//
|
||||
Root as Pagination,
|
||||
Content as PaginationContent,
|
||||
Item as PaginationItem,
|
||||
Link as PaginationLink,
|
||||
PrevButton as PaginationPrevButton,
|
||||
NextButton as PaginationNextButton,
|
||||
Ellipsis as PaginationEllipsis,
|
||||
};
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLUListElement>> = $props();
|
||||
</script>
|
||||
|
||||
<ul
|
||||
bind:this={ref}
|
||||
data-slot="pagination-content"
|
||||
class={cn("flex flex-row items-center gap-1", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</ul>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts">
|
||||
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
|
||||
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
aria-hidden="true"
|
||||
data-slot="pagination-ellipsis"
|
||||
class={cn("flex size-9 items-center justify-center", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<EllipsisIcon class="size-4" />
|
||||
<span class="sr-only">More pages</span>
|
||||
</span>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLLiAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<li bind:this={ref} data-slot="pagination-item" {...restProps}>
|
||||
{@render children?.()}
|
||||
</li>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<script lang="ts">
|
||||
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { type Props, buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
size = "icon",
|
||||
isActive = false,
|
||||
page,
|
||||
children,
|
||||
...restProps
|
||||
}: PaginationPrimitive.PageProps &
|
||||
Props & {
|
||||
isActive: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
{page.value}
|
||||
{/snippet}
|
||||
|
||||
<PaginationPrimitive.Page
|
||||
bind:ref
|
||||
{page}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
class={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
{...restProps}
|
||||
/>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: PaginationPrimitive.NextButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<span>Next</span>
|
||||
<ChevronRightIcon class="size-4" />
|
||||
{/snippet}
|
||||
|
||||
<PaginationPrimitive.NextButton
|
||||
bind:ref
|
||||
aria-label="Go to next page"
|
||||
class={cn(
|
||||
buttonVariants({
|
||||
size: "default",
|
||||
variant: "ghost",
|
||||
class: "gap-1 px-2.5 sm:pr-2.5",
|
||||
}),
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
{...restProps}
|
||||
/>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: PaginationPrimitive.PrevButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<ChevronLeftIcon class="size-4" />
|
||||
<span>Previous</span>
|
||||
{/snippet}
|
||||
|
||||
<PaginationPrimitive.PrevButton
|
||||
bind:ref
|
||||
aria-label="Go to previous page"
|
||||
class={cn(
|
||||
buttonVariants({
|
||||
size: "default",
|
||||
variant: "ghost",
|
||||
class: "gap-1 px-2.5 sm:pl-2.5",
|
||||
}),
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
{...restProps}
|
||||
/>
|
||||
28
website/src/lib/components/ui/pagination/pagination.svelte
Normal file
28
website/src/lib/components/ui/pagination/pagination.svelte
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<script lang="ts">
|
||||
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
count = 0,
|
||||
perPage = 10,
|
||||
page = $bindable(1),
|
||||
siblingCount = 1,
|
||||
...restProps
|
||||
}: PaginationPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<PaginationPrimitive.Root
|
||||
bind:ref
|
||||
bind:page
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
class={cn("mx-auto flex w-full justify-center", className)}
|
||||
{count}
|
||||
{perPage}
|
||||
{siblingCount}
|
||||
{...restProps}
|
||||
/>
|
||||
17
website/src/lib/components/ui/popover/index.ts
Normal file
17
website/src/lib/components/ui/popover/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
import Content from "./popover-content.svelte";
|
||||
import Trigger from "./popover-trigger.svelte";
|
||||
const Root = PopoverPrimitive.Root;
|
||||
const Close = PopoverPrimitive.Close;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Trigger,
|
||||
Close,
|
||||
//
|
||||
Root as Popover,
|
||||
Content as PopoverContent,
|
||||
Trigger as PopoverTrigger,
|
||||
Close as PopoverClose,
|
||||
};
|
||||
29
website/src/lib/components/ui/popover/popover-content.svelte
Normal file
29
website/src/lib/components/ui/popover/popover-content.svelte
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
portalProps,
|
||||
...restProps
|
||||
}: PopoverPrimitive.ContentProps & {
|
||||
portalProps?: PopoverPrimitive.PortalProps;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Portal {...portalProps}>
|
||||
<PopoverPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="popover-content"
|
||||
{sideOffset}
|
||||
{align}
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-popover-content-transform-origin) outline-hidden z-50 w-72 rounded-md border p-4 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
17
website/src/lib/components/ui/popover/popover-trigger.svelte
Normal file
17
website/src/lib/components/ui/popover/popover-trigger.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: PopoverPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="popover-trigger"
|
||||
class={cn("", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
38
website/src/lib/types/market.ts
Normal file
38
website/src/lib/types/market.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
export interface CoinData {
|
||||
symbol: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
currentPrice: number;
|
||||
marketCap: number;
|
||||
volume24h: number;
|
||||
change24h: number;
|
||||
createdAt: string;
|
||||
creatorName: string | null;
|
||||
}
|
||||
|
||||
export interface MarketFilters {
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
sortOrder: string;
|
||||
priceFilter: string;
|
||||
changeFilter: string;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export interface MarketResponse {
|
||||
coins: CoinData[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface VolatilityBadge {
|
||||
text: string;
|
||||
variant: 'default' | 'secondary' | 'outline';
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { clsx, type ClassValue } from "clsx";
|
|||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -81,4 +81,48 @@ export function formatDate(timestamp: string): string {
|
|||
});
|
||||
}
|
||||
|
||||
export function formatRelativeTime(timestamp: string | Date): string {
|
||||
const now = new Date();
|
||||
const past = new Date(timestamp);
|
||||
const ms = now.getTime() - past.getTime();
|
||||
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
|
||||
if (hours < 24) {
|
||||
const extraMinutes = minutes % 60;
|
||||
return extraMinutes === 0 ? `${hours}hr` : `${hours}hr ${extraMinutes}m`;
|
||||
}
|
||||
|
||||
if (days < 7) return `${days}d`;
|
||||
|
||||
const yearsDiff = now.getFullYear() - past.getFullYear();
|
||||
const monthsDiff = now.getMonth() - past.getMonth();
|
||||
const totalMonths = yearsDiff * 12 + monthsDiff;
|
||||
const adjustedMonths = totalMonths + (now.getDate() < past.getDate() ? -1 : 0);
|
||||
const years = Math.floor(adjustedMonths / 12);
|
||||
|
||||
if (adjustedMonths < 1) {
|
||||
const weeks = Math.floor(days / 7);
|
||||
const extraDays = days % 7;
|
||||
return extraDays === 0 ? `${weeks}w` : `${weeks}w ${extraDays}d`;
|
||||
}
|
||||
|
||||
if (years < 1) {
|
||||
const tempDate = new Date(past);
|
||||
tempDate.setMonth(tempDate.getMonth() + adjustedMonths);
|
||||
const remainingDays = Math.floor((now.getTime() - tempDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const weeks = Math.floor(remainingDays / 7);
|
||||
return weeks === 0 ? `${adjustedMonths}m` : `${adjustedMonths}m ${weeks}w`;
|
||||
}
|
||||
|
||||
const remainingMonths = adjustedMonths % 12;
|
||||
return remainingMonths === 0 ? `${years}y` : `${years}y ${remainingMonths}m`;
|
||||
}
|
||||
|
||||
export const formatMarketCap = formatValue;
|
||||
168
website/src/routes/api/market/+server.ts
Normal file
168
website/src/routes/api/market/+server.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
15
website/src/routes/market/+page.server.ts
Normal file
15
website/src/routes/market/+page.server.ts
Normal 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)
|
||||
}
|
||||
};
|
||||
};
|
||||
568
website/src/routes/market/+page.svelte
Normal file
568
website/src/routes/market/+page.svelte
Normal 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>
|
||||
Reference in a new issue