Merge branch 'outpoot:main' into main
This commit is contained in:
commit
5e507c3df2
31 changed files with 6196 additions and 109 deletions
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}));
|
||||
|
||||
|
|
|
|||
171
website/src/routes/api/prestige/+server.ts
Normal file
171
website/src/routes/api/prestige/+server.ts
Normal 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
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export async function GET({ params }) {
|
|||
baseCurrencyBalance: true,
|
||||
isAdmin: true,
|
||||
loginStreak: true,
|
||||
prestigeLevel: true,
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
477
website/src/routes/prestige/+page.svelte
Normal file
477
website/src/routes/prestige/+page.svelte
Normal 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>
|
||||
Reference in a new issue