feat: prestige system

This commit is contained in:
Face 2025-06-14 21:51:26 +06:00
parent 08adc11dd0
commit ec6426781d
17 changed files with 2683 additions and 30 deletions

6
package-lock.json generated Normal file
View file

@ -0,0 +1,6 @@
{
"name": "rugplay",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View file

@ -27,14 +27,14 @@
}, },
"devDependencies": { "devDependencies": {
"@internationalized/date": "^3.8.1", "@internationalized/date": "^3.8.1",
"@lucide/svelte": "^0.482.0", "@lucide/svelte": "^0.515.0",
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.15.21", "@types/node": "^22.15.21",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"bits-ui": "^2.5.0", "bits-ui": "^2.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-kit": "^0.22.0", "drizzle-kit": "^0.22.0",
"prettier": "^3.3.2", "prettier": "^3.3.2",
@ -218,7 +218,7 @@
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
"@lucide/svelte": ["@lucide/svelte@0.482.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-n2ycHU9cNcleRDwwpEHBJ6pYzVhHIaL3a+9dQa8kns9hB2g05bY+v2p2KP8v0pZwtNhYTHk/F2o2uZ1bVtQGhw=="], "@lucide/svelte": ["@lucide/svelte@0.515.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-CEAyqcZmNBfYzVgaRmK2RFJP5tnbXxekRyDk0XX/eZQRfsJmkDvmQwXNX8C869BgNeryzmrRyjHhUL6g9ZOHNA=="],
"@noble/ciphers": ["@noble/ciphers@0.6.0", "", {}, "sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ=="], "@noble/ciphers": ["@noble/ciphers@0.6.0", "", {}, "sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ=="],
@ -516,7 +516,7 @@
"better-call": ["better-call@1.0.9", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-Qfm0gjk0XQz0oI7qvTK1hbqTsBY4xV2hsHAxF8LZfUYl3RaECCIifXuVqtPpZJWvlCCMlQSvkvhhyuApGUba6g=="], "better-call": ["better-call@1.0.9", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-Qfm0gjk0XQz0oI7qvTK1hbqTsBY4xV2hsHAxF8LZfUYl3RaECCIifXuVqtPpZJWvlCCMlQSvkvhhyuApGUba6g=="],
"bits-ui": ["bits-ui@2.5.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/dom": "^1.7.0", "css.escape": "^1.5.1", "esm-env": "^1.1.2", "runed": "^0.28.0", "svelte-toolbelt": "^0.9.1", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-PbjylA1UWd4A/c5AYqie/EVxQ1/8uugmJKLg9whLoBBHbfPEBGhK09dCPrahK9kA6DRHhMmij0XXIUGIfrmNow=="], "bits-ui": ["bits-ui@2.7.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/dom": "^1.7.0", "css.escape": "^1.5.1", "esm-env": "^1.1.2", "runed": "^0.28.0", "svelte-toolbelt": "^0.9.1", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-2kjVSVBDt9SFnjFiA9vD74T5+UtAKWYpfOil9d4+7v0fSawvTNcMgOP05cD3FTJT/rarj78DjhaXdyTWIW6I/g=="],
"body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], "body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="],

View file

@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "prestige_level" integer DEFAULT 0;

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,13 @@
"when": 1749907594739, "when": 1749907594739,
"tag": "0001_cuddly_dormammu", "tag": "0001_cuddly_dormammu",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1749916220202,
"tag": "0002_small_micromacro",
"breakpoints": true
} }
] ]
} }

View file

@ -31,7 +31,8 @@
Hammer, Hammer,
BookOpen, BookOpen,
Info, Info,
Bell Bell,
Crown
} from 'lucide-svelte'; } from 'lucide-svelte';
import { mode, setMode } from 'mode-watcher'; import { mode, setMode } from 'mode-watcher';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
@ -85,7 +86,7 @@
function handleModeToggle() { function handleModeToggle() {
setMode(mode.current === 'light' ? 'dark' : 'light'); setMode(mode.current === 'light' ? 'dark' : 'light');
setOpenMobile(false); // Remove setOpenMobile(false) to keep menu open
} }
function formatCurrency(value: number): string { function formatCurrency(value: number): string {
@ -152,6 +153,11 @@
showUserManual = true; showUserManual = true;
setOpenMobile(false); setOpenMobile(false);
} }
function handlePrestigeClick() {
goto('/prestige');
setOpenMobile(false);
}
</script> </script>
<SignInConfirmDialog bind:open={shouldSignIn} /> <SignInConfirmDialog bind:open={shouldSignIn} />
@ -195,22 +201,6 @@
</Sidebar.MenuButton> </Sidebar.MenuButton>
</Sidebar.MenuItem> </Sidebar.MenuItem>
{/each} {/each}
<Sidebar.MenuItem>
<Sidebar.MenuButton>
{#snippet child({ props }: { props: MenuButtonProps })}
<button onclick={handleModeToggle} {...props}>
{#if mode.current === 'light'}
<Moon class="h-5 w-5" />
<span>Dark Mode</span>
{:else}
<Sun class="h-5 w-5" />
<span>Light Mode</span>
{/if}
</button>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu> </Sidebar.Menu>
</Sidebar.GroupContent> </Sidebar.GroupContent>
</Sidebar.Group> </Sidebar.Group>
@ -421,6 +411,8 @@
</div> </div>
</DropdownMenu.Label> </DropdownMenu.Label>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<!-- Profile & Settings Group -->
<DropdownMenu.Group> <DropdownMenu.Group>
<DropdownMenu.Item onclick={handleAccountClick}> <DropdownMenu.Item onclick={handleAccountClick}>
<User /> <User />
@ -430,10 +422,16 @@
<Settings /> <Settings />
Settings Settings
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onclick={handleUserManualClick}> <DropdownMenu.Item onclick={handlePrestigeClick}>
<BookOpen /> <Crown />
User Manual Prestige
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<!-- Features Group -->
<DropdownMenu.Group>
<DropdownMenu.Item <DropdownMenu.Item
onclick={() => { onclick={() => {
showPromoCode = true; showPromoCode = true;
@ -443,10 +441,24 @@
<Gift /> <Gift />
Promo code Promo code
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onclick={handleUserManualClick}>
<BookOpen />
User Manual
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleModeToggle}>
{#if mode.current === 'light'}
<Moon />
Dark Mode
{:else}
<Sun />
Light Mode
{/if}
</DropdownMenu.Item>
</DropdownMenu.Group> </DropdownMenu.Group>
{#if $USER_DATA?.isAdmin} {#if $USER_DATA?.isAdmin}
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<!-- Admin Group -->
<DropdownMenu.Group> <DropdownMenu.Group>
<DropdownMenu.Item <DropdownMenu.Item
onclick={handleAdminClick} onclick={handleAdminClick}
@ -471,8 +483,11 @@
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Group> </DropdownMenu.Group>
{/if} {/if}
<DropdownMenu.Group>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<!-- Legal Group -->
<DropdownMenu.Group>
<DropdownMenu.Item onclick={handleTermsClick}> <DropdownMenu.Item onclick={handleTermsClick}>
<Scale /> <Scale />
Terms of Service Terms of Service
@ -482,7 +497,10 @@
Privacy Policy Privacy Policy
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Group> </DropdownMenu.Group>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<!-- Sign Out -->
<DropdownMenu.Item <DropdownMenu.Item
onclick={() => { onclick={() => {
signOut().then(() => { signOut().then(() => {

View file

@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { UserProfile } from '$lib/types/user-profile'; import type { UserProfile } from '$lib/types/user-profile';
import SilentBadge from './SilentBadge.svelte'; import SilentBadge from './SilentBadge.svelte';
import { Hash, Hammer, Flame } from 'lucide-svelte'; import { Hash, Hammer, Flame, Star } from 'lucide-svelte';
import { getPrestigeName, getPrestigeColor } from '$lib/utils';
let { let {
user, user,
@ -14,14 +15,23 @@
} = $props(); } = $props();
let badgeClass = $derived(size === 'sm' ? 'text-xs' : ''); let badgeClass = $derived(size === 'sm' ? 'text-xs' : '');
let prestigeName = $derived(user.prestigeLevel ? getPrestigeName(user.prestigeLevel) : null);
let prestigeColor = $derived(user.prestigeLevel ? getPrestigeColor(user.prestigeLevel) : 'text-gray-500');
</script> </script>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
{#if showId} {#if showId}
<SilentBadge icon={Hash} class="text-muted-foreground {badgeClass}" text="#{user.id} to join" /> <SilentBadge icon={Hash} class="text-muted-foreground {badgeClass}" text="#{user.id} to join" />
{/if} {/if}
{#if prestigeName}
<SilentBadge icon={Star} text={prestigeName} class="{prestigeColor} {badgeClass}" />
{/if}
{#if user.loginStreak && user.loginStreak > 1} {#if user.loginStreak && user.loginStreak > 1}
<SilentBadge icon={Flame} text="{user.loginStreak} day streak" class="text-orange-500 {badgeClass}" /> <SilentBadge
icon={Flame}
text="{user.loginStreak} day streak"
class="text-orange-500 {badgeClass}"
/>
{/if} {/if}
{#if user.isAdmin} {#if user.isAdmin}
<SilentBadge icon={Hammer} text="Admin" class="text-primary {badgeClass}" /> <SilentBadge icon={Hammer} text="Admin" class="text-primary {badgeClass}" />

View file

@ -0,0 +1,150 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Skeleton } from '$lib/components/ui/skeleton';
import * as Avatar from '$lib/components/ui/avatar';
import { Star } from 'lucide-svelte';
</script>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Main Content Column -->
<div class="flex flex-col lg:col-span-2">
<!-- How Card Skeleton -->
<Card.Root class="mb-6 gap-1">
<Card.Header class="pb-2">
<Card.Title class="text-base">How</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
{#each Array(3) as _, i}
<div class="flex gap-3">
<div
class="bg-primary/10 text-primary flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-sm font-medium"
>
{i + 1}
</div>
<div class="flex-1 space-y-2">
<Skeleton class="h-4 w-24" />
<Skeleton class="h-3 w-full" />
<Skeleton class="h-3 w-3/4" />
</div>
</div>
{/each}
</div>
</Card.Content>
</Card.Root>
<!-- Progress Card Skeleton -->
<Card.Root class="flex flex-1 flex-col gap-1">
<Card.Header>
<Card.Title class="flex items-center gap-2">
<Star class="h-5 w-5" />
Progress
</Card.Title>
</Card.Header>
<Card.Content class="flex flex-1 flex-col space-y-6">
<!-- Progress Section -->
<div class="space-y-6">
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<Skeleton class="h-4 w-32" />
<Skeleton class="h-4 w-12" />
</div>
<Skeleton class="h-2 w-full rounded-full" />
</div>
<!-- Financial Details Table Skeleton -->
<div class="overflow-hidden rounded-xl border">
<table class="w-full text-sm">
<tbody class="divide-y">
{#each Array(3) as _}
<tr>
<td class="px-3 py-2">
<Skeleton class="h-4 w-20" />
</td>
<td class="px-3 py-2 text-right">
<Skeleton class="ml-auto h-4 w-24" />
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<Skeleton class="h-4 w-40" />
<!-- Prestige Button Skeleton -->
<Skeleton class="h-12 w-full rounded-lg" />
</Card.Content>
</Card.Root>
</div>
<!-- Right Column - Info -->
<div class="flex flex-col space-y-4">
<!-- Profile Preview Card Skeleton -->
<Card.Root class="flex-1 gap-1">
<Card.Header class="pb-2">
<Card.Title class="text-base">Preview</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<!-- Current Profile -->
<div class="space-y-2">
<Skeleton class="h-3 w-12" />
<div class="flex items-center gap-3 rounded-lg border p-3">
<Avatar.Root class="h-10 w-10 shrink-0">
<Avatar.Fallback>
<Skeleton class="h-full w-full rounded-full" />
</Avatar.Fallback>
</Avatar.Root>
<div class="min-w-0 flex-1 space-y-1">
<div class="flex min-w-0 items-center gap-2">
<Skeleton class="h-4 w-20" />
<Skeleton class="h-4 w-16" />
</div>
<Skeleton class="h-3 w-16" />
</div>
</div>
</div>
<!-- Prestige Preview -->
<div class="space-y-2">
<Skeleton class="h-3 w-10" />
<div
class="flex items-center gap-3 rounded-lg border-2 border-yellow-500/30 bg-yellow-50/50 p-3 dark:bg-yellow-950/20"
>
<Avatar.Root class="h-10 w-10 shrink-0">
<Avatar.Fallback>
<Skeleton class="h-full w-full rounded-full" />
</Avatar.Fallback>
</Avatar.Root>
<div class="min-w-0 flex-1 space-y-1">
<div class="flex min-w-0 items-center gap-2">
<Skeleton class="h-4 w-20" />
<Skeleton class="h-4 w-20" />
</div>
<Skeleton class="h-3 w-16" />
</div>
</div>
</div>
</Card.Content>
</Card.Root>
<!-- All Prestige Levels Skeleton -->
<Card.Root class="flex-1 gap-1">
<Card.Header class="pb-2">
<Card.Title class="text-base">Levels</Card.Title>
</Card.Header>
<Card.Content class="space-y-1">
{#each Array(5) as _}
<div class="flex items-center justify-between py-1">
<div class="flex items-center gap-2">
<Skeleton class="h-4 w-4" />
<Skeleton class="h-4 w-20" />
</div>
<Skeleton class="h-3 w-16" />
</div>
{/each}
</Card.Content>
</Card.Root>
</div>
</div>

View file

@ -0,0 +1,7 @@
import Root from "./progress.svelte";
export {
Root,
//
Root as Progress,
};

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { Progress as ProgressPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
max = 100,
value,
...restProps
}: WithoutChildrenOrChild<ProgressPrimitive.RootProps> = $props();
</script>
<ProgressPrimitive.Root
bind:ref
data-slot="progress"
class={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
{value}
{max}
{...restProps}
>
<div
data-slot="progress-indicator"
class="bg-primary h-full w-full flex-1 transition-all"
style="transform: translateX(-{100 - (100 * (value ?? 0)) / (max ?? 1)}%)"
></div>
</ProgressPrimitive.Root>

View file

@ -31,7 +31,8 @@ export const user = pgTable("user", {
precision: 20, precision: 20,
scale: 8, scale: 8,
}).notNull().default("0.00000000"), }).notNull().default("0.00000000"),
loginStreak: integer("login_streak").notNull().default(0) loginStreak: integer("login_streak").notNull().default(0),
prestigeLevel: integer("prestige_level").default(0),
}); });
export const session = pgTable("session", { export const session = pgTable("session", {

View file

@ -16,6 +16,8 @@ export type User = {
volumeMaster: number; volumeMaster: number;
volumeMuted: boolean; volumeMuted: boolean;
prestigeLevel: number;
} | null; } | null;
export const USER_DATA = writable<User>(undefined); export const USER_DATA = writable<User>(undefined);

View file

@ -9,6 +9,8 @@ export interface UserProfile {
isAdmin: boolean; isAdmin: boolean;
totalPortfolioValue: number; totalPortfolioValue: number;
loginStreak: number; loginStreak: number;
prestigeLevel: number | null;
} }
export interface UserStats { export interface UserStats {

View file

@ -336,3 +336,49 @@ export function timeToLocal(originalTime: number): number {
const d = new Date(originalTime * 1000); const d = new Date(originalTime * 1000);
return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()) / 1000); return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()) / 1000);
} }
export const PRESTIGE_COSTS = {
1: 100_000,
2: 250_000,
3: 1_000_000,
4: 5_000_000,
5: 25_000_000
} as const;
export const PRESTIGE_NAMES = {
1: 'Prestige I',
2: 'Prestige II',
3: 'Prestige III',
4: 'Prestige IV',
5: 'Prestige V'
} as const;
export const PRESTIGE_COLORS = {
1: 'text-blue-500',
2: 'text-purple-500',
3: 'text-yellow-500',
4: 'text-orange-500',
5: 'text-red-500'
} as const;
export function getPrestigeName(level: number): string | null {
if (level <= 0) return null;
const clampedLevel = Math.min(level, 5) as keyof typeof PRESTIGE_NAMES;
return PRESTIGE_NAMES[clampedLevel];
}
export function getPrestigeCost(level: number): number | null {
if (level <= 0) return null;
const clampedLevel = Math.min(level, 5) as keyof typeof PRESTIGE_COSTS;
return PRESTIGE_COSTS[clampedLevel];
}
export function getPrestigeColor(level: number): string {
if (level <= 0) return 'text-gray-500';
const clampedLevel = Math.min(level, 5) as keyof typeof PRESTIGE_COLORS;
return PRESTIGE_COLORS[clampedLevel];
}
export function getMaxPrestigeLevel(): number {
return 5;
}

View file

@ -0,0 +1,171 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user, userPortfolio, transaction, notifications, coin } from '$lib/server/db/schema';
import { eq, sql } from 'drizzle-orm';
import type { RequestHandler } from './$types';
import { formatValue, getPrestigeCost, getPrestigeName } from '$lib/utils';
export const POST: RequestHandler = async ({ request, locals }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) throw error(401, 'Not authenticated');
const userId = Number(session.user.id);
return await db.transaction(async (tx) => {
const [userData] = await tx
.select({
baseCurrencyBalance: user.baseCurrencyBalance,
prestigeLevel: user.prestigeLevel
})
.from(user)
.where(eq(user.id, userId))
.for('update')
.limit(1);
if (!userData) throw error(404, 'User not found');
const currentPrestige = userData.prestigeLevel || 0;
const nextPrestige = currentPrestige + 1;
const prestigeCost = getPrestigeCost(nextPrestige);
const prestigeName = getPrestigeName(nextPrestige);
if (!prestigeCost || !prestigeName) {
throw error(400, 'Maximum prestige level reached');
}
const holdings = await tx
.select({
coinId: userPortfolio.coinId,
quantity: userPortfolio.quantity,
currentPrice: coin.currentPrice,
symbol: coin.symbol
})
.from(userPortfolio)
.leftJoin(coin, eq(userPortfolio.coinId, coin.id))
.where(eq(userPortfolio.userId, userId));
let warningMessage = '';
let totalSaleValue = 0;
if (holdings.length > 0) {
warningMessage = `All ${holdings.length} coin holdings have been sold at current market prices. `;
for (const holding of holdings) {
const quantity = Number(holding.quantity);
const price = Number(holding.currentPrice);
const saleValue = quantity * price;
totalSaleValue += saleValue;
await tx.insert(transaction).values({
coinId: holding.coinId!,
type: 'SELL',
quantity: holding.quantity,
pricePerCoin: holding.currentPrice || '0',
totalBaseCurrencyAmount: saleValue.toString(),
timestamp: new Date()
});
}
await tx
.delete(userPortfolio)
.where(eq(userPortfolio.userId, userId));
}
const currentBalance = Number(userData.baseCurrencyBalance) + totalSaleValue;
if (currentBalance < prestigeCost) {
throw error(400, `Insufficient funds. Need ${formatValue(prestigeCost)}, have ${formatValue(currentBalance)}`);
}
await tx
.update(user)
.set({
baseCurrencyBalance: '100.00000000',
prestigeLevel: nextPrestige,
updatedAt: new Date()
})
.where(eq(user.id, userId));
await tx.delete(userPortfolio).where(eq(userPortfolio.userId, userId));
await tx.insert(notifications).values({
userId: userId,
type: 'SYSTEM',
title: `${prestigeName} Achieved!`,
message: `Congratulations! You have successfully reached ${prestigeName}. Your portfolio has been reset and you can now start fresh with your new prestige badge.`,
});
return json({
success: true,
newPrestigeLevel: nextPrestige,
costPaid: prestigeCost,
coinsSold: holdings.length,
totalSaleValue,
message: `${warningMessage}Congratulations! You've reached Prestige ${nextPrestige}!`
});
});
};
export const GET: RequestHandler = async ({ request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) throw error(401, 'Not authenticated');
const userId = Number(session.user.id);
const [userProfile] = await db
.select({
id: user.id,
name: user.name,
username: user.username,
bio: user.bio,
image: user.image,
createdAt: user.createdAt,
baseCurrencyBalance: user.baseCurrencyBalance,
isAdmin: user.isAdmin,
loginStreak: user.loginStreak,
prestigeLevel: user.prestigeLevel
})
.from(user)
.where(eq(user.id, userId))
.limit(1);
if (!userProfile) {
throw error(404, 'User not found');
}
const [portfolioStats] = await db
.select({
holdingsCount: sql<number>`COUNT(*)`,
holdingsValue: sql<number>`COALESCE(SUM(CAST(${userPortfolio.quantity} AS NUMERIC) * CAST(${coin.currentPrice} AS NUMERIC)), 0)`
})
.from(userPortfolio)
.leftJoin(coin, eq(userPortfolio.coinId, coin.id))
.where(eq(userPortfolio.userId, userId));
const baseCurrencyBalance = Number(userProfile.baseCurrencyBalance);
const holdingsValue = Number(portfolioStats?.holdingsValue || 0);
const holdingsCount = Number(portfolioStats?.holdingsCount || 0);
const totalPortfolioValue = baseCurrencyBalance + holdingsValue;
return json({
profile: {
...userProfile,
baseCurrencyBalance,
totalPortfolioValue,
prestigeLevel: userProfile.prestigeLevel || 0
},
stats: {
totalPortfolioValue,
baseCurrencyBalance,
holdingsValue,
holdingsCount,
coinsCreated: 0,
totalTransactions: 0,
totalBuyVolume: 0,
totalSellVolume: 0,
transactions24h: 0,
buyVolume24h: 0,
sellVolume24h: 0
}
});
};

View file

@ -25,6 +25,7 @@ export async function GET({ params }) {
baseCurrencyBalance: true, baseCurrencyBalance: true,
isAdmin: true, isAdmin: true,
loginStreak: true, loginStreak: true,
prestigeLevel: true,
} }
}); });

View file

@ -0,0 +1,477 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Alert, AlertDescription } from '$lib/components/ui/alert';
import { Progress } from '$lib/components/ui/progress';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Avatar from '$lib/components/ui/avatar';
import { AlertTriangle, Crown, Loader2, Star } from 'lucide-svelte';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { USER_DATA } from '$lib/stores/user-data';
import { formatValue, getPublicUrl, PRESTIGE_COSTS, PRESTIGE_NAMES } from '$lib/utils';
import SEO from '$lib/components/self/SEO.svelte';
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
import ProfileBadges from '$lib/components/self/ProfileBadges.svelte';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import PrestigeSkeleton from '$lib/components/self/skeletons/PrestigeSkeleton.svelte';
let isPrestiging = $state(false);
let error = $state('');
let shouldSignIn = $state(false);
let loading = $state(true);
let showConfirmDialog = $state(false);
let confirmationText = $state('');
let prestigeData = $state<any>(null);
let userData = $derived($USER_DATA);
const currentPrestige = $derived(prestigeData?.profile?.prestigeLevel || 0);
const nextPrestige = $derived(currentPrestige + 1);
const prestigeCost = $derived.by(() => {
if (!prestigeData) return null;
const nextLevel = currentPrestige + 1;
return PRESTIGE_COSTS[nextLevel as keyof typeof PRESTIGE_COSTS] || null;
});
const prestigeName = $derived.by(() => {
if (!prestigeData) return null;
const nextLevel = currentPrestige + 1;
return PRESTIGE_NAMES[nextLevel as keyof typeof PRESTIGE_NAMES] || null;
});
const currentBalance = $derived(prestigeData?.profile?.baseCurrencyBalance || 0);
const holdingsValue = $derived(prestigeData?.stats?.holdingsValue || 0);
const totalValue = $derived(prestigeData?.profile?.totalPortfolioValue || 0);
const canAfford = $derived(prestigeCost ? currentBalance >= prestigeCost : false);
const hasMaxPrestige = $derived(!prestigeCost);
const progressPercentage = $derived(
prestigeCost ? Math.min((currentBalance / prestigeCost) * 100, 100) : 100
);
const amountNeeded = $derived(prestigeCost ? Math.max(prestigeCost - currentBalance, 0) : 0);
onMount(async () => {
await fetchPrestigeData();
loading = false;
});
async function fetchPrestigeData() {
if (!userData) return;
try {
const response = await fetch('/api/prestige');
if (!response.ok) throw new Error('Failed to fetch prestige data');
prestigeData = await response.json();
} catch (e) {
console.error('Failed to fetch prestige data:', e);
toast.error('Failed to load prestige data');
}
}
async function handlePrestige() {
if (!canAfford || !userData) return;
isPrestiging = true;
error = '';
try {
const response = await fetch('/api/prestige', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || 'Failed to prestige');
}
toast.success(`Congratulations! You've reached ${prestigeName}!`);
await fetchPrestigeData();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'An error occurred';
error = errorMessage;
toast.error(errorMessage);
} finally {
isPrestiging = false;
showConfirmDialog = false;
confirmationText = '';
}
}
function openConfirmDialog() {
if (!canAfford || !userData) return;
showConfirmDialog = true;
}
function closeConfirmDialog() {
showConfirmDialog = false;
confirmationText = '';
}
$effect(() => {
console.log(currentPrestige);
});
const canConfirmPrestige = $derived(confirmationText.toUpperCase() === 'PRESTIGE');
</script>
<SEO
title="Prestige - Rugplay"
description="Advance your trading status and reset your progress for prestige rewards in the Rugplay cryptocurrency simulation."
noindex={true}
/>
<SignInConfirmDialog bind:open={shouldSignIn} />
<!-- Prestige Confirmation Dialog -->
<Dialog.Root bind:open={showConfirmDialog}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<AlertTriangle class="text-destructive h-5 w-5" />
Confirm
</Dialog.Title>
<Dialog.Description>
This action is permanent and cannot be undone. Please review the consequences carefully.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<Alert variant="destructive">
<AlertTriangle class="h-4 w-4" />
<AlertDescription>
<strong>You will lose:</strong>
<ul class="mt-2 list-disc space-y-1 pl-4">
<li>Cash balance: {formatValue(currentBalance)}</li>
{#if holdingsValue > 0}
<li>All coin holdings worth {formatValue(holdingsValue)}</li>
{/if}
<li>Total portfolio value: {formatValue(totalValue)}</li>
</ul>
We will automatically sell all your coin holdings.
</AlertDescription>
</Alert>
<div class="space-y-2">
<Label for="confirmation" class="text-sm font-medium">Type "PRESTIGE" to confirm:</Label>
<Input
id="confirmation"
bind:value={confirmationText}
placeholder="Type PRESTIGE here"
class="uppercase"
/>
</div>
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={closeConfirmDialog}>Cancel</Button>
<Button onclick={handlePrestige} disabled={!canConfirmPrestige || isPrestiging}>
{#if isPrestiging}
<Loader2 class="h-4 w-4 animate-spin" />
Advancing...
{:else}
Proceed
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<div class="container mx-auto max-w-7xl p-6">
<header class="mb-8">
<div class="text-center">
<div class="mb-2 flex items-center justify-center gap-3">
<Star class="h-8 w-8 text-yellow-500" />
<h1 class="text-3xl font-bold">Prestige</h1>
</div>
<p class="text-muted-foreground mb-6">Reset your progress to advance your trading status</p>
</div>
</header>
{#if loading}
<PrestigeSkeleton />
{:else if !userData}
<div class="flex h-96 items-center justify-center">
<div class="text-center">
<div class="text-muted-foreground mb-4 text-xl">Sign in to prestige</div>
<p class="text-muted-foreground mb-4 text-sm">You need an account to prestige</p>
<Button onclick={() => (shouldSignIn = true)}>Sign In</Button>
</div>
</div>
{:else}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Main Content Column -->
<div class="flex flex-col lg:col-span-2">
<!-- How -->
<Card.Root class="mb-6 gap-1">
<Card.Header class="pb-2">
<Card.Title class="text-base">How</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="flex gap-3">
<div
class="bg-primary/10 text-primary flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-sm font-medium"
>
1
</div>
<div>
<p class="font-medium">Meet Requirements</p>
<p class="text-muted-foreground text-sm">
Accumulate enough cash to afford the prestige cost
</p>
</div>
</div>
<div class="flex gap-3">
<div
class="bg-primary/10 text-primary flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-sm font-medium"
>
2
</div>
<div>
<p class="font-medium">Reset Progress</p>
<p class="text-muted-foreground text-sm">
All cash and holdings are erased, but history remains
</p>
</div>
</div>
<div class="flex gap-3">
<div
class="bg-primary/10 text-primary flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-sm font-medium"
>
3
</div>
<div>
<p class="font-medium">Gain Status</p>
<p class="text-muted-foreground text-sm">
Earn an exclusive prestige title and start fresh
</p>
</div>
</div>
</div>
</Card.Content>
</Card.Root>
{#if !hasMaxPrestige}
<!-- Prestige Requirements -->
<Card.Root class="flex flex-1 flex-col gap-1">
<Card.Header>
<Card.Title class="flex items-center gap-2">
<Star class="h-5 w-5" />
Progress
</Card.Title>
</Card.Header>
<Card.Content class="flex flex-1 flex-col space-y-6">
<!-- Progress Section -->
<div class="space-y-6">
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="font-medium">Progress to {prestigeName}</span>
<span class="font-mono">{progressPercentage.toFixed(1)}%</span>
</div>
<Progress value={progressPercentage} class="h-2" />
</div>
<!-- Financial Details Table -->
<div class="overflow-hidden rounded-xl border">
<table class="w-full text-sm">
<tbody class="divide-y">
<tr>
<td class="text-muted-foreground px-3 py-2 font-medium">Required:</td>
<td class="px-3 py-2 text-right font-mono font-bold">
{formatValue(prestigeCost || 0)}
</td>
</tr>
<tr>
<td class="text-muted-foreground px-3 py-2 font-medium">Your Cash:</td>
<td
class="px-3 py-2 text-right font-mono font-bold"
class:text-green-600={canAfford}
class:text-red-600={!canAfford}
>
{formatValue(currentBalance)}
</td>
</tr>
{#if !canAfford}
<tr>
<td class="text-muted-foreground px-3 py-2 font-medium">Still needed:</td>
<td class="px-3 py-2 text-right font-mono font-bold text-red-600">
{formatValue(amountNeeded)}
</td>
</tr>
{/if}
</tbody>
</table>
</div>
</div>
{#if !canAfford}
<Label>Tip: sell coin holdings</Label>
{:else}
<Alert variant="destructive">
<AlertTriangle class="h-4 w-4" />
<AlertDescription>Prestiging is permanent and cannot be undone!</AlertDescription>
</Alert>
{/if}
<!-- Prestige Button -->
<Button
onclick={canAfford ? openConfirmDialog : undefined}
disabled={!canAfford || isPrestiging}
class="w-full"
size="lg"
variant={canAfford ? 'default' : 'secondary'}
>
{#if isPrestiging}
<Loader2 class="h-4 w-4 animate-spin" />
Advancing to {prestigeName}...
{:else if !canAfford}
Need {formatValue(amountNeeded)} more to prestige
{:else}
Let's go
{/if}
</Button>
</Card.Content>
</Card.Root>
{:else}
<!-- Max Prestige Card -->
<Card.Root class="flex flex-1 flex-col gap-1">
<Card.Content class="py-16 text-center">
<Star class="mx-auto mb-6 h-20 w-20 text-yellow-500" />
<h3 class="mb-3 text-2xl font-bold">You're a star!</h3>
<p class="text-muted-foreground">
You have reached the highest prestige level available.
</p>
</Card.Content>
</Card.Root>
{/if}
<!-- Error Messages -->
{#if error}
<Alert class="mt-6">
<AlertTriangle class="h-4 w-4" />
<AlertDescription class="text-red-600">
{error}
</AlertDescription>
</Alert>
{/if}
</div>
<!-- Right Column - Info -->
<div class="flex flex-col space-y-4">
<!-- Profile Preview Card -->
{#if userData}
<Card.Root class="flex-1 gap-1">
<Card.Header class="pb-2">
<Card.Title class="text-base">Preview</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<!-- Current Profile -->
<div class="space-y-2">
<Label class="text-muted-foreground text-xs">Current</Label>
<div class="flex items-center gap-3 rounded-lg border p-3">
<Avatar.Root class="h-10 w-10 shrink-0">
<Avatar.Image src={getPublicUrl(userData.image)} alt={userData.name} />
<Avatar.Fallback class="text-sm"
>{userData.name?.charAt(0) || '?'}</Avatar.Fallback
>
</Avatar.Root>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<h4 class="truncate text-sm font-medium">{userData.name}</h4>
<ProfileBadges
user={{
...userData,
id: parseInt(userData.id),
prestigeLevel: currentPrestige,
createdAt: new Date(),
totalPortfolioValue: totalValue,
loginStreak: 0
}}
showId={false}
size="sm"
/>
</div>
<p class="text-muted-foreground truncate text-xs">@{userData.username}</p>
</div>
</div>
</div>
<!-- Prestige Preview -->
<div class="space-y-2">
<Label class="text-muted-foreground text-xs">After</Label>
<div
class="flex items-center gap-3 rounded-lg border-2 border-yellow-500/30 bg-yellow-50/50 p-3 dark:bg-yellow-950/20"
>
<Avatar.Root class="h-10 w-10 shrink-0">
<Avatar.Image src={getPublicUrl(userData.image)} alt={userData.name} />
<Avatar.Fallback class="text-sm"
>{userData.name?.charAt(0) || '?'}</Avatar.Fallback
>
</Avatar.Root>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<h4 class="truncate text-sm font-medium">{userData.name}</h4>
<ProfileBadges
user={{
...userData,
id: parseInt(userData.id),
prestigeLevel: nextPrestige,
createdAt: new Date(),
totalPortfolioValue: totalValue,
loginStreak: 0
}}
showId={false}
size="sm"
/>
</div>
<p class="text-muted-foreground truncate text-xs">@{userData.username}</p>
</div>
</div>
</div>
</Card.Content>
</Card.Root>
{/if}
<!-- All Prestige Levels -->
<Card.Root class="flex-1 gap-1">
<Card.Header class="pb-2">
<Card.Title class="text-base">Levels</Card.Title>
</Card.Header>
<Card.Content>
{#each Object.entries(PRESTIGE_COSTS) as [level, cost]}
{@const levelNum = parseInt(level)}
{@const isCurrentNext = levelNum === nextPrestige && !hasMaxPrestige}
{@const isAchieved = levelNum <= currentPrestige}
<div
class="flex items-center justify-between py-1"
class:opacity-50={!isAchieved && !isCurrentNext}
>
<div class="flex items-center gap-2">
{#if isAchieved}
<Star class="h-4 w-4 text-yellow-500" />
{:else if isCurrentNext}
<ChevronRight class="h-4 w-4 text-blue-500" />
{:else}
<div class="h-4 w-4"></div>
{/if}
<span class="text-sm font-medium" class:text-yellow-600={isAchieved}>
{PRESTIGE_NAMES[levelNum as keyof typeof PRESTIGE_NAMES]}
</span>
</div>
<span class="font-mono text-xs">{formatValue(cost)}</span>
</div>
{/each}
</Card.Content>
</Card.Root>
</div>
</div>
{/if}
</div>
<style>
.container {
min-height: calc(100vh - 4rem);
}
</style>