feat: prestige system
This commit is contained in:
parent
08adc11dd0
commit
ec6426781d
17 changed files with 2683 additions and 30 deletions
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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}" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
7
website/src/lib/components/ui/progress/index.ts
Normal file
7
website/src/lib/components/ui/progress/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Root from "./progress.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Progress,
|
||||
};
|
||||
27
website/src/lib/components/ui/progress/progress.svelte
Normal file
27
website/src/lib/components/ui/progress/progress.svelte
Normal 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>
|
||||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export type User = {
|
|||
|
||||
volumeMaster: number;
|
||||
volumeMuted: boolean;
|
||||
|
||||
prestigeLevel: number;
|
||||
} | null;
|
||||
|
||||
export const USER_DATA = writable<User>(undefined);
|
||||
|
|
@ -9,6 +9,8 @@ export interface UserProfile {
|
|||
isAdmin: boolean;
|
||||
totalPortfolioValue: number;
|
||||
loginStreak: number;
|
||||
|
||||
prestigeLevel: number | null;
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Reference in a new issue