feat: profile page + 404 page + refactor code
This commit is contained in:
parent
3f137e5c3c
commit
d692e86fe0
17 changed files with 1282 additions and 313 deletions
450
website/src/routes/user/[username]/+page.svelte
Normal file
450
website/src/routes/user/[username]/+page.svelte
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import DataTable from '$lib/components/self/DataTable.svelte';
|
||||
import ProfileBadges from '$lib/components/self/ProfileBadges.svelte';
|
||||
import { getPublicUrl, formatPrice, formatValue, formatQuantity, formatDate } from '$lib/utils';
|
||||
import { onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
Calendar,
|
||||
Wallet,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Coins,
|
||||
Receipt,
|
||||
Activity,
|
||||
RefreshCw
|
||||
} from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { UserProfileData } from '$lib/types/user-profile';
|
||||
|
||||
let { data } = $props();
|
||||
const username = data.username;
|
||||
|
||||
let profileData = $state<UserProfileData | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
await fetchProfileData();
|
||||
});
|
||||
|
||||
async function fetchProfileData() {
|
||||
try {
|
||||
const response = await fetch(`/api/user/${username}`);
|
||||
if (response.ok) {
|
||||
profileData = await response.json();
|
||||
} else {
|
||||
toast.error('Failed to load profile data');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch profile data:', e);
|
||||
toast.error('Failed to load profile data');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let memberSince = $derived(
|
||||
profileData?.profile
|
||||
? new Date(profileData.profile.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long'
|
||||
})
|
||||
: ''
|
||||
);
|
||||
let hasCreatedCoins = $derived(
|
||||
profileData?.createdCoins?.length ? profileData.createdCoins.length > 0 : false
|
||||
);
|
||||
|
||||
let totalTradingVolume = $derived(
|
||||
profileData?.stats
|
||||
? Number(profileData.stats.totalBuyVolume) + Number(profileData.stats.totalSellVolume)
|
||||
: 0
|
||||
);
|
||||
|
||||
let buyPercentage = $derived(
|
||||
profileData?.stats && totalTradingVolume > 0
|
||||
? (Number(profileData.stats.totalBuyVolume) / totalTradingVolume) * 100
|
||||
: 0
|
||||
);
|
||||
let sellPercentage = $derived(
|
||||
profileData?.stats && totalTradingVolume > 0
|
||||
? (Number(profileData.stats.totalSellVolume) / totalTradingVolume) * 100
|
||||
: 0
|
||||
);
|
||||
|
||||
let totalPortfolioValue = $derived(
|
||||
profileData?.stats?.totalPortfolioValue ? Number(profileData.stats.totalPortfolioValue) : 0
|
||||
);
|
||||
let baseCurrencyBalance = $derived(
|
||||
profileData?.stats?.baseCurrencyBalance ? Number(profileData.stats.baseCurrencyBalance) : 0
|
||||
);
|
||||
let holdingsValue = $derived(
|
||||
profileData?.stats?.holdingsValue ? Number(profileData.stats.holdingsValue) : 0
|
||||
);
|
||||
let totalBuyVolume = $derived(
|
||||
profileData?.stats?.totalBuyVolume ? Number(profileData.stats.totalBuyVolume) : 0
|
||||
);
|
||||
let totalSellVolume = $derived(
|
||||
profileData?.stats?.totalSellVolume ? Number(profileData.stats.totalSellVolume) : 0
|
||||
);
|
||||
let buyVolume24h = $derived(
|
||||
profileData?.stats?.buyVolume24h ? Number(profileData.stats.buyVolume24h) : 0
|
||||
);
|
||||
let sellVolume24h = $derived(
|
||||
profileData?.stats?.sellVolume24h ? Number(profileData.stats.sellVolume24h) : 0
|
||||
);
|
||||
|
||||
let totalTradingVolumeAllTime = $derived(totalBuyVolume + totalSellVolume);
|
||||
|
||||
let totalTradingVolume24h = $derived(buyVolume24h + sellVolume24h);
|
||||
|
||||
const createdCoinsColumns = [
|
||||
{
|
||||
key: 'coin',
|
||||
label: 'Coin',
|
||||
class: 'pl-6 font-medium',
|
||||
render: (value: any, row: any) => ({
|
||||
component: 'coin',
|
||||
icon: row.icon,
|
||||
symbol: row.symbol,
|
||||
name: row.name
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'currentPrice',
|
||||
label: 'Price',
|
||||
class: 'font-mono',
|
||||
render: (value: any) => `$${formatPrice(parseFloat(value))}`
|
||||
},
|
||||
{
|
||||
key: 'marketCap',
|
||||
label: 'Market Cap',
|
||||
class: 'hidden font-mono sm:table-cell',
|
||||
render: (value: any) => formatValue(parseFloat(value))
|
||||
},
|
||||
{
|
||||
key: 'change24h',
|
||||
label: '24h Change',
|
||||
class: 'hidden md:table-cell',
|
||||
render: (value: any) => ({
|
||||
component: 'badge',
|
||||
variant: parseFloat(value) >= 0 ? 'success' : 'destructive',
|
||||
text: `${parseFloat(value) >= 0 ? '+' : ''}${parseFloat(value).toFixed(2)}%`
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Created',
|
||||
class: 'text-muted-foreground hidden text-sm lg:table-cell',
|
||||
render: (value: any) => formatDate(value)
|
||||
}
|
||||
];
|
||||
|
||||
const transactionsColumns = [
|
||||
{
|
||||
key: 'type',
|
||||
label: 'Type',
|
||||
class: 'pl-6',
|
||||
render: (value: any) => ({
|
||||
component: 'badge',
|
||||
variant: value === 'BUY' ? 'success' : 'destructive',
|
||||
text: value
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'coin',
|
||||
label: 'Coin',
|
||||
class: 'font-medium',
|
||||
render: (value: any, row: any) => ({
|
||||
component: 'coin',
|
||||
icon: row.coinIcon,
|
||||
symbol: row.coinSymbol,
|
||||
name: row.coinName,
|
||||
size: 6
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'quantity',
|
||||
label: 'Quantity',
|
||||
class: 'hidden font-mono sm:table-cell',
|
||||
render: (value: any) => formatQuantity(parseFloat(value))
|
||||
},
|
||||
{
|
||||
key: 'pricePerCoin',
|
||||
label: 'Price',
|
||||
class: 'font-mono',
|
||||
render: (value: any) => `$${formatPrice(parseFloat(value))}`
|
||||
},
|
||||
{
|
||||
key: 'totalBaseCurrencyAmount',
|
||||
label: 'Total',
|
||||
class: 'hidden font-mono font-medium md:table-cell',
|
||||
render: (value: any) => formatValue(parseFloat(value))
|
||||
},
|
||||
{
|
||||
key: 'timestamp',
|
||||
label: 'Date',
|
||||
class: 'text-muted-foreground hidden text-sm lg:table-cell',
|
||||
render: (value: any) => formatDate(value)
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title
|
||||
>{profileData?.profile?.name
|
||||
? `${profileData.profile.name} (@${profileData.profile.username})`
|
||||
: 'Loading...'} - Rugplay</title
|
||||
>
|
||||
<meta
|
||||
name="description"
|
||||
content="View {profileData?.profile?.name || 'user'}'s profile and trading activity on Rugplay"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto max-w-6xl p-6">
|
||||
{#if loading}
|
||||
<div class="flex h-96 items-center justify-center">
|
||||
<div class="text-center">
|
||||
<RefreshCw class="text-muted-foreground mx-auto mb-4 h-8 w-8 animate-spin" />
|
||||
<div class="text-xl">Loading profile...</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !profileData}
|
||||
<div class="flex h-96 items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-muted-foreground mb-4 text-xl">Failed to load profile</div>
|
||||
<Button onclick={fetchProfileData}>Try Again</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Profile Header Card -->
|
||||
<Card.Root class="mb-6 py-0">
|
||||
<Card.Content class="p-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<!-- Avatar -->
|
||||
<div class="flex-shrink-0">
|
||||
<Avatar.Root class="size-20 sm:size-24">
|
||||
<Avatar.Image
|
||||
src={getPublicUrl(profileData.profile.image)}
|
||||
alt={profileData.profile.name}
|
||||
/>
|
||||
<Avatar.Fallback class="text-xl"
|
||||
>{profileData.profile.name.charAt(0).toUpperCase()}</Avatar.Fallback
|
||||
>
|
||||
</Avatar.Root>
|
||||
</div>
|
||||
|
||||
<!-- Profile Info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-3">
|
||||
<div class="mb-1 flex flex-wrap items-center gap-2">
|
||||
<h1 class="text-2xl font-bold sm:text-3xl">{profileData.profile.name}</h1>
|
||||
|
||||
<!-- Badges -->
|
||||
<ProfileBadges user={profileData.profile} />
|
||||
</div>
|
||||
<p class="text-muted-foreground text-lg">@{profileData.profile.username}</p>
|
||||
</div>
|
||||
|
||||
{#if profileData.profile.bio}
|
||||
<p class="text-muted-foreground mb-3 max-w-2xl leading-relaxed">
|
||||
{profileData.profile.bio}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Calendar class="h-4 w-4" />
|
||||
<span>Joined {memberSince}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Main Portfolio Stats -->
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Total Portfolio Value -->
|
||||
<Card.Root class="py-0">
|
||||
<Card.Content class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-muted-foreground text-sm font-medium">Total Portfolio</div>
|
||||
<Wallet class="text-muted-foreground h-4 w-4" />
|
||||
</div>
|
||||
<div class="mt-1 text-2xl font-bold">
|
||||
{formatValue(totalPortfolioValue)}
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">{profileData.stats.holdingsCount} holdings</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Liquid Value -->
|
||||
<Card.Root class="py-0">
|
||||
<Card.Content class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-muted-foreground text-sm font-medium">Liquid Value</div>
|
||||
</div>
|
||||
<div class="text-success mt-1 text-2xl font-bold">
|
||||
{formatValue(baseCurrencyBalance)}
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">Available cash</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Illiquid Value -->
|
||||
<Card.Root class="py-0">
|
||||
<Card.Content class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-muted-foreground text-sm font-medium">Illiquid Value</div>
|
||||
</div>
|
||||
<div class="text-success mt-1 text-2xl font-bold">
|
||||
{formatValue(holdingsValue)}
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">Coin holdings</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Buy/Sell Ratio -->
|
||||
<Card.Root class="py-0">
|
||||
<Card.Content class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-muted-foreground text-sm font-medium">Buy/Sell Ratio</div>
|
||||
<div class="flex gap-1">
|
||||
<div class="bg-success h-2 w-2 rounded-full"></div>
|
||||
<div class="h-2 w-2 rounded-full bg-red-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span class="text-success text-xl font-bold">{buyPercentage.toFixed(1)}%</span>
|
||||
<span class="text-muted-foreground text-xs">buy</span>
|
||||
<span class="text-xl font-bold text-red-600">{sellPercentage.toFixed(1)}%</span>
|
||||
<span class="text-muted-foreground text-xs">sell</span>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<!-- Buy & Sell Activity Breakdown -->
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-4">
|
||||
<!-- Buy Activity -->
|
||||
<Card.Root class="py-0">
|
||||
<Card.Content class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-foreground text-sm font-medium">Buy Activity</div>
|
||||
<TrendingUp class="text-success h-4 w-4" />
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<div class="text-success text-2xl font-bold">
|
||||
{formatValue(totalBuyVolume)}
|
||||
</div>
|
||||
<div class="text-muted-foreground text-xs">Total amount spent</div>
|
||||
</div>
|
||||
<div class="border-muted mt-3 border-t pt-3">
|
||||
<div class="text-success text-lg font-bold">
|
||||
{formatValue(buyVolume24h)}
|
||||
</div>
|
||||
<div class="text-muted-foreground text-xs">24h buy volume</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Sell Activity -->
|
||||
<Card.Root class="py-0">
|
||||
<Card.Content class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-foreground text-sm font-medium">Sell Activity</div>
|
||||
<TrendingDown class="h-4 w-4 text-red-600" />
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<div class="text-2xl font-bold text-red-600">
|
||||
{formatValue(totalSellVolume)}
|
||||
</div>
|
||||
<div class="text-muted-foreground text-xs">Total amount received</div>
|
||||
</div>
|
||||
<div class="border-muted mt-3 border-t pt-3">
|
||||
<div class="text-lg font-bold text-red-600">
|
||||
{formatValue(sellVolume24h)}
|
||||
</div>
|
||||
<div class="text-muted-foreground text-xs">24h sell volume</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Total Trading Volume -->
|
||||
<Card.Root class="py-0">
|
||||
<Card.Content class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-muted-foreground text-sm font-medium">Total Trading Volume</div>
|
||||
<Badge variant="outline" class="text-xs">All Time</Badge>
|
||||
</div>
|
||||
<div class="mt-1 text-2xl font-bold">
|
||||
{formatValue(totalTradingVolumeAllTime)}
|
||||
</div>
|
||||
<div class="text-muted-foreground text-xs">
|
||||
{profileData.stats.totalTransactions} total trades
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- 24h Trading Volume -->
|
||||
<Card.Root class="py-0">
|
||||
<Card.Content class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-muted-foreground text-sm font-medium">24h Trading Volume</div>
|
||||
<Badge variant="outline" class="text-xs">24h</Badge>
|
||||
</div>
|
||||
<div class="mt-1 text-2xl font-bold">
|
||||
{formatValue(totalTradingVolume24h)}
|
||||
</div>
|
||||
<div class="text-muted-foreground text-xs">
|
||||
{profileData.stats.transactions24h || 0} trades today
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<!-- Created Coins -->
|
||||
{#if hasCreatedCoins}
|
||||
<Card.Root class="mb-6">
|
||||
<Card.Header class="pb-3">
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<Coins class="h-5 w-5" />
|
||||
Created Coins ({profileData.createdCoins.length})
|
||||
</Card.Title>
|
||||
<Card.Description>Coins launched by {profileData.profile.name}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="p-0">
|
||||
<DataTable
|
||||
columns={createdCoinsColumns}
|
||||
data={profileData.createdCoins}
|
||||
onRowClick={(coin) => goto(`/coin/${coin.symbol}`)}
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Recent Trading Activity -->
|
||||
<Card.Root>
|
||||
<Card.Header class="pb-3">
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<Activity class="h-5 w-5" />
|
||||
Recent Trading Activity
|
||||
</Card.Title>
|
||||
<Card.Description>Latest transactions by {profileData.profile.name}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="p-0">
|
||||
<DataTable
|
||||
columns={transactionsColumns}
|
||||
data={profileData?.recentTransactions || []}
|
||||
emptyIcon={Receipt}
|
||||
emptyTitle="No recent activity"
|
||||
emptyDescription="This user hasn't made any trades yet."
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
5
website/src/routes/user/[username]/+page.ts
Normal file
5
website/src/routes/user/[username]/+page.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export async function load({ params }) {
|
||||
return {
|
||||
username: params.username
|
||||
};
|
||||
}
|
||||
Reference in a new issue