Merge branch 'main' of https://github.com/MD1125/rugplay into pr/77

This commit is contained in:
Face 2025-06-24 12:31:22 +03:00
commit 789fc7cc69
43 changed files with 6943 additions and 153 deletions

View file

@ -42,8 +42,7 @@ export const auth = betterAuth({
s3ImageKey = await uploadProfilePicture(
profile.sub,
new Uint8Array(arrayBuffer),
blob.type,
blob.size
blob.type || 'image/jpeg'
);
}
} catch (error) {

View file

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

View file

@ -1,7 +1,8 @@
<script lang="ts">
import type { UserProfile } from '$lib/types/user-profile';
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 {
user,
@ -14,14 +15,23 @@
} = $props();
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>
<div class="flex items-center gap-1">
{#if showId}
<SilentBadge icon={Hash} class="text-muted-foreground {badgeClass}" text="#{user.id} to join" />
{/if}
{#if prestigeName}
<SilentBadge icon={Star} text={prestigeName} class="{prestigeColor} {badgeClass}" />
{/if}
{#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 user.isAdmin}
<SilentBadge icon={Hammer} text="Admin" class="text-primary {badgeClass}" />

View file

@ -314,8 +314,20 @@
}
}
onMount(() => {
onMount(async () => {
volumeSettings.load();
try {
const response = await fetch('/api/portfolio/summary');
if (!response.ok) {
throw new Error('Failed to fetch portfolio summary');
}
const data = await response.json();
balance = data.baseCurrencyBalance;
onBalanceUpdate?.(data.baseCurrencyBalance);
} catch (error) {
console.error('Failed to fetch balance:', error);
}
});
</script>

View file

@ -0,0 +1,480 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import confetti from 'canvas-confetti';
import { toast } from 'svelte-sonner';
import { formatValue, playSound, showConfetti, showSchoolPrideCannons } from '$lib/utils';
import { volumeSettings } from '$lib/stores/volume-settings';
import { onMount } from 'svelte';
interface DiceResult {
won: boolean;
result: number;
newBalance: number;
payout: number;
amountWagered: number;
}
const MAX_BET_AMOUNT = 1000000;
const baseRotation = "rotate3d(1, 1, 0, 340deg)";
const faceRotations = {
1: { x: 0, y: 0 },
2: { x: 0, y: 90 },
3: { x: 90, y: 0 },
4: { x: -90, y: 0 },
5: { x: 0, y: -90 },
6: { x: 0, y: 180 }
};
const diceRotations = {
1: { x: 0, y: 0, z: 0 },
2: { x: 0, y: -90, z: 0 },
3: { x: -90, y: 0, z: 0 },
4: { x: 90, y: 0, z: 0 },
5: { x: 0, y: 90, z: 0 },
6: { x: 0, y: 180, z: 0 }
};
function getRandInt(from: number, to: number): number {
return Math.round(Math.random() * (to - from)) + from;
}
function getExtraSpin(spinFactor = 4) {
const extraSpinsX = spinFactor * 360;
const extraSpinsY = spinFactor * 360;
const extraSpinsZ = spinFactor * 360;
return {
x: extraSpinsX,
y: extraSpinsY,
z: extraSpinsZ
};
}
function getFaceRotation(face: number) {
return faceRotations[face as keyof typeof faceRotations];
}
function getFaceTransform(face: number): string {
const rotation = getFaceRotation(face);
return `${getRotate(rotation.x, rotation.y)} translateZ(50px)`;
}
function getDiceRotation(face: number, addExtraSpin = false, spinFactor = 4) {
let extraSpin = { x: 0, y: 0, z: 0 };
if (addExtraSpin) {
extraSpin = getExtraSpin(spinFactor);
}
const rotation = diceRotations[face as keyof typeof diceRotations];
return {
x: rotation.x + extraSpin.x,
y: rotation.y + extraSpin.y,
z: rotation.z + extraSpin.z
};
}
function getDiceTransform(face: number, addExtraSpin = false, spinFactor = 4): string {
const rotation = getDiceRotation(face, addExtraSpin, spinFactor);
return `${baseRotation} ${getRotate(rotation.x, rotation.y, rotation.z)}`;
}
function getRotate(x?: number, y?: number, z?: number) {
const rotateX = x !== undefined ? `rotateX(${x}deg)` : "";
const rotateY = y !== undefined ? `rotateY(${y}deg)` : "";
const rotateZ = z !== undefined ? `rotateZ(${z}deg)` : "";
return `${rotateX} ${rotateY} ${rotateZ}`;
}
let {
balance = $bindable(),
onBalanceUpdate
}: {
balance: number;
onBalanceUpdate?: (newBalance: number) => void;
} = $props();
let betAmount = $state(10);
let betAmountDisplay = $state('10');
let selectedNumber = $state(1);
let isRolling = $state(false);
let diceRotation = $state({ x: 0, y: 0 });
let lastResult = $state<DiceResult | null>(null);
let activeSoundTimeouts = $state<NodeJS.Timeout[]>([]);
let canBet = $derived(
betAmount > 0 && betAmount <= balance && betAmount <= MAX_BET_AMOUNT && !isRolling
);
function selectNumber(num: number) {
if (!isRolling) {
selectedNumber = num;
playSound('click');
}
}
function setBetAmount(amount: number) {
const clampedAmount = Math.min(amount, Math.min(balance, MAX_BET_AMOUNT));
if (clampedAmount >= 0) {
betAmount = clampedAmount;
betAmountDisplay = clampedAmount.toLocaleString();
}
}
function handleBetAmountInput(event: Event) {
const target = event.target as HTMLInputElement;
const value = target.value.replace(/,/g, '');
const numValue = parseFloat(value) || 0;
const clampedValue = Math.min(numValue, Math.min(balance, MAX_BET_AMOUNT));
betAmount = clampedValue;
betAmountDisplay = target.value;
}
function handleBetAmountBlur() {
betAmountDisplay = betAmount.toLocaleString();
}
async function rollDice() {
if (!canBet) return;
isRolling = true;
lastResult = null;
activeSoundTimeouts.forEach(clearTimeout);
activeSoundTimeouts = [];
const spinFactor = 20; // Increase / Decrease to make the Spin faster or slower
const animationDuration = 1500; // Duration of the Animation, keep it like thatif you haven't added your own sound in website\static\sound\dice.mp3
try {
const response = await fetch('/api/gambling/dice', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
selectedNumber,
amount: betAmount
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to place bet');
}
const resultData: DiceResult = await response.json();
playSound('dice');
const diceElement = document.querySelector('.dice') as HTMLElement;
if (diceElement) {
diceElement.style.transition = 'none';
diceElement.style.transform = getDiceTransform(selectedNumber, false);
void diceElement.offsetHeight;
diceElement.style.transition = 'transform 1.5s cubic-bezier(0.1, 0.9, 0.1, 1)';
diceElement.style.transform = getDiceTransform(resultData.result, true, spinFactor);
}
await new Promise(resolve => setTimeout(resolve, animationDuration));
await new Promise(resolve => setTimeout(resolve, 200)); // Small delay to show the Result
balance = resultData.newBalance;
lastResult = resultData;
onBalanceUpdate?.(resultData.newBalance);
if (resultData.won) {
showConfetti(confetti);
showSchoolPrideCannons(confetti);
} else {
playSound('lose');
}
isRolling = false;
} catch (error) {
console.error('Dice roll error:', error);
toast.error('Roll failed', {
description: error instanceof Error ? error.message : 'Unknown error occurred'
});
isRolling = false;
activeSoundTimeouts.forEach(clearTimeout);
activeSoundTimeouts = [];
}
}
function getDotsForFace(face: number): number { // Could be made bigger, has no Point though Ig.
switch (face) {
case 1: return 1;
case 2: return 2;
case 3: return 3;
case 4: return 4;
case 5: return 5;
case 6: return 6;
default: return 0;
}
}
// Dynmaically fetch the correct balance.
onMount(async () => {
volumeSettings.load();
try {
const response = await fetch('/api/portfolio/summary');
if (!response.ok) {
throw new Error('Failed to fetch portfolio summary');
}
const data = await response.json();
balance = data.baseCurrencyBalance;
onBalanceUpdate?.(data.baseCurrencyBalance);
} catch (error) {
console.error('Failed to fetch balance:', error);
}
});
</script>
<Card>
<CardHeader>
<CardTitle>Dice</CardTitle>
<CardDescription>Choose a number and roll the dice to win 3x your bet!</CardDescription>
</CardHeader>
<CardContent>
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
<div class="flex flex-col space-y-4">
<div class="text-center">
<p class="text-muted-foreground text-sm">Balance</p>
<p class="text-2xl font-bold">{formatValue(balance)}</p>
</div>
<div class="flex-1 flex items-center justify-center">
<div class="dice-container">
<div class="dice">
{#each Array(6) as _, i}
<div class="face" style="transform: {getFaceTransform(i + 1)}">
<div class="dot-container">
{#each Array(getDotsForFace(i + 1)) as _}
<div class="dot"></div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
<div class="flex items-center justify-center text-center">
{#if lastResult && !isRolling}
<div class="bg-muted/50 w-full rounded-lg p-3">
{#if lastResult.won}
<p class="text-success font-semibold">WIN</p>
<p class="text-sm">
Won {formatValue(lastResult.payout)} on {lastResult.result}
</p>
{:else}
<p class="text-destructive font-semibold">LOSS</p>
<p class="text-sm">
Lost {formatValue(lastResult.amountWagered)} on {lastResult.result}
</p>
{/if}
</div>
{/if}
</div>
</div>
<div class="space-y-4">
<div>
<div class="mb-2 block text-sm font-medium">Choose Number</div>
<div class="grid grid-cols-3 gap-2">
{#each Array(6) as _, i}
<Button
variant={selectedNumber === i + 1 ? 'default' : 'outline'}
onclick={() => selectNumber(i + 1)}
disabled={isRolling}
class="h-16"
>
{i + 1}
</Button>
{/each}
</div>
</div>
<div>
<label for="bet-amount" class="mb-2 block text-sm font-medium">Bet Amount</label>
<Input
id="bet-amount"
type="text"
value={betAmountDisplay}
oninput={handleBetAmountInput}
onblur={handleBetAmountBlur}
disabled={isRolling}
placeholder="Enter bet amount"
/>
<p class="text-muted-foreground mt-1 text-xs">
Max bet: {MAX_BET_AMOUNT.toLocaleString()}
</p>
</div>
<div>
<div class="grid grid-cols-4 gap-2">
<Button
size="sm"
variant="outline"
onclick={() =>
setBetAmount(Math.floor(Math.min(balance || 0, MAX_BET_AMOUNT) * 0.25))}
disabled={isRolling}>25%</Button
>
<Button
size="sm"
variant="outline"
onclick={() => setBetAmount(Math.floor(Math.min(balance || 0, MAX_BET_AMOUNT) * 0.5))}
disabled={isRolling}>50%</Button
>
<Button
size="sm"
variant="outline"
onclick={() =>
setBetAmount(Math.floor(Math.min(balance || 0, MAX_BET_AMOUNT) * 0.75))}
disabled={isRolling}>75%</Button
>
<Button
size="sm"
variant="outline"
onclick={() => setBetAmount(Math.floor(Math.min(balance || 0, MAX_BET_AMOUNT)))}
disabled={isRolling}>Max</Button
>
</div>
</div>
<Button class="h-12 w-full text-lg" onclick={rollDice} disabled={!canBet}>
{isRolling ? 'Rolling...' : 'Roll'}
</Button>
</div>
</div>
</CardContent>
</Card>
<style>
.dice-container {
perspective: 1000px;
}
.dice {
width: 100px;
height: 100px;
transform-style: preserve-3d;
transform: rotate3d(0.9, 1, 0, 340deg);
transition: transform 4s cubic-bezier(0.1, 0.9, 0.1, 1);
}
.face {
position: absolute;
width: 100px;
height: 100px;
background: #fff;
border: 2px solid #363131;
box-sizing: border-box;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
backface-visibility: hidden;
}
.face:nth-child(1) { transform: translateZ(50px); }
.face:nth-child(2) { transform: rotateY(90deg) translateZ(50px); }
.face:nth-child(3) { transform: rotateX(90deg) translateZ(50px); }
.face:nth-child(4) { transform: rotateX(-90deg) translateZ(50px); }
.face:nth-child(5) { transform: rotateY(-90deg) translateZ(50px); }
.face:nth-child(6) { transform: rotateY(180deg) translateZ(50px); }
.dot-container {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
padding: 15%;
box-sizing: border-box;
gap: 10%;
}
.dot {
background: #363131;
border-radius: 50%;
width: 100%;
height: 100%;
}
.face:nth-child(1) .dot-container {
grid-template-areas:
". . ."
". dot ."
". . .";
}
.face:nth-child(1) .dot {
grid-area: dot;
}
.face:nth-child(2) .dot-container {
grid-template-areas:
"dot1 . ."
". . ."
". . dot2";
}
.face:nth-child(2) .dot:nth-child(1) { grid-area: dot1; }
.face:nth-child(2) .dot:nth-child(2) { grid-area: dot2; }
.face:nth-child(3) .dot-container {
grid-template-areas:
"dot1 . ."
". dot2 ."
". . dot3";
}
.face:nth-child(3) .dot:nth-child(1) { grid-area: dot1; }
.face:nth-child(3) .dot:nth-child(2) { grid-area: dot2; }
.face:nth-child(3) .dot:nth-child(3) { grid-area: dot3; }
.face:nth-child(4) .dot-container {
grid-template-areas:
"dot1 . dot2"
". . ."
"dot3 . dot4";
}
.face:nth-child(4) .dot:nth-child(1) { grid-area: dot1; }
.face:nth-child(4) .dot:nth-child(2) { grid-area: dot2; }
.face:nth-child(4) .dot:nth-child(3) { grid-area: dot3; }
.face:nth-child(4) .dot:nth-child(4) { grid-area: dot4; }
.face:nth-child(5) .dot-container {
grid-template-areas:
"dot1 . dot2"
". dot3 ."
"dot4 . dot5";
}
.face:nth-child(5) .dot:nth-child(1) { grid-area: dot1; }
.face:nth-child(5) .dot:nth-child(2) { grid-area: dot2; }
.face:nth-child(5) .dot:nth-child(3) { grid-area: dot3; }
.face:nth-child(5) .dot:nth-child(4) { grid-area: dot4; }
.face:nth-child(5) .dot:nth-child(5) { grid-area: dot5; }
.face:nth-child(6) .dot-container {
grid-template-areas:
"dot1 . dot2"
"dot3 . dot4"
"dot5 . dot6";
}
.face:nth-child(6) .dot:nth-child(1) { grid-area: dot1; }
.face:nth-child(6) .dot:nth-child(2) { grid-area: dot2; }
.face:nth-child(6) .dot:nth-child(3) { grid-area: dot3; }
.face:nth-child(6) .dot:nth-child(4) { grid-area: dot4; }
.face:nth-child(6) .dot:nth-child(5) { grid-area: dot5; }
.face:nth-child(6) .dot:nth-child(6) { grid-area: dot6; }
</style>

View file

@ -132,6 +132,7 @@
}
if (autoCashoutTimer >= AUTO_CASHOUT_TIME) {
isAutoCashout = true;
clearInterval(autoCashoutInterval);
cashOut();
}
}, 100);
@ -279,8 +280,21 @@
}
}
onMount(() => {
// Dynmaically fetch the correct balance.
onMount(async () => {
volumeSettings.load();
try {
const response = await fetch('/api/portfolio/summary');
if (!response.ok) {
throw new Error('Failed to fetch portfolio summary');
}
const data = await response.json();
balance = data.baseCurrencyBalance;
onBalanceUpdate?.(data.baseCurrencyBalance);
} catch (error) {
console.error('Failed to fetch balance:', error);
}
});
onDestroy(() => {
@ -313,7 +327,7 @@
<button
class="mine-tile"
class:revealed={revealedTiles.includes(index)}
class:mine={revealedTiles.includes(index) && minePositions.includes(index) && index === lastClickedTile}
class:mine={revealedTiles.includes(index) && minePositions.includes(index) && !clickedSafeTiles.includes(index)}
class:safe={revealedTiles.includes(index) && !minePositions.includes(index) && clickedSafeTiles.includes(index)}
class:light={document.documentElement.classList.contains('light')}
onclick={() => handleTileClick(index)}
@ -596,4 +610,4 @@
height: 32px;
object-fit: contain;
}
</style>
</style>

View file

@ -211,8 +211,21 @@
}
});
onMount(() => {
// Dynmaically fetch the correct balance.
onMount(async () => {
volumeSettings.load();
try {
const response = await fetch('/api/portfolio/summary');
if (!response.ok) {
throw new Error('Failed to fetch portfolio summary');
}
const data = await response.json();
balance = data.baseCurrencyBalance;
onBalanceUpdate?.(data.baseCurrencyBalance);
} catch (error) {
console.error('Failed to fetch balance:', error);
}
});
</script>

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,
scale: 8,
}).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", {
@ -190,6 +191,7 @@ export const predictionQuestion = pgTable("prediction_question", {
creatorIdIdx: index("prediction_question_creator_id_idx").on(table.creatorId),
statusIdx: index("prediction_question_status_idx").on(table.status),
resolutionDateIdx: index("prediction_question_resolution_date_idx").on(table.resolutionDate),
statusResolutionIdx: index("prediction_question_status_resolution_idx").on(table.status, table.resolutionDate),
};
});

View file

@ -19,17 +19,13 @@ interface MinesSession {
export const activeGames = new Map<string, MinesSession>();
// Clean up old games every minute.
// Clean up old games every minute. (5 Minute system)
setInterval(async () => {
const now = Date.now();
for (const [token, game] of activeGames.entries()) {
// Delete games older than 5 minutes that are still there for some reason.
if (now - game.lastActivity > 5 * 60 * 1000) {
// If no tiles were revealed, refund the bet
if (game.revealedTiles.length === 0) {
try {
console.log(`Processing refund for inactive Mines game ${token} (User: ${game.userId}, Bet: ${game.betAmount})`);
try {
const [userData] = await db
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
@ -48,29 +44,74 @@ setInterval(async () => {
})
.where(eq(user.id, game.userId));
console.log(`Successfully refunded ${game.betAmount} to user ${game.userId}. New balance: ${newBalance}`);
} catch (error) {
console.error(`Failed to refund inactive game ${token}:`, error);
}
} else {
console.log(`Cleaning up inactive game ${token} (User: ${game.userId}) - No refund needed as tiles were revealed`);
}
activeGames.delete(token);
}
}
}, 60000);
setInterval(async () => {
const now = Date.now();
for (const [token, game] of activeGames.entries()) {
if (game.status === 'active' && game.revealedTiles.length > 0 && now - game.lastActivity > 20000) {
try {
const [userData] = await db
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, game.userId))
.for('update')
.limit(1);
const currentBalance = Number(userData.baseCurrencyBalance);
const payout = game.betAmount * game.currentMultiplier;
const roundedPayout = Math.round(payout * 100000000) / 100000000;
const newBalance = Math.round((currentBalance + roundedPayout) * 100000000) / 100000000;
await db
.update(user)
.set({
baseCurrencyBalance: newBalance.toFixed(8),
updatedAt: new Date()
})
.where(eq(user.id, game.userId));
activeGames.delete(token);
} catch (error) {
console.error(`Failed to auto cashout game ${token}:`, error);
}
}
}
}, 15000);
// Rig the game...
const getMaxPayout = (bet: number, picks: number): number => {
const absoluteCap = 5_000_000; // never pay above this. Yeah, its rigged. Live with that :)
const baseCap = 1.4; // 1.4x min multiplier, increase to, well, increase payouts
const growthRate = 0.45; // cap curve sensitivity
// Cap increases with number of successful reveals
const effectiveMultiplierCap = baseCap + Math.pow(picks, growthRate);
const payoutCap = bet * effectiveMultiplierCap;
return Math.min(payoutCap, absoluteCap);
const getMaxPayout = (bet: number, picks: number, mines: number): number => {
const MAX_PAYOUT = 2_000_000; // Maximum payout cap of 2 million to not make linker too rich
const HIGH_BET_THRESHOLD = 50_000;
const mineFactor = 1 + (mines / 25);
const baseMultiplier = (1.4 + Math.pow(picks, 0.45)) * mineFactor;
// For high bets, we stop linker from getting richer ¯\_(ツ)_/¯
if (bet > HIGH_BET_THRESHOLD) {
const betRatio = Math.pow(Math.min(1, (bet - HIGH_BET_THRESHOLD) / (MAX_PAYOUT - HIGH_BET_THRESHOLD)), 1);
// Direct cap on multiplier for high bets
const maxAllowedMultiplier = 1.05 + (picks * 0.1);
const highBetMultiplier = Math.min(baseMultiplier, maxAllowedMultiplier) * (1 - (bet / MAX_PAYOUT) * 0.9);
const betSizeFactor = Math.max(0.1, 1 - (bet / MAX_PAYOUT) * 0.9);
const minMultiplier = (1.1 + (picks * 0.15 * betSizeFactor)) * mineFactor;
const reducedMultiplier = highBetMultiplier - ((highBetMultiplier - minMultiplier) * betRatio);
const payout = Math.min(bet * reducedMultiplier, MAX_PAYOUT);
return payout;
}
const payout = Math.min(bet * baseMultiplier, MAX_PAYOUT);
return payout;
};
@ -78,6 +119,7 @@ export function calculateMultiplier(picks: number, mines: number, betAmount: num
const TOTAL_TILES = 25;
const HOUSE_EDGE = 0.05;
// Calculate probability of winning based on picks and mines
let probability = 1;
for (let i = 0; i < picks; i++) {
probability *= (TOTAL_TILES - mines - i) / (TOTAL_TILES - i);
@ -85,14 +127,15 @@ export function calculateMultiplier(picks: number, mines: number, betAmount: num
if (probability <= 0) return 1.0;
// Calculate fair multiplier based on probability and house edge
const fairMultiplier = (1 / probability) * (1 - HOUSE_EDGE);
const rawPayout = fairMultiplier * betAmount;
const maxPayout = getMaxPayout(betAmount, picks);
const maxPayout = getMaxPayout(betAmount, picks, mines);
const cappedPayout = Math.min(rawPayout, maxPayout);
const effectiveMultiplier = cappedPayout / betAmount;
return Math.max(1.0, effectiveMultiplier);
return Math.max(1.0, Number(effectiveMultiplier.toFixed(2)));
}

View file

@ -0,0 +1,39 @@
import sharp from 'sharp';
const MAX_SIZE = 128;
const WEBP_QUALITY = 50;
export interface ProcessedImage {
buffer: Buffer;
contentType: string;
size: number;
}
export async function processImage(
inputBuffer: Buffer,
): Promise<ProcessedImage> {
try {
const image = sharp(inputBuffer, { animated: true });
const processedBuffer = await image
.resize(MAX_SIZE, MAX_SIZE, {
fit: 'inside',
withoutEnlargement: true
})
.webp({
quality: WEBP_QUALITY,
effort: 6
})
.toBuffer();
return {
buffer: processedBuffer,
contentType: 'image/webp',
size: processedBuffer.length
};
} catch (error) {
console.error('Image processing failed:', error);
throw new Error('Failed to process image');
}
}

View file

@ -38,7 +38,91 @@ export async function resolveExpiredQuestions() {
);
if (resolution.confidence < 50) {
console.log(`Skipping question ${question.id} due to low confidence: ${resolution.confidence}`);
console.log(`Cancelling question ${question.id} due to low confidence: ${resolution.confidence}`);
await db.transaction(async (tx) => {
// Mark question as cancelled
await tx
.update(predictionQuestion)
.set({
status: 'CANCELLED',
resolvedAt: now,
})
.where(eq(predictionQuestion.id, question.id));
// Get all bets for this question
const bets = await tx
.select({
id: predictionBet.id,
userId: predictionBet.userId,
side: predictionBet.side,
amount: predictionBet.amount,
})
.from(predictionBet)
.where(and(
eq(predictionBet.questionId, question.id),
isNull(predictionBet.settledAt)
));
const notificationsToCreate: Array<{
userId: number;
amount: number;
}> = [];
// Refund all bets
for (const bet of bets) {
const refundAmount = Number(bet.amount);
// Mark bet as settled with full refund
await tx
.update(predictionBet)
.set({
actualWinnings: refundAmount.toFixed(8),
settledAt: now,
})
.where(eq(predictionBet.id, bet.id));
// Refund the user
if (bet.userId !== null) {
const [userData] = await tx
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, bet.userId))
.limit(1);
if (userData) {
const newBalance = Number(userData.baseCurrencyBalance) + refundAmount;
await tx
.update(user)
.set({
baseCurrencyBalance: newBalance.toFixed(8),
updatedAt: now,
})
.where(eq(user.id, bet.userId));
}
notificationsToCreate.push({
userId: bet.userId,
amount: refundAmount
});
}
}
// Create refund notifications for all users who had bets
for (const notifData of notificationsToCreate) {
const { userId, amount } = notifData;
const title = 'Prediction skipped 🥀';
const message = `You received a full refund of ${formatValue(amount)} for "${question.question}". We recommend betting on more reliable predictions!`;
await createNotification(
userId.toString(),
'HOPIUM',
title,
message,
);
}
});
continue;
}
@ -139,7 +223,6 @@ export async function resolveExpiredQuestions() {
}
});
console.log(`Successfully resolved question ${question.id}: ${resolution.resolution ? 'YES' : 'NO'} (confidence: ${resolution.confidence}%)`);
} catch (error) {
console.error(`Failed to resolve question ${question.id}:`, error);
}

View file

@ -2,6 +2,7 @@ import { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand } fro
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PRIVATE_B2_KEY_ID, PRIVATE_B2_APP_KEY } from '$env/static/private';
import { PUBLIC_B2_BUCKET, PUBLIC_B2_ENDPOINT, PUBLIC_B2_REGION } from '$env/static/public';
import { processImage } from './image.js';
const s3Client = new S3Client({
endpoint: PUBLIC_B2_ENDPOINT,
@ -47,7 +48,6 @@ export async function uploadProfilePicture(
identifier: string, // Can be user ID or a unique ID from social provider
body: Uint8Array,
contentType: string,
contentLength?: number
): Promise<string> {
if (!contentType || !contentType.startsWith('image/')) {
throw new Error('Invalid file type. Only images are allowed.');
@ -58,17 +58,16 @@ export async function uploadProfilePicture(
throw new Error('Unsupported image format. Only JPEG, PNG, GIF, and WebP are allowed.');
}
let fileExtension = contentType.split('/')[1];
if (fileExtension === 'jpeg') fileExtension = 'jpg';
const key = `avatars/${identifier}.${fileExtension}`;
const processedImage = await processImage(Buffer.from(body));
const key = `avatars/${identifier}.webp`;
const command = new PutObjectCommand({
Bucket: PUBLIC_B2_BUCKET,
Key: key,
Body: body,
ContentType: contentType,
...(contentLength && { ContentLength: contentLength }),
Body: processedImage.buffer,
ContentType: processedImage.contentType,
ContentLength: processedImage.size,
});
await s3Client.send(command);
@ -79,7 +78,6 @@ export async function uploadCoinIcon(
coinSymbol: string,
body: Uint8Array,
contentType: string,
contentLength?: number
): Promise<string> {
if (!contentType || !contentType.startsWith('image/')) {
throw new Error('Invalid file type. Only images are allowed.');
@ -90,17 +88,16 @@ export async function uploadCoinIcon(
throw new Error('Unsupported image format. Only JPEG, PNG, GIF, and WebP are allowed.');
}
let fileExtension = contentType.split('/')[1];
if (fileExtension === 'jpeg') fileExtension = 'jpg';
const processedImage = await processImage(Buffer.from(body));
const key = `coins/${coinSymbol.toLowerCase()}.${fileExtension}`;
const key = `coins/${coinSymbol.toLowerCase()}.webp`;
const command = new PutObjectCommand({
Bucket: PUBLIC_B2_BUCKET,
Key: key,
Body: body,
ContentType: contentType,
...(contentLength && { ContentLength: contentLength }),
Body: processedImage.buffer,
ContentType: processedImage.contentType,
ContentLength: processedImage.size,
});
await s3Client.send(command);

View file

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

View file

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

View file

@ -335,4 +335,50 @@ export const formatMarketCap = formatValue;
export function timeToLocal(originalTime: number): number {
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);
}
}
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

@ -95,11 +95,7 @@
class="text-primary underline hover:cursor-pointer"
onclick={() => (shouldSignIn = !shouldSignIn)}>sign in</button
>
or{' '}
<button
class="text-primary underline hover:cursor-pointer"
onclick={() => (shouldSignIn = !shouldSignIn)}>create an account</button
> to play.
to play.
{/if}
</p>
</header>

View file

@ -49,8 +49,7 @@ async function handleIconUpload(iconFile: File | null, symbol: string): Promise<
return await uploadCoinIcon(
symbol,
new Uint8Array(arrayBuffer),
iconFile.type,
iconFile.size
iconFile.type
);
}

View file

@ -0,0 +1,87 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { randomBytes } from 'crypto';
import type { RequestHandler } from './$types';
interface DiceRequest {
selectedNumber: number;
amount: number;
}
export const POST: RequestHandler = async ({ request }) => {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session?.user) {
throw error(401, 'Not authenticated');
}
try {
const { selectedNumber, amount }: DiceRequest = await request.json();
if (!selectedNumber || selectedNumber < 1 || selectedNumber > 6 || !Number.isInteger(selectedNumber)) {
return json({ error: 'Invalid number selection' }, { status: 400 });
}
if (!amount || amount <= 0 || !Number.isFinite(amount)) {
return json({ error: 'Invalid bet amount' }, { status: 400 });
}
if (amount > 1000000) {
return json({ error: 'Bet amount too large' }, { status: 400 });
}
const userId = Number(session.user.id);
const result = await db.transaction(async (tx) => {
const [userData] = await tx
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, userId))
.for('update')
.limit(1);
const currentBalance = Number(userData.baseCurrencyBalance);
const roundedAmount = Math.round(amount * 100000000) / 100000000;
const roundedBalance = Math.round(currentBalance * 100000000) / 100000000;
if (roundedAmount > roundedBalance) {
throw new Error(`Insufficient funds. You need *${roundedAmount.toFixed(2)} but only have *${roundedBalance.toFixed(2)}`);
}
const gameResult = Math.floor(randomBytes(1)[0] / 42.67) + 1; // This gives us a number between 1-6
const won = gameResult === selectedNumber;
const multiplier = 3;
const payout = won ? roundedAmount * multiplier : 0;
const newBalance = roundedBalance - roundedAmount + payout;
await tx
.update(user)
.set({
baseCurrencyBalance: newBalance.toFixed(8),
updatedAt: new Date()
})
.where(eq(user.id, userId));
return {
won,
result: gameResult,
newBalance,
payout,
amountWagered: roundedAmount
};
});
return json(result);
} catch (e) {
console.error('Dice API error:', e);
const errorMessage = e instanceof Error ? e.message : 'Internal server error';
return json({ error: errorMessage }, { status: 400 });
}
};

View file

@ -47,6 +47,7 @@ export const POST: RequestHandler = async ({ request }) => {
newBalance = Math.round((currentBalance + roundedPayout) * 100000000) / 100000000;
}
await tx
.update(user)
.set({
@ -55,13 +56,15 @@ export const POST: RequestHandler = async ({ request }) => {
})
.where(eq(user.id, userId));
activeGames.delete(sessionToken);
return {
newBalance,
payout,
amountWagered: game.betAmount,
isAbort: game.revealedTiles.length === 0
isAbort: game.revealedTiles.length === 0,
minePositions: game.minePositions
};
});

View file

@ -27,35 +27,46 @@ export const POST: RequestHandler = async ({ request }) => {
return json({ error: 'Tile already revealed' }, { status: 400 });
}
// Update last activity time
game.lastActivity = Date.now();
// Check if hit mine
if (game.minePositions.includes(tileIndex)) {
game.status = 'lost';
const minePositions = game.minePositions;
// Fetch user balance to return after loss
const userId = Number(session.user.id);
const [userData] = await db
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, userId))
.for('update')
.limit(1);
const currentBalance = Number(userData.baseCurrencyBalance);
await db
.update(user)
.set({
baseCurrencyBalance: currentBalance.toFixed(8),
updatedAt: new Date()
})
.where(eq(user.id, userId));
activeGames.delete(sessionToken);
return json({
hitMine: true,
minePositions,
newBalance: Number(userData.baseCurrencyBalance),
status: 'lost'
newBalance: currentBalance,
status: 'lost',
amountWagered: game.betAmount
});
}
// Safe tile
// Safe tile (Yipeee)
game.revealedTiles.push(tileIndex);
game.currentMultiplier = calculateMultiplier(
game.revealedTiles.length,
@ -63,9 +74,38 @@ export const POST: RequestHandler = async ({ request }) => {
game.betAmount
);
// Check if all safe tiles are revealed. Crazy when you get this :)
if (game.revealedTiles.length === 25 - game.mineCount) {
game.status = 'won';
const userId = Number(session.user.id);
const [userData] = await db
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, userId))
.for('update')
.limit(1);
const currentBalance = Number(userData.baseCurrencyBalance);
const payout = game.betAmount * game.currentMultiplier;
const roundedPayout = Math.round(payout * 100000000) / 100000000;
const newBalance = Math.round((currentBalance + roundedPayout) * 100000000) / 100000000;
await db
.update(user)
.set({
baseCurrencyBalance: newBalance.toFixed(8),
updatedAt: new Date()
})
.where(eq(user.id, userId));
activeGames.delete(sessionToken);
return json({
hitMine: false,
currentMultiplier: game.currentMultiplier,
status: 'won',
newBalance,
payout
});
}
return json({

View file

@ -43,6 +43,7 @@ export const POST: RequestHandler = async ({ request }) => {
throw new Error(`Insufficient funds. You need *${roundedAmount.toFixed(2)} but only have *${roundedBalance.toFixed(2)}`);
}
// Generate mine positions
const positions = new Set<number>();
while (positions.size < mineCount) {
@ -53,7 +54,7 @@ export const POST: RequestHandler = async ({ request }) => {
if (!positions.has(i)) safePositions.push(i);
}
// transaction token for authentication
// transaction token for authentication stuff
const randomBytes = new Uint8Array(8);
crypto.getRandomValues(randomBytes);
const sessionToken = Array.from(randomBytes)
@ -61,6 +62,7 @@ export const POST: RequestHandler = async ({ request }) => {
.join('');
const now = Date.now();
const newBalance = roundedBalance - roundedAmount;
// Create session
activeGames.set(sessionToken, {
@ -76,16 +78,20 @@ export const POST: RequestHandler = async ({ request }) => {
userId
});
// Hold bet amount on server to prevent the user from like sending it to another account and farming money without a risk
// Update user balance
await tx
.update(user)
.set({
baseCurrencyBalance: (roundedBalance - roundedAmount).toFixed(8),
baseCurrencyBalance: newBalance.toFixed(8),
updatedAt: new Date()
})
.where(eq(user.id, userId));
return { sessionToken };
return {
sessionToken,
newBalance
};
});
return json(result);

View file

@ -2,7 +2,7 @@ import { auth } from '$lib/auth';
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { predictionQuestion, user, predictionBet } from '$lib/server/db/schema';
import { eq, desc, and, sum, count } from 'drizzle-orm';
import { eq, desc, and, sum, count, or } from 'drizzle-orm';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, request }) => {
@ -24,9 +24,22 @@ export const GET: RequestHandler = async ({ url, request }) => {
const userId = session?.user ? Number(session.user.id) : null;
try {
let statusFilter;
if (status === 'ACTIVE') {
statusFilter = eq(predictionQuestion.status, 'ACTIVE');
} else if (status === 'RESOLVED') {
statusFilter = or(
eq(predictionQuestion.status, 'RESOLVED'),
eq(predictionQuestion.status, 'CANCELLED')
);
} else {
statusFilter = undefined;
}
const conditions = [];
if (status !== 'ALL') {
conditions.push(eq(predictionQuestion.status, status as any));
if (statusFilter) {
conditions.push(statusFilter);
}
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined;

View file

@ -44,28 +44,52 @@ export async function GET({ request }) {
const value = quantity * price;
totalCoinValue += value;
// Calculate average purchase price from buy transactions
const avgPriceResult = await db.select({
avgPrice: sql<number>`
CASE
WHEN SUM(${transaction.quantity}) > 0
THEN SUM(${transaction.totalBaseCurrencyAmount}) / SUM(${transaction.quantity})
ELSE 0
END
`
const allTransactions = await db.select({
type: transaction.type,
quantity: transaction.quantity,
totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount,
timestamp: transaction.timestamp
})
.from(transaction)
.where(
and(
eq(transaction.userId, userId),
eq(transaction.coinId, holding.coinId),
eq(transaction.type, 'BUY')
.from(transaction)
.where(
and(
eq(transaction.userId, userId),
eq(transaction.coinId, holding.coinId)
)
)
);
.orderBy(transaction.timestamp);
const avgPurchasePrice = Number(avgPriceResult[0]?.avgPrice || 0);
const percentageChange = avgPurchasePrice > 0
? ((price - avgPurchasePrice) / avgPurchasePrice) * 100
// calculate cost basis
let remainingQuantity = quantity;
let totalCostBasis = 0;
let runningQuantity = 0;
for (const tx of allTransactions) {
const txQuantity = Number(tx.quantity);
const txAmount = Number(tx.totalBaseCurrencyAmount);
if (tx.type === 'BUY') {
runningQuantity += txQuantity;
// if we still need to account for held coins
if (remainingQuantity > 0) {
const quantityToAttribute = Math.min(txQuantity, remainingQuantity);
const avgPrice = txAmount / txQuantity;
totalCostBasis += quantityToAttribute * avgPrice;
remainingQuantity -= quantityToAttribute;
}
} else if (tx.type === 'SELL') {
runningQuantity -= txQuantity;
}
// if we accounted for all held coins, break
if (remainingQuantity <= 0) break;
}
const avgPurchasePrice = quantity > 0 ? totalCostBasis / quantity : 0;
const percentageChange = totalCostBasis > 0
? ((value - totalCostBasis) / totalCostBasis) * 100
: 0;
return {
@ -76,7 +100,8 @@ export async function GET({ request }) {
value,
change24h: Number(holding.change24h),
avgPurchasePrice,
percentageChange
percentageChange,
costBasis: totalCostBasis
};
}));

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

@ -91,8 +91,7 @@ export async function POST({ request }) {
const key = await uploadProfilePicture(
session.user.id,
new Uint8Array(arrayBuffer),
avatarFile.type,
avatarFile.size
avatarFile.type
);
updates.image = key;
} catch (e) {

View file

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

View file

@ -9,6 +9,7 @@
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
import { Button } from '$lib/components/ui/button';
import SEO from '$lib/components/self/SEO.svelte';
import Dice from '$lib/components/self/games/Dice.svelte'
let shouldSignIn = $state(false);
let balance = $state(0);
@ -78,6 +79,12 @@
>
Mines
</Button>
<Button
variant={activeGame === 'dice' ? 'default' : 'outline'}
onclick={() => (activeGame = 'dice')}
>
Dice
</Button>
</div>
<!-- Game Content -->
@ -87,6 +94,8 @@
<Slots bind:balance onBalanceUpdate={handleBalanceUpdate} />
{:else if activeGame === 'mines'}
<Mines bind:balance onBalanceUpdate={handleBalanceUpdate} />
{:else if activeGame === 'dice'}
<Dice bind:balance onBalanceUpdate={handleBalanceUpdate} />
{/if}
{/if}
</div>

View file

@ -174,7 +174,7 @@
<div class="text-center">
<h1 class="mb-2 flex items-center justify-center gap-2 text-3xl font-bold">
<Sparkles class="h-8 w-8 text-purple-500" />
Hopium<span class="text-xs">[BETA]</span>
Hopium
</h1>
<p class="text-muted-foreground mb-6">
AI-powered prediction markets. Create questions and bet on outcomes.
@ -236,6 +236,11 @@
NO
{/if}
</Badge>
{:else if question.status === 'CANCELLED'}
<Badge variant="outline" class="flex flex-shrink-0 items-center gap-1 text-muted-foreground border-muted-foreground">
<XIcon class="h-3 w-3" />
SKIP
</Badge>
{/if}
<!-- Probability Meter -->

View file

@ -242,6 +242,11 @@
RESOLVED: NO
{/if}
</Badge>
{:else if question.status === 'CANCELLED'}
<Badge variant="outline" class="text-muted-foreground border-muted-foreground">
<XIcon class="h-4 w-4" />
SKIP
</Badge>
{/if}
</div>
</div>
@ -415,7 +420,7 @@
size="lg"
disabled={!customBetAmount ||
Number(customBetAmount) <= 0 ||
Number(customBetAmount) >= userBalance ||
Number(customBetAmount) > userBalance ||
placingBet ||
question.aiResolution !== null}
onclick={placeBet}

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>