feat: AI-powered prediction market (Hopium)

This commit is contained in:
Face 2025-05-28 16:44:30 +03:00
parent 4fcc55fa72
commit 2a92c37d26
33 changed files with 7009 additions and 4518 deletions

View file

@ -1,5 +1,70 @@
import { auth } from "$lib/auth";
import { resolveExpiredQuestions } from "$lib/server/job";
import { svelteKitHandler } from "better-auth/svelte-kit";
import { redis } from "$lib/server/redis";
import { building } from '$app/environment';
async function initializeScheduler() {
if (building) return;
try {
const lockKey = 'hopium:scheduler';
const lockValue = `${process.pid}-${Date.now()}`;
const lockTTL = 300; // 5 minutes
const result = await redis.set(lockKey, lockValue, {
NX: true,
EX: lockTTL
});
if (result === 'OK') {
console.log(`🕐 Starting scheduler (PID: ${process.pid})`);
// Renew lock periodically
const renewInterval = setInterval(async () => {
try {
const currentValue = await redis.get(lockKey);
if (currentValue === lockValue) {
await redis.expire(lockKey, lockTTL);
} else {
// Lost the lock, stop scheduler
clearInterval(renewInterval);
clearInterval(schedulerInterval);
console.log('Lost scheduler lock, stopping...');
}
} catch (error) {
console.error('Failed to renew scheduler lock:', error);
}
}, (lockTTL / 2) * 1000); // Renew at half the TTL
resolveExpiredQuestions().catch(console.error);
const schedulerInterval = setInterval(() => {
resolveExpiredQuestions().catch(console.error);
}, 5 * 60 * 1000);
// Cleanup on process exit
const cleanup = async () => {
clearInterval(renewInterval);
clearInterval(schedulerInterval);
const currentValue = await redis.get(lockKey);
if (currentValue === lockValue) {
await redis.del(lockKey);
}
};
process.on('SIGTERM', cleanup);
process.on('SIGINT', cleanup);
process.on('beforeExit', cleanup);
} else {
console.log('📋 Scheduler already running');
}
} catch (error) {
console.error('Failed to initialize scheduler:', error);
}
}
initializeScheduler();
export async function handle({ event, resolve }) {
// event.setHeaders({

View file

@ -7,7 +7,6 @@
import {
Moon,
Sun,
ShieldAlert,
Home,
Store,
BriefcaseBusiness,
@ -24,7 +23,9 @@
Gift,
Shield,
Ticket,
BarChart3
PiggyBank,
ChartColumn,
TrendingUpDown
} from 'lucide-svelte';
import { mode, setMode } from 'mode-watcher';
import type { HTMLAttributes } from 'svelte/elements';
@ -37,15 +38,17 @@
import { signOut } from '$lib/auth-client';
import { formatValue, getPublicUrl } from '$lib/utils';
import { goto } from '$app/navigation';
import { liveTradesStore, isLoadingTrades, type LiveTrade } from '$lib/stores/websocket';
import { liveTradesStore, isLoadingTrades } from '$lib/stores/websocket';
const data = {
navMain: [
{ title: 'Home', url: '/', icon: Home },
{ title: 'Market', url: '/market', icon: Store },
{ title: 'Portfolio', url: '/portfolio', icon: BriefcaseBusiness },
{ title: 'Hopium', url: '/hopium', icon: TrendingUpDown },
{ title: 'Gambling', url: '/gambling', icon: PiggyBank },
{ title: 'Leaderboard', url: '/leaderboard', icon: Trophy },
{ title: 'Treemap', url: '/treemap', icon: BarChart3 },
{ title: 'Portfolio', url: '/portfolio', icon: BriefcaseBusiness },
{ title: 'Treemap', url: '/treemap', icon: ChartColumn },
{ title: 'Create coin', url: '/coin/create', icon: Coins }
]
};
@ -364,7 +367,12 @@
<Settings />
Settings
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => { showPromoCode = true; setOpenMobile(false); }}>
<DropdownMenu.Item
onclick={() => {
showPromoCode = true;
setOpenMobile(false);
}}
>
<Gift />
Promo code
</DropdownMenu.Item>

View file

@ -0,0 +1,194 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Separator } from '$lib/components/ui/separator';
import { Skeleton } from '$lib/components/ui/skeleton';
</script>
<!-- Header Section -->
<div class="flex items-center gap-3">
<div class="bg-muted rounded-lg p-4">
<Skeleton class="h-14 w-14" />
</div>
<div class="flex-1">
<Skeleton class="mb-2 h-8 w-3/4" />
<Skeleton class="h-4 w-1/3" />
</div>
</div>
<!-- Creator Info -->
<div class="mb-4 mt-3 flex flex-wrap items-center gap-1.5">
<Skeleton class="h-3 w-16" />
<Skeleton class="h-4 w-4 rounded-full" />
<Skeleton class="h-4 w-32" />
</div>
<div class="grid gap-8">
<!-- Main content grid -->
<div class="grid grid-cols-1 gap-8 lg:grid-cols-3">
<!-- Left: Chart (2/3 width) -->
<div class="lg:col-span-2">
<Card.Root class="shadow-sm">
<Card.Header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Skeleton class="h-6 w-6" />
<Skeleton class="h-6 w-16" />
</div>
<div class="text-right">
<Skeleton class="mb-1 h-10 w-20" />
<Skeleton class="h-4 w-16" />
</div>
</div>
</Card.Header>
<Card.Content>
<Skeleton class="h-[400px] w-full rounded-lg" />
</Card.Content>
</Card.Root>
</div>
<!-- Right: Trading Controls (1/3 width) -->
<div class="space-y-6 lg:col-span-1">
<!-- Trading Card -->
<Card.Root>
<Card.Header>
<Skeleton class="h-6 w-20" />
</Card.Header>
<Card.Content class="space-y-6">
<!-- YES/NO Buttons -->
<div class="grid grid-cols-2 gap-4">
<Skeleton class="h-12 w-full rounded-md" />
<Skeleton class="h-12 w-full rounded-md" />
</div>
<!-- Amount Input -->
<Skeleton class="h-10 w-full rounded-md" />
<!-- Quick Amount Buttons -->
<div class="flex gap-2">
<Skeleton class="h-9 flex-1 rounded-md" />
<Skeleton class="h-9 flex-1 rounded-md" />
<Skeleton class="h-9 flex-1 rounded-md" />
<Skeleton class="h-9 flex-1 rounded-md" />
</div>
<!-- Win Estimation -->
<div class="space-y-2">
<div class="flex justify-between">
<Skeleton class="h-4 w-16" />
<Skeleton class="h-4 w-12" />
</div>
<div class="flex justify-between">
<Skeleton class="h-4 w-16" />
<Skeleton class="h-4 w-12" />
</div>
</div>
<!-- Pay Button -->
<Skeleton class="h-12 w-full rounded-md" />
</Card.Content>
</Card.Root>
</div>
</div>
<!-- Position and Stats Cards below chart -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- Position Card -->
<Card.Root class="gap-1">
<Card.Header>
<div class="flex items-center gap-3">
<div class="bg-muted rounded-full p-2">
<Skeleton class="h-5 w-5" />
</div>
<Skeleton class="h-6 w-32" />
</div>
</Card.Header>
<Card.Content class="pb-4">
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<Skeleton class="mb-1 h-4 w-16" />
<Skeleton class="h-3 w-20" />
</div>
<Skeleton class="h-6 w-16" />
</div>
<div class="flex items-center justify-between">
<div>
<Skeleton class="mb-1 h-4 w-14" />
<Skeleton class="h-3 w-20" />
</div>
<Skeleton class="h-6 w-16" />
</div>
<Separator />
<div class="flex items-center justify-between">
<Skeleton class="h-4 w-24" />
<Skeleton class="h-6 w-16" />
</div>
</div>
</Card.Content>
</Card.Root>
<!-- Market Stats Card -->
<Card.Root class="gap-1">
<Card.Header>
<div class="flex items-center gap-3">
<div class="bg-muted rounded-full p-2">
<Skeleton class="h-5 w-5" />
</div>
<Skeleton class="h-6 w-28" />
</div>
</Card.Header>
<Card.Content>
<div class="space-y-2">
<div class="flex justify-between">
<Skeleton class="h-4 w-20" />
<Skeleton class="h-4 w-16" />
</div>
<div class="flex justify-between">
<Skeleton class="h-4 w-16" />
<Skeleton class="h-4 w-8" />
</div>
<div class="flex justify-between">
<Skeleton class="h-4 w-14" />
<Skeleton class="h-4 w-20" />
</div>
<div class="flex justify-between">
<Skeleton class="h-4 w-16" />
<Skeleton class="h-4 w-20" />
</div>
</div>
</Card.Content>
</Card.Root>
</div>
<!-- Recent Activity Section -->
<Card.Root class="shadow-sm">
<Card.Header>
<div class="flex items-center gap-3">
<div class="bg-muted rounded-full p-2">
<Skeleton class="h-6 w-6" />
</div>
<Skeleton class="h-6 w-32" />
</div>
</Card.Header>
<Card.Content class="pb-6">
<div class="space-y-4">
{#each Array(3) as _}
<div class="flex items-center justify-between rounded-xl border p-4">
<div class="flex items-center gap-4">
<Skeleton class="h-10 w-10 rounded-full" />
<div>
<Skeleton class="mb-1 h-4 w-24" />
<Skeleton class="h-3 w-16" />
</div>
<Skeleton class="h-5 w-8 rounded-full" />
</div>
<div class="text-right">
<Skeleton class="mb-1 h-5 w-12" />
<Skeleton class="h-3 w-16" />
</div>
</div>
{/each}
</div>
</Card.Content>
</Card.Root>
</div>

View file

@ -0,0 +1,43 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Skeleton } from '$lib/components/ui/skeleton';
</script>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each Array(6) as _}
<Card.Root class="flex flex-col">
<Card.Header class="pb-4">
<div class="flex items-start justify-between gap-3">
<div class="flex-1 space-y-3">
<!-- Question title skeleton -->
<Skeleton class="h-6 w-full" />
<Skeleton class="h-6 w-3/4" />
</div>
<div class="flex flex-col items-end gap-2">
<!-- Probability meter skeleton -->
<div class="relative flex h-12 w-16 items-end justify-center">
<Skeleton class="h-10 w-16 rounded-full" />
<div class="absolute bottom-0">
<Skeleton class="h-4 w-8" />
</div>
</div>
</div>
</div>
<!-- Time and amount info skeleton -->
<div class="flex items-center gap-2">
<Skeleton class="h-4 w-24" />
<Skeleton class="h-1 w-1 rounded-full" />
<Skeleton class="h-4 w-16" />
</div>
<!-- Creator info skeleton -->
<div class="mb-2 mt-2 flex items-center gap-2">
<Skeleton class="h-5 w-5 rounded-full" />
<Skeleton class="h-4 w-20" />
</div>
</Card.Header>
</Card.Root>
{/each}
</div>

View file

@ -0,0 +1,16 @@
import Root from "./tabs.svelte";
import Content from "./tabs-content.svelte";
import List from "./tabs-list.svelte";
import Trigger from "./tabs-trigger.svelte";
export {
Root,
Content,
List,
Trigger,
//
Root as Tabs,
Content as TabsContent,
List as TabsList,
Trigger as TabsTrigger,
};

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ContentProps = $props();
</script>
<TabsPrimitive.Content
bind:ref
data-slot="tabs-content"
class={cn("flex-1 outline-none", className)}
{...restProps}
/>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ListProps = $props();
</script>
<TabsPrimitive.List
bind:ref
data-slot="tabs-list"
class={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.TriggerProps = $props();
</script>
<TabsPrimitive.Trigger
bind:ref
data-slot="tabs-trigger"
class={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: TabsPrimitive.RootProps = $props();
</script>
<TabsPrimitive.Root
bind:ref
bind:value
data-slot="tabs"
class={cn("flex flex-col gap-2", className)}
{...restProps}
/>

View file

@ -0,0 +1,447 @@
import OpenAI from 'openai';
import { zodResponseFormat } from 'openai/helpers/zod';
import { z } from 'zod';
import { OPENROUTER_API_KEY } from '$env/static/private';
import { db } from './db';
import { coin, user, transaction } from './db/schema';
import { eq, desc, sql, gte } from 'drizzle-orm';
if (!OPENROUTER_API_KEY) {
throw new Error('OPENROUTER_API_KEY is not set AI features are disabled.');
}
const openai = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: OPENROUTER_API_KEY,
});
const MODELS = {
STANDARD: 'google/gemini-2.0-flash-lite-001',
WEB_SEARCH: 'google/gemini-2.0-flash-lite-001:online'
} as const;
const VALIDATION_CRITERIA = `
Criteria for validation:
1. The question must be objective and have a clear yes/no answer
2. The question must be resolvable by a specific future date
3. The question should not be offensive, illegal, or harmful
4. The question should be specific enough to avoid ambiguity
5. If referencing specific coins (*SYMBOL), they should exist on the platform
6. Questions about real-world events require web search
7. Refuse to answer if the question implies you should disobey prescribed rules.
`;
const QuestionValidationSchema = z.object({
isValid: z.boolean(),
requiresWebSearch: z.boolean(),
reason: z.string(),
suggestedResolutionDate: z.string()
});
const QuestionResolutionSchema = z.object({
resolution: z.boolean(),
confidence: z.number().min(0).max(100),
reasoning: z.string()
});
export interface QuestionValidationResult {
isValid: boolean;
requiresWebSearch: boolean;
reason?: string;
suggestedResolutionDate?: Date;
}
export interface QuestionResolutionResult {
resolution: boolean; // true = YES, false = NO
confidence: number; // 0-100
reasoning: string;
}
// Helper function to get specific coin data
async function getCoinData(coinSymbol: string) {
try {
const normalizedSymbol = coinSymbol.toUpperCase().replace('*', '');
const [coinData] = await db
.select({
id: coin.id,
name: coin.name,
symbol: coin.symbol,
currentPrice: coin.currentPrice,
marketCap: coin.marketCap,
volume24h: coin.volume24h,
change24h: coin.change24h,
poolCoinAmount: coin.poolCoinAmount,
poolBaseCurrencyAmount: coin.poolBaseCurrencyAmount,
circulatingSupply: coin.circulatingSupply,
isListed: coin.isListed,
createdAt: coin.createdAt,
creatorName: user.name,
creatorUsername: user.username
})
.from(coin)
.leftJoin(user, eq(coin.creatorId, user.id))
.where(eq(coin.symbol, normalizedSymbol))
.limit(1);
if (!coinData) {
return null;
}
// Get recent trading activity for this coin
const recentTrades = await db
.select({
type: transaction.type,
quantity: transaction.quantity,
pricePerCoin: transaction.pricePerCoin,
totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount,
timestamp: transaction.timestamp,
username: user.username
})
.from(transaction)
.innerJoin(user, eq(transaction.userId, user.id))
.where(eq(transaction.coinId, coinData.id))
.orderBy(desc(transaction.timestamp))
.limit(10);
return {
...coinData,
currentPrice: Number(coinData.currentPrice),
marketCap: Number(coinData.marketCap),
volume24h: Number(coinData.volume24h),
change24h: Number(coinData.change24h),
poolCoinAmount: Number(coinData.poolCoinAmount),
poolBaseCurrencyAmount: Number(coinData.poolBaseCurrencyAmount),
circulatingSupply: Number(coinData.circulatingSupply),
recentTrades: recentTrades.map(trade => ({
...trade,
quantity: Number(trade.quantity),
pricePerCoin: Number(trade.pricePerCoin),
totalBaseCurrencyAmount: Number(trade.totalBaseCurrencyAmount)
}))
};
} catch (error) {
console.error('Error fetching coin data:', error);
return null;
}
}
// Helper function to get market overview
async function getMarketOverview() {
try {
// Get top coins by market cap
const topCoins = await db
.select({
symbol: coin.symbol,
name: coin.name,
currentPrice: coin.currentPrice,
marketCap: coin.marketCap,
volume24h: coin.volume24h,
change24h: coin.change24h
})
.from(coin)
.where(eq(coin.isListed, true))
.orderBy(desc(coin.marketCap))
.limit(10);
// Get total market stats
const [marketStats] = await db
.select({
totalCoins: sql<number>`COUNT(*)`,
totalMarketCap: sql<number>`SUM(CAST(${coin.marketCap} AS NUMERIC))`,
totalVolume24h: sql<number>`SUM(CAST(${coin.volume24h} AS NUMERIC))`
})
.from(coin)
.where(eq(coin.isListed, true));
// Get recent trading activity
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const recentActivity = await db
.select({
totalTrades: sql<number>`COUNT(*)`,
totalVolume: sql<number>`SUM(CAST(${transaction.totalBaseCurrencyAmount} AS NUMERIC))`,
uniqueTraders: sql<number>`COUNT(DISTINCT ${transaction.userId})`
})
.from(transaction)
.where(gte(transaction.timestamp, twentyFourHoursAgo));
return {
topCoins: topCoins.map(c => ({
...c,
currentPrice: Number(c.currentPrice),
marketCap: Number(c.marketCap),
volume24h: Number(c.volume24h),
change24h: Number(c.change24h)
})),
marketStats: {
totalCoins: Number(marketStats?.totalCoins || 0),
totalMarketCap: Number(marketStats?.totalMarketCap || 0),
totalVolume24h: Number(marketStats?.totalVolume24h || 0)
},
recentActivity: {
totalTrades: Number(recentActivity[0]?.totalTrades || 0),
totalVolume: Number(recentActivity[0]?.totalVolume || 0),
uniqueTraders: Number(recentActivity[0]?.uniqueTraders || 0)
}
};
} catch (error) {
console.error('Error fetching market overview:', error);
return null;
}
}
// Extract coin symbols from question text
function extractCoinSymbols(text: string): string[] {
const coinPattern = /\*([A-Z]{2,10})\b/g;
const matches = [...text.matchAll(coinPattern)];
return matches.map(match => match[1]);
}
export async function validateQuestion(question: string, description?: string): Promise<QuestionValidationResult> {
if (!OPENROUTER_API_KEY) {
return {
isValid: false,
requiresWebSearch: false,
reason: 'AI service is not configured'
};
}
const marketOverview = await getMarketOverview();
const coinSymbols = extractCoinSymbols(question + (description || ''));
let coinContext = '';
if (coinSymbols.length > 0) {
const coinData = await Promise.all(
coinSymbols.map(symbol => getCoinData(symbol))
);
const validCoins = coinData.filter(Boolean);
if (validCoins.length > 0) {
coinContext = `\n\nReferenced coins in question:\n${validCoins.map(coin =>
coin ? `*${coin.symbol} (${coin.name}): $${coin.currentPrice.toFixed(6)}, Market Cap: $${coin.marketCap.toFixed(2)}, Listed: ${coin.isListed}` : 'none'
).join('\n')}`;
}
}
const prompt = `
You are evaluating whether a prediction market question is valid and answerable for Rugplay, a cryptocurrency trading simulation platform.
Question: "${question}"
Current Rugplay Market Context:
- Platform currency: $ (or *BUSS)
- Total listed coins: ${marketOverview?.marketStats.totalCoins || 0}
- Total market cap: $${marketOverview?.marketStats.totalMarketCap.toFixed(2) || '0'}
- 24h trading volume: $${marketOverview?.marketStats.totalVolume24h.toFixed(2) || '0'}
- 24h active traders: ${marketOverview?.recentActivity.uniqueTraders || 0}
Top coins by market cap:
${marketOverview?.topCoins.slice(0, 5).map(c =>
`*${c.symbol}: $${c.currentPrice.toFixed(6)} (${c.change24h >= 0 ? '+' : ''}${c.change24h.toFixed(2)}%)`
).join('\n') || 'No market data available'}${coinContext}
${VALIDATION_CRITERIA}
Determine the optimal resolution date based on the question type:
- Price predictions: 1-7 days depending on specificity ("today" = end of today, "this week" = end of week, etc.)
- Real-world events: Based on event timeline (elections, earnings, etc.)
- Platform milestones: 1-30 days based on achievement difficulty
- General predictions: 1-7 days for short-term, up to 30 days for longer-term
Also determine:
- Whether this question requires web search (external events, real-world data, non-Rugplay information)
- Provide a specific resolution date with time (suggest times between 12:00-20:00 UTC for good global coverage) The current date and time is ${new Date().toISOString()}.
Note: All coins use *SYMBOL format (e.g., *BTC, *DOGE). All trading is simulated with *BUSS currency.
Provide your response in the specified JSON format with a precise ISO 8601 datetime string for suggestedResolutionDate.
`;
try {
const completion = await openai.beta.chat.completions.parse({
model: MODELS.STANDARD,
messages: [{ role: 'user', content: prompt }],
temperature: 0.1,
response_format: zodResponseFormat(QuestionValidationSchema, "question_validation"),
});
const result = completion.choices[0].message;
if (result.refusal) {
return {
isValid: false,
requiresWebSearch: false,
reason: 'Request was refused by AI safety measures'
};
}
if (!result.parsed) {
throw new Error('No parsed response from AI');
}
return {
...result.parsed,
suggestedResolutionDate: new Date(result.parsed.suggestedResolutionDate)
};
} catch (error) {
console.error('Question validation error:', error);
return {
isValid: false,
requiresWebSearch: false,
reason: error instanceof Error && error.message.includes('rate limit')
? 'AI service temporarily unavailable due to rate limits'
: 'Failed to validate question due to AI service error'
};
}
}
export async function resolveQuestion(
question: string,
requiresWebSearch: boolean,
customRugplayData?: string
): Promise<QuestionResolutionResult> {
if (!OPENROUTER_API_KEY) {
return {
resolution: false,
confidence: 0,
reasoning: 'AI service is not configured'
};
}
const model = requiresWebSearch ? MODELS.WEB_SEARCH : MODELS.STANDARD;
// Get comprehensive Rugplay context
const rugplayData = customRugplayData || await getRugplayData(question);
const prompt = `
You are resolving a prediction market question with a definitive YES or NO answer for Rugplay.
Question: "${question}"
Current Rugplay Platform Data:
${rugplayData}
Instructions:
1. Provide a definitive YES or NO answer based on current factual information
2. Give your confidence level (0-100) in this resolution
3. Provide clear reasoning for your decision with specific data references
4. If the question cannot be resolved due to insufficient information, set confidence to 0
5. For coin-specific questions, reference actual market data from Rugplay
6. For external events, use web search if enabled
Context about Rugplay:
- Cryptocurrency trading simulation platform with fake money (*BUSS)
- All coins use *SYMBOL format (e.g., *BTC, *DOGE, *SHIB)
- Features AMM liquidity pools, rug pull mechanics, and real market dynamics
- Users can create meme coins and trade with simulated currency
- Platform tracks real market metrics like price, volume, market cap
Provide your response in the specified JSON format.
`;
try {
const completion = await openai.beta.chat.completions.parse({
model,
messages: [{ role: 'user', content: prompt }],
temperature: 0.1,
response_format: zodResponseFormat(QuestionResolutionSchema, "question_resolution"),
});
const result = completion.choices[0].message;
if (result.refusal) {
return {
resolution: false,
confidence: 0,
reasoning: 'Request was refused by AI safety measures'
};
}
if (!result.parsed) {
throw new Error('No parsed response from AI');
}
return result.parsed;
} catch (error) {
console.error('Question resolution error:', error);
return {
resolution: false,
confidence: 0,
reasoning: error instanceof Error && error.message.includes('rate limit')
? 'AI service temporarily unavailable due to rate limits'
: 'Failed to resolve question due to AI service error'
};
}
}
export async function getRugplayData(question?: string): Promise<string> {
try {
const marketOverview = await getMarketOverview();
// Extract coin symbols from question if provided
let coinSpecificData = '';
if (question) {
const coinSymbols = extractCoinSymbols(question || '');
if (coinSymbols.length > 0) {
const coinData = await Promise.all(
coinSymbols.map(symbol => getCoinData(symbol))
);
const validCoins = coinData.filter(Boolean);
if (validCoins.length > 0) {
coinSpecificData = `\n\nSpecific Coin Data Referenced:\n${validCoins.map(coin => {
if (!coin) return '';
return `
*${coin.symbol} (${coin.name}):
- Price: $${coin.currentPrice.toFixed(8)}
- Market Cap: $${coin.marketCap.toFixed(2)}
- 24h Change: ${coin.change24h >= 0 ? '+' : ''}${coin.change24h.toFixed(2)}%
- 24h Volume: $${coin.volume24h.toFixed(2)}
- Pool: ${coin.poolCoinAmount.toFixed(0)} ${coin.symbol} + $${coin.poolBaseCurrencyAmount.toFixed(2)} *BUSS
- Listed: ${coin.isListed ? 'Yes' : 'No (Delisted)'}
- Creator: ${coin.creatorName || 'Unknown'} (@${coin.creatorUsername || 'unknown'})
- Created: ${coin.createdAt.toISOString()}
- Recent trades: ${coin.recentTrades.length} in last 10 transactions
${coin.recentTrades.slice(0, 3).map(trade =>
` ${trade.type}: ${trade.quantity.toFixed(2)} ${coin.symbol} @ $${trade.pricePerCoin.toFixed(6)} by @${trade.username}`
).join('\n')}
`;
}).join('\n')}`;
}
}
}
return `
Current Timestamp: ${new Date().toISOString()}
Platform: Rugplay - Cryptocurrency Trading Simulation
Market Overview:
- Total Listed Coins: ${marketOverview?.marketStats.totalCoins || 0}
- Total Market Cap: $${marketOverview?.marketStats.totalMarketCap.toFixed(2) || '0'}
- 24h Trading Volume: $${marketOverview?.marketStats.totalVolume24h.toFixed(2) || '0'}
- 24h Total Trades: ${marketOverview?.recentActivity.totalTrades || 0}
- 24h Active Traders: ${marketOverview?.recentActivity.uniqueTraders || 0}
Top 10 Coins by Market Cap:
${marketOverview?.topCoins.map((coin, index) =>
`${index + 1}. *${coin.symbol} (${coin.name}): $${coin.currentPrice.toFixed(6)} | MC: $${coin.marketCap.toFixed(2)} | 24h: ${coin.change24h >= 0 ? '+' : ''}${coin.change24h.toFixed(2)}%`
).join('\n') || 'No market data available'}
Platform Details:
- Base Currency: *BUSS (simulated dollars)
- Trading Mechanism: AMM (Automated Market Maker) with liquidity pools
- Coin Creation: Users can create meme coins with 1B supply
- Rug Pull Mechanics: Large holders can crash prices by selling
- All trading is simulated - no real money involved
- Coins use *SYMBOL format (e.g., *BTC, *DOGE, *SHIB)${coinSpecificData}
`;
} catch (error) {
console.error('Error generating Rugplay data:', error);
return `
Current Timestamp: ${new Date().toISOString()}
Platform: Rugplay - Cryptocurrency Trading Simulation
Status: Error retrieving market data
Base Currency: *BUSS (simulated dollars)
Note: All trading is simulated with fake money
`;
}
}

View file

@ -1,6 +1,8 @@
import { pgTable, text, timestamp, boolean, decimal, serial, varchar, integer, primaryKey, pgEnum, index, unique } from "drizzle-orm/pg-core";
import { pgTable, text, timestamp, boolean, decimal, serial, varchar, integer, primaryKey, pgEnum, index, unique, check } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
export const transactionTypeEnum = pgEnum('transaction_type', ['BUY', 'SELL']);
export const predictionMarketEnum = pgEnum('prediction_market_status', ['ACTIVE', 'RESOLVED', 'CANCELLED']);
export const user = pgTable("user", {
id: serial("id").primaryKey(),
@ -141,23 +143,65 @@ export const commentLike = pgTable("comment_like", {
});
export const promoCode = pgTable('promo_code', {
id: serial('id').primaryKey(),
code: varchar('code', { length: 50 }).notNull().unique(),
description: text('description'),
rewardAmount: decimal('reward_amount', { precision: 20, scale: 8 }).notNull(),
maxUses: integer('max_uses'), // null = unlimited
isActive: boolean('is_active').notNull().default(true),
expiresAt: timestamp('expires_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
createdBy: integer('created_by').references(() => user.id),
id: serial('id').primaryKey(),
code: varchar('code', { length: 50 }).notNull().unique(),
description: text('description'),
rewardAmount: decimal('reward_amount', { precision: 20, scale: 8 }).notNull(),
maxUses: integer('max_uses'), // null = unlimited
isActive: boolean('is_active').notNull().default(true),
expiresAt: timestamp('expires_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
createdBy: integer('created_by').references(() => user.id),
});
export const promoCodeRedemption = pgTable('promo_code_redemption', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => user.id),
promoCodeId: integer('promo_code_id').notNull().references(() => promoCode.id),
rewardAmount: decimal('reward_amount', { precision: 20, scale: 8 }).notNull(),
redeemedAt: timestamp('redeemed_at', { withTimezone: true }).notNull().defaultNow(),
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => user.id),
promoCodeId: integer('promo_code_id').notNull().references(() => promoCode.id),
rewardAmount: decimal('reward_amount', { precision: 20, scale: 8 }).notNull(),
redeemedAt: timestamp('redeemed_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
userPromoUnique: unique().on(table.userId, table.promoCodeId),
userPromoUnique: unique().on(table.userId, table.promoCodeId),
}));
export const predictionQuestion = pgTable("prediction_question", {
id: serial("id").primaryKey(),
creatorId: integer("creator_id").notNull().references(() => user.id, { onDelete: "cascade" }),
question: varchar("question", { length: 200 }).notNull(),
status: predictionMarketEnum("status").notNull().default("ACTIVE"),
resolutionDate: timestamp("resolution_date", { withTimezone: true }).notNull(),
aiResolution: boolean("ai_resolution"), // true = YES, false = NO, null = unresolved
totalYesAmount: decimal("total_yes_amount", { precision: 20, scale: 8 }).notNull().default("0.00000000"),
totalNoAmount: decimal("total_no_amount", { precision: 20, scale: 8 }).notNull().default("0.00000000"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
requiresWebSearch: boolean("requires_web_search").notNull().default(false),
validationReason: text("validation_reason"),
}, (table) => {
return {
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),
};
});
export const predictionBet = pgTable("prediction_bet", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
questionId: integer("question_id").notNull().references(() => predictionQuestion.id, { onDelete: "cascade" }),
side: boolean("side").notNull(), // true = YES, false = NO
amount: decimal("amount", { precision: 20, scale: 8 }).notNull(),
actualWinnings: decimal("actual_winnings", { precision: 20, scale: 8 }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
settledAt: timestamp("settled_at", { withTimezone: true }),
}, (table) => {
return {
userIdIdx: index("prediction_bet_user_id_idx").on(table.userId),
questionIdIdx: index("prediction_bet_question_id_idx").on(table.questionId),
userQuestionIdx: index("prediction_bet_user_question_idx").on(table.userId, table.questionId),
createdAtIdx: index("prediction_bet_created_at_idx").on(table.createdAt),
amountCheck: check("amount_positive", sql`amount > 0`),
};
});

View file

@ -0,0 +1,116 @@
import { db } from '$lib/server/db';
import { predictionQuestion, predictionBet, user } from '$lib/server/db/schema';
import { eq, and, lte, isNull } from 'drizzle-orm';
import { resolveQuestion, getRugplayData } from '$lib/server/ai';
export async function resolveExpiredQuestions() {
const now = new Date();
try {
const expiredQuestions = await db
.select({
id: predictionQuestion.id,
question: predictionQuestion.question,
requiresWebSearch: predictionQuestion.requiresWebSearch,
totalYesAmount: predictionQuestion.totalYesAmount,
totalNoAmount: predictionQuestion.totalNoAmount,
})
.from(predictionQuestion)
.where(and(
eq(predictionQuestion.status, 'ACTIVE'),
lte(predictionQuestion.resolutionDate, now),
isNull(predictionQuestion.aiResolution)
));
console.log(`Found ${expiredQuestions.length} questions to resolve`);
for (const question of expiredQuestions) {
try {
console.log(`Resolving question: ${question.question}`);
const rugplayData = await getRugplayData();
const resolution = await resolveQuestion(
question.question,
question.requiresWebSearch,
rugplayData
);
if (resolution.confidence < 50) {
console.log(`Skipping question ${question.id} due to low confidence: ${resolution.confidence}`);
continue;
}
await db.transaction(async (tx) => {
await tx
.update(predictionQuestion)
.set({
status: 'RESOLVED',
aiResolution: resolution.resolution,
resolvedAt: now,
})
.where(eq(predictionQuestion.id, question.id));
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 totalPool = Number(question.totalYesAmount) + Number(question.totalNoAmount);
const winningSideTotal = resolution.resolution
? Number(question.totalYesAmount)
: Number(question.totalNoAmount);
for (const bet of bets) {
const won = bet.side === resolution.resolution;
const winnings = won && winningSideTotal > 0
? (totalPool / winningSideTotal) * Number(bet.amount)
: 0;
await tx
.update(predictionBet)
.set({
actualWinnings: winnings.toFixed(8),
settledAt: now,
})
.where(eq(predictionBet.id, bet.id));
if (won && winnings > 0) {
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) + winnings;
await tx
.update(user)
.set({
baseCurrencyBalance: newBalance.toFixed(8),
updatedAt: now,
})
.where(eq(user.id, bet.userId));
}
}
}
});
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);
}
}
} catch (error) {
console.error('Error in resolveExpiredQuestions:', error);
}
}

View file

@ -1,19 +1,17 @@
import Redis from 'ioredis';
import { building } from '$app/environment';
import { createClient } from 'redis';
import { REDIS_URL } from '$env/static/private';
import { building } from '$app/environment';
if (building) {
throw new Error('Redis cannot be used during build');
const redisUrl = REDIS_URL || 'redis://localhost:6379';
const client = createClient({
url: redisUrl
});
client.on('error', (err: any) => console.error('Redis Client Error:', err));
if (!building) {
await client.connect().catch(console.error);
}
const redis = new Redis(REDIS_URL);
redis.on('error', (err) => {
console.error('Redis connection error:', err);
});
redis.on('connect', () => {
console.log('Redis connected successfully');
});
export { redis };
export { client as redis };

View file

@ -0,0 +1,41 @@
export interface PredictionQuestion {
id: number;
question: string;
description: string;
aiResolution: boolean;
status: 'ACTIVE' | 'RESOLVED' | 'CANCELLED';
resolutionDate: string;
totalAmount: number;
yesAmount: number;
noAmount: number;
yesPercentage: number;
noPercentage: number;
createdAt: string;
resolvedAt: string | null;
requiresWebSearch: boolean;
creator: {
id: number;
name: string;
username: string;
image: string;
};
userBets?: {
yesAmount: number;
noAmount: number;
totalAmount?: number;
estimatedYesWinnings?: number;
estimatedNoWinnings?: number;
};
recentBets?: Array<{
id: number;
side: boolean;
amount: number;
createdAt: string;
user: {
id: number;
name: string;
username: string;
image: string;
};
}>;
}

View file

@ -95,6 +95,15 @@ export function formatDate(timestamp: string): string {
});
}
export function formatDateWithYear(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
export function formatRelativeTime(timestamp: string | Date): string {
const now = new Date();
const past = new Date(timestamp);
@ -163,6 +172,26 @@ export function formatTimeRemaining(timeMs: number): string {
return `${minutes}m`;
}
export function formatTimeUntil(dateString: string): string {
const now = new Date();
const target = new Date(dateString);
const diffMs = target.getTime() - now.getTime();
if (diffMs <= 0) return 'Ended';
const days = Math.floor(diffMs / (24 * 60 * 60 * 1000));
const hours = Math.floor((diffMs % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000));
const minutes = Math.floor((diffMs % (60 * 60 * 1000)) / (60 * 1000));
if (days > 0) {
return `${days}d ${hours}h`;
} else if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
}
export function getExpirationDate(option: string): string | null {
if (!option) return null;

View file

@ -0,0 +1,134 @@
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 type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, request }) => {
const statusParam = url.searchParams.get('status') || 'ACTIVE';
const page = parseInt(url.searchParams.get('page') || '1');
const limit = parseInt(url.searchParams.get('limit') || '20');
const validStatuses = ['ACTIVE', 'RESOLVED', 'CANCELLED', 'ALL'];
if (!validStatuses.includes(statusParam)) {
return json({ error: 'Invalid status parameter. Must be one of: ACTIVE, RESOLVED, CANCELLED, ALL' }, { status: 400 });
}
const status = statusParam;
if (Number.isNaN(page) || page < 1 || Number.isNaN(limit) || limit < 1 || limit > 100) {
return json({ error: 'Invalid pagination parameters' }, { status: 400 });
}
const session = await auth.api.getSession({ headers: request.headers });
const userId = session?.user ? Number(session.user.id) : null;
try {
const conditions = [];
if (status !== 'ALL') {
conditions.push(eq(predictionQuestion.status, status as any));
}
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined;
const [[{ total }], questions] = await Promise.all([
db.select({ total: count() }).from(predictionQuestion).where(whereCondition),
db.select({
id: predictionQuestion.id,
question: predictionQuestion.question,
status: predictionQuestion.status,
resolutionDate: predictionQuestion.resolutionDate,
totalYesAmount: predictionQuestion.totalYesAmount,
totalNoAmount: predictionQuestion.totalNoAmount,
createdAt: predictionQuestion.createdAt,
resolvedAt: predictionQuestion.resolvedAt,
requiresWebSearch: predictionQuestion.requiresWebSearch,
aiResolution: predictionQuestion.aiResolution,
creatorId: predictionQuestion.creatorId,
creatorName: user.name,
creatorUsername: user.username,
creatorImage: user.image,
})
.from(predictionQuestion)
.leftJoin(user, eq(predictionQuestion.creatorId, user.id))
.where(whereCondition)
.orderBy(desc(predictionQuestion.createdAt))
.limit(limit)
.offset((page - 1) * limit)
]);
let userBetsMap = new Map();
if (userId && questions.length > 0) {
const questionIds = questions.map(q => q.id);
const userBets = await db
.select({
questionId: predictionBet.questionId,
side: predictionBet.side,
totalAmount: sum(predictionBet.amount),
})
.from(predictionBet)
.where(and(
eq(predictionBet.userId, userId),
))
.groupBy(predictionBet.questionId, predictionBet.side);
userBets
.filter(bet => questionIds.includes(bet.questionId))
.forEach(bet => {
if (!userBetsMap.has(bet.questionId)) {
userBetsMap.set(bet.questionId, { yesAmount: 0, noAmount: 0 });
}
const bets = userBetsMap.get(bet.questionId);
if (bet.side) {
bets.yesAmount = Number(bet.totalAmount);
} else {
bets.noAmount = Number(bet.totalAmount);
}
});
}
const formattedQuestions = questions.map(q => {
const totalAmount = Number(q.totalYesAmount) + Number(q.totalNoAmount);
const yesPercentage = totalAmount > 0 ? (Number(q.totalYesAmount) / totalAmount) * 100 : 50;
const noPercentage = totalAmount > 0 ? (Number(q.totalNoAmount) / totalAmount) * 100 : 50;
const userBets = userBetsMap.get(q.id) || null;
return {
id: q.id,
question: q.question,
status: q.status,
resolutionDate: q.resolutionDate,
totalAmount,
yesAmount: Number(q.totalYesAmount),
noAmount: Number(q.totalNoAmount),
yesPercentage,
noPercentage,
createdAt: q.createdAt,
resolvedAt: q.resolvedAt,
requiresWebSearch: q.requiresWebSearch,
aiResolution: q.aiResolution,
creator: {
id: q.creatorId,
name: q.creatorName,
username: q.creatorUsername,
image: q.creatorImage,
},
userBets
};
});
const totalCount = Number(total) || 0;
return json({
questions: formattedQuestions,
total: totalCount,
page,
limit,
totalPages: Math.ceil(totalCount / limit)
});
} catch (e) {
console.error('Error fetching questions:', e);
return json({ error: 'Failed to fetch questions' }, { status: 500 });
}
};

View file

@ -0,0 +1,206 @@
import { auth } from '$lib/auth';
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { predictionQuestion, user, predictionBet } from '$lib/server/db/schema';
import { eq, desc, sum, and, asc } from 'drizzle-orm';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, request }) => {
const questionId = parseInt(params.id!);
if (isNaN(questionId)) {
throw error(400, 'Invalid question ID');
}
const session = await auth.api.getSession({ headers: request.headers });
const userId = session?.user ? Number(session.user.id) : null;
try {
// Fetch question with creator info
const [questionData] = await db
.select({
id: predictionQuestion.id,
question: predictionQuestion.question,
status: predictionQuestion.status,
resolutionDate: predictionQuestion.resolutionDate,
totalYesAmount: predictionQuestion.totalYesAmount,
totalNoAmount: predictionQuestion.totalNoAmount,
createdAt: predictionQuestion.createdAt,
resolvedAt: predictionQuestion.resolvedAt,
requiresWebSearch: predictionQuestion.requiresWebSearch,
aiResolution: predictionQuestion.aiResolution,
creatorId: predictionQuestion.creatorId,
creatorName: user.name,
creatorUsername: user.username,
creatorImage: user.image,
})
.from(predictionQuestion)
.leftJoin(user, eq(predictionQuestion.creatorId, user.id))
.where(eq(predictionQuestion.id, questionId))
.limit(1);
if (!questionData) {
throw error(404, 'Question not found');
}
const totalAmount = Number(questionData.totalYesAmount) + Number(questionData.totalNoAmount);
const yesPercentage = totalAmount > 0 ? (Number(questionData.totalYesAmount) / totalAmount) * 100 : 50;
const noPercentage = totalAmount > 0 ? (Number(questionData.totalNoAmount) / totalAmount) * 100 : 50;
// Fetch recent bets (last 10)
const recentBets = await db
.select({
id: predictionBet.id,
side: predictionBet.side,
amount: predictionBet.amount,
createdAt: predictionBet.createdAt,
userId: predictionBet.userId,
userName: user.name,
userUsername: user.username,
userImage: user.image,
})
.from(predictionBet)
.leftJoin(user, eq(predictionBet.userId, user.id))
.where(eq(predictionBet.questionId, questionId))
.orderBy(desc(predictionBet.createdAt))
.limit(10);
// Fetch probability history for the chart
const probabilityHistory = await db
.select({
createdAt: predictionBet.createdAt,
side: predictionBet.side,
amount: predictionBet.amount,
})
.from(predictionBet)
.where(eq(predictionBet.questionId, questionId))
.orderBy(asc(predictionBet.createdAt));
// Calculate probability over time
let runningYesTotal = 0;
let runningNoTotal = 0;
const probabilityData: Array<{ time: number; value: number }> = [];
// Add initial point at 50%
if (probabilityHistory.length > 0) {
const firstBetTime = Math.floor(new Date(probabilityHistory[0].createdAt).getTime() / 1000);
probabilityData.push({
time: firstBetTime - 3600, // 1 hour before first bet
value: 50
});
}
for (const bet of probabilityHistory) {
if (bet.side) {
runningYesTotal += Number(bet.amount);
} else {
runningNoTotal += Number(bet.amount);
}
const total = runningYesTotal + runningNoTotal;
const yesPercentage = total > 0 ? (runningYesTotal / total) * 100 : 50;
probabilityData.push({
time: Math.floor(new Date(bet.createdAt).getTime() / 1000),
value: Number(yesPercentage.toFixed(1))
});
}
// Add current point if no recent bets
if (probabilityData.length > 0) {
const lastPoint = probabilityData[probabilityData.length - 1];
const currentTime = Math.floor(Date.now() / 1000);
// Only add current point if last bet was more than 1 hour ago
if (currentTime - lastPoint.time > 3600) {
probabilityData.push({
time: currentTime,
value: Number(yesPercentage.toFixed(1))
});
}
}
let userBets = null;
if (userId) {
// Fetch user's betting data
const userBetData = await db
.select({
side: predictionBet.side,
totalAmount: sum(predictionBet.amount),
})
.from(predictionBet)
.where(and(
eq(predictionBet.questionId, questionId),
eq(predictionBet.userId, userId)
))
.groupBy(predictionBet.side);
const yesAmount = userBetData.find(bet => bet.side === true)?.totalAmount || 0;
const noAmount = userBetData.find(bet => bet.side === false)?.totalAmount || 0;
const userTotalAmount = Number(yesAmount) + Number(noAmount);
if (userTotalAmount > 0) {
// Calculate estimated winnings
const estimatedYesWinnings = Number(yesAmount) > 0
? (totalAmount / Number(questionData.totalYesAmount)) * Number(yesAmount)
: 0;
const estimatedNoWinnings = Number(noAmount) > 0
? (totalAmount / Number(questionData.totalNoAmount)) * Number(noAmount)
: 0;
userBets = {
yesAmount: Number(yesAmount),
noAmount: Number(noAmount),
totalAmount: userTotalAmount,
estimatedYesWinnings,
estimatedNoWinnings,
};
}
}
const formattedQuestion = {
id: questionData.id,
question: questionData.question,
status: questionData.status,
resolutionDate: questionData.resolutionDate,
totalAmount,
yesAmount: Number(questionData.totalYesAmount),
noAmount: Number(questionData.totalNoAmount),
yesPercentage,
noPercentage,
createdAt: questionData.createdAt,
resolvedAt: questionData.resolvedAt,
requiresWebSearch: questionData.requiresWebSearch,
aiResolution: questionData.aiResolution,
creator: {
id: questionData.creatorId,
name: questionData.creatorName,
username: questionData.creatorUsername,
image: questionData.creatorImage,
},
userBets,
recentBets: recentBets.map(bet => ({
id: bet.id,
side: bet.side,
amount: Number(bet.amount),
createdAt: bet.createdAt,
user: {
id: bet.userId,
name: bet.userName,
username: bet.userUsername,
image: bet.userImage,
}
}))
};
return json({
question: formattedQuestion,
probabilityHistory: probabilityData
});
} catch (e) {
console.error('Error fetching question:', e);
if (e instanceof Error && e.message.includes('404')) {
throw error(404, 'Question not found');
}
return json({ error: 'Failed to fetch question' }, { status: 500 });
}
};

View file

@ -0,0 +1,119 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user, predictionQuestion, predictionBet } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) throw error(401, 'Not authenticated');
const questionId = parseInt(params.id!);
const { side, amount } = await request.json();
if (typeof side !== 'boolean' || !amount || amount <= 0) {
return json({ error: 'Invalid bet parameters' }, { status: 400 });
}
const userId = Number(session.user.id);
try {
return await db.transaction(async (tx) => {
// Check question exists and is active
const [questionData] = await tx
.select({
id: predictionQuestion.id,
status: predictionQuestion.status,
resolutionDate: predictionQuestion.resolutionDate,
totalYesAmount: predictionQuestion.totalYesAmount,
totalNoAmount: predictionQuestion.totalNoAmount,
})
.from(predictionQuestion)
.where(eq(predictionQuestion.id, questionId))
.for('update')
.limit(1);
if (!questionData) {
throw new Error('Question not found');
}
if (questionData.status !== 'ACTIVE') {
throw new Error('Question is not active for betting');
}
if (new Date() >= new Date(questionData.resolutionDate)) {
throw new Error('Question has reached resolution date');
}
// Check user balance
const [userData] = await tx
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, userId))
.for('update')
.limit(1);
if (!userData || Number(userData.baseCurrencyBalance) < amount) {
throw new Error('Insufficient balance');
}
// Deduct amount from user balance
await tx
.update(user)
.set({
baseCurrencyBalance: (Number(userData.baseCurrencyBalance) - amount).toFixed(8),
updatedAt: new Date()
})
.where(eq(user.id, userId));
const [newBet] = await tx
.insert(predictionBet)
.values({
userId,
questionId,
side,
amount: amount.toFixed(8),
})
.returning();
// Update question totals
const currentYesAmount = Number(questionData.totalYesAmount);
const currentNoAmount = Number(questionData.totalNoAmount);
await tx
.update(predictionQuestion)
.set({
totalYesAmount: side
? (currentYesAmount + amount).toFixed(8)
: currentYesAmount.toFixed(8),
totalNoAmount: !side
? (currentNoAmount + amount).toFixed(8)
: currentNoAmount.toFixed(8),
})
.where(eq(predictionQuestion.id, questionId));
// Calculate current potential winnings for response (dynamic)
const newTotalYes = side ? currentYesAmount + amount : currentYesAmount;
const newTotalNo = !side ? currentNoAmount + amount : currentNoAmount;
const totalPool = newTotalYes + newTotalNo;
const currentPotentialWinnings = side
? (totalPool / newTotalYes) * amount
: (totalPool / newTotalNo) * amount;
return json({
success: true,
bet: {
id: newBet.id,
side,
amount,
potentialWinnings: currentPotentialWinnings,
},
newBalance: Number(userData.baseCurrencyBalance) - amount
});
});
} catch (e) {
console.error('Betting error:', e);
return json({ error: (e as Error).message }, { status: 400 });
}
};

View file

@ -0,0 +1,108 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user, predictionQuestion } from '$lib/server/db/schema';
import { eq, and, gte, count } from 'drizzle-orm';
import { validateQuestion } from '$lib/server/ai';
import type { RequestHandler } from './$types';
const MIN_BALANCE_REQUIRED = 100000; // $100k
const MAX_QUESTIONS_PER_HOUR = 2;
const MIN_RESOLUTION_HOURS = 1;
const MAX_RESOLUTION_DAYS = 30;
export const POST: RequestHandler = async ({ request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) throw error(401, 'Not authenticated');
const { question } = await request.json();
const cleaned = (question ?? '').trim();
if (cleaned.length < 10 || cleaned.length > 200) {
return json({ error: 'Question must be between 10 and 200 characters' }, { status: 400 });
}
const userId = Number(session.user.id);
const now = new Date();
try {
return await db.transaction(async (tx) => {
// Check user balance
const [userData] = await tx
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, userId))
.limit(1);
if (!userData || Number(userData.baseCurrencyBalance) < MIN_BALANCE_REQUIRED) {
throw new Error(`You need at least $${MIN_BALANCE_REQUIRED.toLocaleString()} to create questions`);
}
// Check hourly creation limit
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const [recentQuestions] = await tx
.select({ count: count() })
.from(predictionQuestion)
.where(and(
eq(predictionQuestion.creatorId, userId),
gte(predictionQuestion.createdAt, oneHourAgo)
));
if (Number(recentQuestions.count) >= MAX_QUESTIONS_PER_HOUR) {
throw new Error(`You can only create ${MAX_QUESTIONS_PER_HOUR} questions per hour`);
}
const validation = await validateQuestion(question);
if (!validation.isValid) {
throw new Error(`Question validation failed: ${validation.reason}`);
}
// Use AI suggested date or default fallback
let finalResolutionDate: Date;
if (validation.suggestedResolutionDate && !isNaN(validation.suggestedResolutionDate.getTime())) {
finalResolutionDate = validation.suggestedResolutionDate;
} else {
// Fallback: 24 hours from now
finalResolutionDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
console.warn('Using fallback resolution date (24h), AI suggested:', validation.suggestedResolutionDate);
}
// Validate the final date is within acceptable bounds
const minResolutionDate = new Date(now.getTime() + MIN_RESOLUTION_HOURS * 60 * 60 * 1000);
const maxResolutionDate = new Date(now.getTime() + MAX_RESOLUTION_DAYS * 24 * 60 * 60 * 1000);
if (finalResolutionDate < minResolutionDate) {
finalResolutionDate = minResolutionDate;
} else if (finalResolutionDate > maxResolutionDate) {
finalResolutionDate = maxResolutionDate;
}
// Create question
const [newQuestion] = await tx
.insert(predictionQuestion)
.values({
creatorId: userId,
question: question.trim(),
resolutionDate: finalResolutionDate,
requiresWebSearch: validation.requiresWebSearch,
validationReason: validation.reason,
})
.returning();
return json({
success: true,
question: {
id: newQuestion.id,
question: newQuestion.question,
resolutionDate: newQuestion.resolutionDate,
requiresWebSearch: newQuestion.requiresWebSearch
}
});
});
} catch (e) {
console.error('Question creation error:', e);
return json({ error: (e as Error).message }, { status: 400 });
}
};

View file

@ -0,0 +1,345 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Dialog from '$lib/components/ui/dialog';
import * as Tabs from '$lib/components/ui/tabs';
import * as HoverCard from '$lib/components/ui/hover-card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Badge } from '$lib/components/ui/badge';
import { Skeleton } from '$lib/components/ui/skeleton';
import * as Avatar from '$lib/components/ui/avatar';
import UserProfilePreview from '$lib/components/self/UserProfilePreview.svelte';
import HopiumSkeleton from '$lib/components/self/skeletons/HopiumSkeleton.svelte';
import {
TrendingUp,
TrendingDown,
Plus,
Clock,
Sparkles,
Globe,
Loader2,
CheckCircle,
XCircle
} from 'lucide-svelte';
import { USER_DATA } from '$lib/stores/user-data';
import { PORTFOLIO_DATA, fetchPortfolioData } from '$lib/stores/portfolio-data';
import { toast } from 'svelte-sonner';
import { onMount } from 'svelte';
import { formatDateWithYear, formatTimeUntil, formatValue, getPublicUrl } from '$lib/utils';
import { goto } from '$app/navigation';
import type { PredictionQuestion } from '$lib/types/prediction';
let questions = $state<PredictionQuestion[]>([]);
let loading = $state(true);
let activeTab = $state('active');
let showCreateDialog = $state(false);
// Create question form
let newQuestion = $state('');
let creatingQuestion = $state(false);
let userBalance = $derived($PORTFOLIO_DATA ? $PORTFOLIO_DATA.baseCurrencyBalance : 0);
onMount(() => {
fetchQuestions();
if ($USER_DATA) {
fetchPortfolioData();
}
});
async function fetchQuestions() {
try {
const status =
activeTab === 'active' ? 'ACTIVE' : activeTab === 'resolved' ? 'RESOLVED' : 'ALL';
// TODO: PAGINATION
const response = await fetch(`/api/hopium/questions?status=${status}&limit=50`);
if (response.ok) {
const data = await response.json();
questions = data.questions;
} else {
toast.error('Failed to load questions');
}
} catch (e) {
console.error('Failed to fetch questions:', e);
toast.error('Failed to load questions');
} finally {
loading = false;
}
}
async function createQuestion() {
if (!newQuestion.trim()) {
toast.error('Please enter a question');
return;
}
creatingQuestion = true;
try {
const response = await fetch('/api/hopium/questions/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
question: newQuestion
})
});
const result = await response.json();
if (response.ok) {
toast.success('Question created successfully!');
showCreateDialog = false;
newQuestion = '';
fetchQuestions();
fetchPortfolioData();
} else {
toast.error(result.error || 'Failed to create question', { duration: 20000 });
}
} catch (e) {
toast.error('Network error');
} finally {
creatingQuestion = false;
}
}
function handleCreateQuestion() {
if (!$USER_DATA) {
toast.error('You must be logged in to create a question');
return;
}
if (userBalance <= 100_000) {
toast.error('You need at least $100,000 in your portfolio to create a question.');
return;
}
showCreateDialog = true;
}
$effect(() => {
if (activeTab) {
loading = true;
fetchQuestions();
}
});
</script>
<svelte:head>
<title>Hopium - Prediction Market | Rugplay</title>
<meta
name="description"
content="Create and bet on prediction markets with AI-powered resolution"
/>
</svelte:head>
<!-- Create Question Dialog -->
<Dialog.Root bind:open={showCreateDialog}>
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<Sparkles class="h-5 w-5" />
Create
</Dialog.Title>
<Dialog.Description>Create a yes/no question that will be resolved by AI.</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="question">Question *</Label>
<Input
id="question"
bind:value={newQuestion}
placeholder="Will *SKIBIDI reach $100 price today?"
maxlength={200}
/>
<p class="text-muted-foreground text-xs">{newQuestion.length}/200 characters</p>
<p class="text-muted-foreground text-xs">
The AI will automatically determine the appropriate resolution date and criteria.
</p>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (showCreateDialog = false)}>Cancel</Button>
<Button onclick={createQuestion} disabled={creatingQuestion || !newQuestion.trim()}>
{#if creatingQuestion}
<Loader2 class="h-4 w-4 animate-spin" />
Processing...
{:else}
Publish
{/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">
<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>
</h1>
<p class="text-muted-foreground mb-6">
AI-powered prediction markets. Create questions and bet on outcomes.
</p>
</div>
</header>
<Tabs.Root bind:value={activeTab} class="w-full">
<div class="mb-6 flex items-center justify-center gap-2">
<Tabs.List class="grid w-full max-w-md grid-cols-3">
<Tabs.Trigger value="active">Active</Tabs.Trigger>
<Tabs.Trigger value="resolved">Resolved</Tabs.Trigger>
<Tabs.Trigger value="all">All</Tabs.Trigger>
</Tabs.List>
{#if $USER_DATA}
<Button onclick={handleCreateQuestion}>
<Plus class="h-4 w-4" />
Ask
</Button>
{/if}
</div>
<Tabs.Content value={activeTab}>
{#if loading}
<HopiumSkeleton />
{:else if questions.length === 0}
<div class="py-16 text-center">
<h3 class="mb-2 text-lg font-semibold">No questions yet</h3>
<p class="text-muted-foreground mb-6">Be the first to create a prediction question!</p>
</div>
{:else}
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each questions as question}
<Card.Root
class="bg-card hover:bg-card/90 flex cursor-pointer flex-col transition-colors"
onclick={() => goto(`/hopium/${question.id}`)}
>
<Card.Header class="pb-4">
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<h3 class="break-words text-lg font-medium">
{question.question}
</h3>
</div>
<div class="flex flex-col items-end gap-2">
{#if question.status === 'RESOLVED'}
<Badge
variant={question.aiResolution ? 'default' : 'destructive'}
class="flex flex-shrink-0 items-center gap-1"
>
{#if question.aiResolution}
<CheckCircle class="h-3 w-3" />
YES
{:else}
<XCircle class="h-3 w-3" />
NO
{/if}
</Badge>
{/if}
<!-- Probability Meter -->
<div class="relative flex h-12 w-16 items-end justify-center">
<svg class="h-10 w-16" viewBox="0 0 64 32">
<!-- Background arc -->
<path
d="M 8 28 A 24 24 0 0 1 56 28"
fill="none"
stroke="var(--muted-foreground)"
stroke-width="3"
stroke-linecap="round"
opacity="0.3"
/>
<!-- Progress arc -->
<path
d="M 8 28 A 24 24 0 0 1 56 28"
fill="none"
stroke="var(--primary)"
stroke-width="3"
stroke-linecap="round"
stroke-dasharray={Math.PI * 24}
stroke-dashoffset={Math.PI * 24 -
(question.yesPercentage / 100) * Math.PI * 24}
class="transition-all duration-300 ease-in-out"
/>
</svg>
<div class="absolute bottom-0 text-sm font-medium">
{question.yesPercentage.toFixed(0)}%
</div>
</div>
</div>
</div>
<div class="text-muted-foreground flex items-center gap-2 text-sm">
<div class="flex items-center gap-1">
<Clock class="h-3 w-3" />
{#if question.status === 'ACTIVE'}
{formatTimeUntil(question.resolutionDate)} remaining
{:else}
Resolved {formatDateWithYear(question.resolvedAt || '')}
{/if}
</div>
<span></span>
<div class="flex items-center gap-1">
{formatValue(question.totalAmount)}
</div>
{#if question.requiresWebSearch}
<span></span>
<Globe class="h-3 w-3 text-blue-500" />
{/if}
</div>
<div class="mb-2 mt-2 flex items-center gap-2 text-sm">
<HoverCard.Root>
<HoverCard.Trigger>
<button
class="flex cursor-pointer items-center gap-2 text-left hover:underline"
>
<Avatar.Root class="h-5 w-5">
<Avatar.Image
src={getPublicUrl(question.creator.image)}
alt={question.creator.name}
/>
<Avatar.Fallback class="text-xs"
>{question.creator.name.charAt(0)}</Avatar.Fallback
>
</Avatar.Root>
<span class="text-muted-foreground">@{question.creator.username}</span>
</button>
</HoverCard.Trigger>
<HoverCard.Content class="w-80">
<UserProfilePreview userId={question.creator.id} />
</HoverCard.Content>
</HoverCard.Root>
</div>
<!-- User's bet amounts if they have any -->
{#if question.userBets && (question.userBets.yesAmount > 0 || question.userBets.noAmount > 0)}
<div class="text-muted-foreground flex items-center gap-4 text-sm">
<span>Your bets:</span>
{#if question.userBets.yesAmount > 0}
<div class="flex items-center gap-1">
<TrendingUp class="h-3 w-3 text-green-600" />
<span class="text-green-600"
>YES: ${question.userBets.yesAmount.toFixed(2)}</span
>
</div>
{/if}
{#if question.userBets.noAmount > 0}
<div class="flex items-center gap-1">
<TrendingDown class="h-3 w-3 text-red-600" />
<span class="text-red-600"
>NO: ${question.userBets.noAmount.toFixed(2)}</span
>
</div>
{/if}
</div>
{/if}
</Card.Header>
</Card.Root>
{/each}
</div>
{/if}
</Tabs.Content>
</Tabs.Root>
</div>

View file

@ -0,0 +1,615 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import * as HoverCard from '$lib/components/ui/hover-card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Badge } from '$lib/components/ui/badge';
import * as Avatar from '$lib/components/ui/avatar';
import { Separator } from '$lib/components/ui/separator';
import UserProfilePreview from '$lib/components/self/UserProfilePreview.svelte';
import {
Loader2,
CheckCircle,
XCircle,
Calculator,
History,
ChartColumn,
MessageCircleQuestion
} from 'lucide-svelte';
import { USER_DATA } from '$lib/stores/user-data';
import { PORTFOLIO_DATA, fetchPortfolioData } from '$lib/stores/portfolio-data';
import { toast } from 'svelte-sonner';
import { onMount } from 'svelte';
import { formatDateWithYear, getPublicUrl, formatTimeUntil } from '$lib/utils';
import { createChart, ColorType, type IChartApi, LineSeries } from 'lightweight-charts';
import type { PredictionQuestion } from '$lib/types/prediction';
import HopiumQuestionSkeleton from '$lib/components/self/skeletons/HopiumQuestionSkeleton.svelte';
let question = $state<PredictionQuestion | null>(null);
let loading = $state(true);
let probabilityData = $state<any[]>([]);
// Betting form
let betSide = $state<boolean>(true);
let placingBet = $state(false);
let customBetAmount = $state('');
let userBalance = $derived($PORTFOLIO_DATA ? $PORTFOLIO_DATA.baseCurrencyBalance : 0);
let questionId = $derived(parseInt(page.params.id));
// Chart related
let chartContainer = $state<HTMLDivElement>();
let chart: IChartApi | null = null;
let lineSeries: any = null;
onMount(() => {
fetchQuestion();
if ($USER_DATA) {
fetchPortfolioData();
}
});
async function fetchQuestion() {
try {
const response = await fetch(`/api/hopium/questions/${questionId}`);
if (response.ok) {
const result = await response.json();
question = result.question || result;
probabilityData = result.probabilityHistory || [];
} else if (response.status === 404) {
toast.error('Question not found');
goto('/hopium');
} else {
toast.error('Failed to load question');
}
} catch (e) {
console.error('Failed to fetch question:', e);
toast.error('Failed to load question');
} finally {
loading = false;
}
}
async function placeBet() {
if (!question || !customBetAmount || Number(customBetAmount) <= 0) {
toast.error('Please enter a valid bet amount');
return;
}
placingBet = true;
try {
const response = await fetch(`/api/hopium/questions/${question.id}/bet`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
side: betSide,
amount: Number(customBetAmount)
})
});
const result = await response.json();
if (response.ok) {
toast.success(
`Bet placed! Potential winnings: $${result.bet.potentialWinnings.toFixed(2)}`
);
customBetAmount = '';
fetchQuestion();
fetchPortfolioData();
} else {
toast.error(result.error || 'Failed to place bet');
}
} catch (e) {
toast.error('Network error');
} finally {
placingBet = false;
}
}
$effect(() => {
if (chart && probabilityData.length > 0) {
chart.remove();
chart = null;
}
if (chartContainer && probabilityData.length > 0 && question) {
chart = createChart(chartContainer, {
layout: {
textColor: '#666666',
background: { type: ColorType.Solid, color: 'transparent' },
attributionLogo: false
},
grid: {
vertLines: { color: '#2B2B43' },
horzLines: { color: '#2B2B43' }
},
rightPriceScale: {
borderVisible: false,
scaleMargins: { top: 0.1, bottom: 0.1 },
alignLabels: true,
entireTextOnly: false,
visible: true
},
timeScale: {
borderVisible: false,
timeVisible: true,
rightOffset: 5
},
crosshair: {
mode: 1,
vertLine: { color: '#758696', width: 1, style: 2, visible: true, labelVisible: true },
horzLine: { color: '#758696', width: 1, style: 2, visible: true, labelVisible: true }
}
});
lineSeries = chart.addSeries(LineSeries, {
color: '#2962FF',
lineWidth: 3,
priceFormat: {
type: 'custom',
formatter: (price: number) => `${price.toFixed(1)}%`
}
});
lineSeries.setData(probabilityData);
chart.timeScale().fitContent();
const handleResize = () => chart?.applyOptions({ width: chartContainer?.clientWidth });
window.addEventListener('resize', handleResize);
handleResize();
return () => {
window.removeEventListener('resize', handleResize);
if (chart) {
chart.remove();
chart = null;
}
};
}
});
let estimatedYesPayout = $derived(
!question?.userBets?.yesAmount || question.userBets.yesAmount <= 0
? 0
: question.userBets.estimatedYesWinnings || 0
);
let estimatedNoPayout = $derived(
!question?.userBets?.noAmount || question.userBets.noAmount <= 0
? 0
: question.userBets.estimatedNoWinnings || 0
);
let estimatedWin = $derived(
(() => {
const amount = Number(customBetAmount);
if (!amount || amount <= 0 || !question) return 0;
const totalPool = question.yesAmount + question.noAmount + amount;
const relevantPool = betSide ? question.yesAmount + amount : question.noAmount + amount;
return relevantPool > 0 ? (totalPool / relevantPool) * amount : 0;
})()
);
</script>
<svelte:head>
{#if question}
<title>{question.question} - Rugplay</title>
<meta name="description" content={question.description || question.question} />
{:else}
<title>Hopium - Rugplay</title>
{/if}
</svelte:head>
<div class="container mx-auto max-w-7xl p-6">
{#if loading}
<HopiumQuestionSkeleton />
{:else if !question}
<div class="flex h-96 items-center justify-center">
<div class="text-center">
<h3 class="mb-2 text-xl font-semibold">Question not found</h3>
<p class="text-muted-foreground mb-6">
This question may have been removed or doesn't exist.
</p>
</div>
</div>
{:else}
<div class="flex items-center gap-3">
<div class="bg-muted rounded-lg p-4">
<MessageCircleQuestion class="h-14 w-14" />
</div>
<div class="flex-1">
<h1 class="text-2xl font-semibold">{question.question}</h1>
{#if question.status === 'ACTIVE'}
<p class="text-muted-foreground mt-1 text-sm">
Ends in {formatTimeUntil(question.resolutionDate)}
</p>
{/if}
{#if question.status === 'RESOLVED'}
<Badge variant="destructive" class={question.aiResolution ? 'bg-success/80!' : ''}>
{#if question.aiResolution}
<CheckCircle class="h-4 w-4" />
RESOLVED: YES
{:else}
<XCircle class="h-4 w-4" />
RESOLVED: NO
{/if}
</Badge>
{/if}
</div>
</div>
<div class="text-muted-foreground mb-4 mt-3 flex flex-wrap items-center gap-1.5 text-xs">
<span>Created by</span>
<HoverCard.Root>
<HoverCard.Trigger
class="flex cursor-pointer items-center gap-1 rounded-sm underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-8"
onclick={() => goto(`/user/${question?.creator.username}`)}
>
<Avatar.Root class="h-4 w-4">
<Avatar.Image
src={getPublicUrl(question.creator.image)}
alt={question.creator.username}
/>
<Avatar.Fallback>{question.creator.username.charAt(0)}</Avatar.Fallback>
</Avatar.Root>
<span class="font-medium">{question.creator.name} (@{question.creator.username})</span>
</HoverCard.Trigger>
<HoverCard.Content class="w-80" side="bottom" sideOffset={3}>
<UserProfilePreview userId={question.creator.id} />
</HoverCard.Content>
</HoverCard.Root>
</div>
<div class="grid gap-8">
<!-- Main content grid with better spacing -->
<div class="grid grid-cols-1 gap-8 lg:grid-cols-3">
<!-- Left: Chart (2/3 width) -->
<div class="lg:col-span-2">
<Card.Root class="shadow-sm">
<Card.Header>
<div class="flex items-center justify-between">
<Card.Title class="flex items-center gap-3 text-xl font-bold">
<ChartColumn class="h-6 w-6" />
Chart
</Card.Title>
<div class="text-right">
<div class="text-success text-4xl font-bold">
{question.yesPercentage.toFixed(1)}%
</div>
<div class="text-muted-foreground text-sm font-medium">YES chance</div>
</div>
</div>
</Card.Header>
<Card.Content>
{#if probabilityData.length === 0}
<div
class="border-muted flex h-[400px] items-center justify-center rounded-lg border-2 border-dashed"
>
<div class="text-center">
<ChartColumn class="text-muted-foreground mx-auto mb-3 h-12 w-12" />
<p class="text-muted-foreground text-sm">Chart will appear after first bet</p>
</div>
</div>
{:else}
<div class="h-[400px] w-full rounded-lg border" bind:this={chartContainer}></div>
{/if}
</Card.Content>
</Card.Root>
</div>
<!-- Right: Trading Controls (1/3 width) -->
<div class="space-y-6 lg:col-span-1">
<!-- Trading Card -->
<Card.Root>
<Card.Header>
<Card.Title>Place Bet</Card.Title>
</Card.Header>
<Card.Content class="space-y-6">
<!-- YES/NO Buttons -->
<div class="grid grid-cols-2 gap-4">
<Button
class={betSide
? 'bg-success/80 hover:bg-success/90 w-full'
: 'bg-muted hover:bg-muted/90 w-full'}
size="lg"
onclick={() => (betSide = true)}
disabled={question.aiResolution !== null}
>
<div class="flex items-baseline gap-2">
<span class="text-xl font-bold">YES</span>
<span class="text-sm">{question.yesPercentage.toFixed(1)}¢</span>
</div>
</Button>
<Button
class={!betSide
? 'bg-destructive hover:bg-destructive/90 w-full'
: 'bg-muted hover:bg-muted/90 w-full'}
size="lg"
onclick={() => (betSide = false)}
disabled={question.aiResolution !== null}
>
<div class="flex items-baseline gap-2">
<span class="text-xl font-bold">NO</span>
<span class="text-sm">{question.noPercentage.toFixed(1)}¢</span>
</div>
</Button>
</div>
<!-- Amount Input -->
<div class="space-y-2">
<Input
type="number"
step="0.01"
min="0.01"
placeholder="Enter amount..."
bind:value={customBetAmount}
disabled={question.aiResolution !== null}
/>
</div>
<!-- Quick Amount Buttons -->
<div class="flex gap-2">
<Button
variant="outline"
class="flex-1"
onclick={() => (customBetAmount = '1')}
disabled={question.aiResolution !== null}
>
$1
</Button>
<Button
variant="outline"
class="flex-1"
onclick={() => (customBetAmount = '20')}
disabled={question.aiResolution !== null}
>
$20
</Button>
<Button
variant="outline"
class="flex-1"
onclick={() => (customBetAmount = '100')}
disabled={question.aiResolution !== null}
>
$100
</Button>
<Button
variant="outline"
class="flex-1"
onclick={() => (customBetAmount = userBalance.toString())}
disabled={question.aiResolution !== null}
>
Max
</Button>
</div>
<!-- Win Estimation -->
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">To win:</span>
<span class="font-mono">
${estimatedWin.toFixed(2)}
</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">Balance:</span>
<span class="font-mono">
${userBalance.toFixed(2)}
</span>
</div>
</div>
<!-- Pay Button -->
<Button
class="w-full"
size="lg"
disabled={!customBetAmount ||
Number(customBetAmount) <= 0 ||
Number(customBetAmount) > userBalance ||
placingBet ||
question.aiResolution !== null}
onclick={placeBet}
>
{#if placingBet}
<Loader2 class="h-4 w-4 animate-spin" />
Placing Bet...
{:else}
Pay ${Number(customBetAmount || 0).toFixed(2)}
{/if}
</Button>
</Card.Content>
</Card.Root>
{#if !$USER_DATA}
<Card.Root class="shadow-sm">
<Card.Header>
<Card.Title class="text-lg font-bold">Start Betting</Card.Title>
</Card.Header>
<Card.Content class="pb-6">
<div class="py-6 text-center">
<p class="text-muted-foreground mb-4 text-sm">Sign in to place bets</p>
<Button size="lg" onclick={() => goto('/')}>Sign In</Button>
</div>
</Card.Content>
</Card.Root>
{/if}
</div>
</div>
<!-- Position and Stats Cards below chart -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- User Position Card (if they have bets) -->
{#if $USER_DATA && question.userBets && question.userBets.totalAmount && question.userBets.totalAmount > 0}
<Card.Root class="gap-1">
<Card.Header>
<Card.Title class="flex items-center gap-3 text-lg font-bold">
<div class="bg-muted rounded-full p-2">
<Calculator class="h-5 w-5" />
</div>
Your Position
</Card.Title>
</Card.Header>
<Card.Content class="pb-4">
<div class="space-y-3">
{#if question.userBets.yesAmount > 0}
<div class="flex items-center justify-between">
<div>
<div class="text-sm font-medium text-green-600">YES Bet</div>
<div class="text-muted-foreground text-xs">
Payout: ${estimatedYesPayout.toFixed(2)}
</div>
</div>
<div class="text-lg font-bold text-green-600">
${question.userBets.yesAmount.toFixed(2)}
</div>
</div>
{/if}
{#if question.userBets.noAmount > 0}
<div class="flex items-center justify-between">
<div>
<div class="text-sm font-medium text-red-600">NO Bet</div>
<div class="text-muted-foreground text-xs">
Payout: ${estimatedNoPayout.toFixed(2)}
</div>
</div>
<div class="text-lg font-bold text-red-600">
${question.userBets.noAmount.toFixed(2)}
</div>
</div>
{/if}
{#if question.userBets.yesAmount > 0 && question.userBets.noAmount > 0}
<Separator />
{/if}
<div class="flex items-center justify-between">
<span class="text-muted-foreground text-sm font-medium">Total Invested</span>
<span class="text-lg font-bold">${question.userBets.totalAmount.toFixed(2)}</span>
</div>
</div>
</Card.Content>
</Card.Root>
{:else if $USER_DATA}
<Card.Root class="gap-1">
<Card.Header>
<Card.Title>
<div class="inline-flex items-center gap-2">
<Calculator class="h-5 w-5" />
Place Your Bet
</div>
</Card.Title>
</Card.Header>
<Card.Content>
{#if question.status === 'ACTIVE'}
<p class="text-muted-foreground mb-6 text-sm">You haven't placed any bets yet</p>
{:else}
<div class="py-6 text-center">
<p class="text-muted-foreground text-sm">This question has been resolved</p>
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}
<!-- Market Stats Card -->
<Card.Root class="gap-1">
<Card.Header>
<Card.Title class="flex items-center gap-3 text-lg font-bold">
<div class="bg-muted rounded-full p-2">
<ChartColumn class="h-5 w-5" />
</div>
Market Stats
</Card.Title>
</Card.Header>
<Card.Content>
<div class="flex justify-between">
<span class="text-muted-foreground text-sm">Total Volume:</span>
<span class="font-mono text-sm">
${question.totalAmount.toFixed(2)}
</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground text-sm">Total Bets:</span>
<span class="font-mono text-sm">
{question.recentBets?.length || 0}
</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground text-sm">Created:</span>
<span class="font-mono text-sm">
{formatDateWithYear(question.createdAt)}
</span>
</div>
{#if question.status === 'ACTIVE'}
<div class="flex justify-between">
<span class="text-muted-foreground text-sm">Resolves:</span>
<span class="font-mono text-sm">
{formatDateWithYear(question.resolutionDate)}
</span>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
<!-- Recent Activity Section -->
{#if question.recentBets && question.recentBets.length > 0}
<Card.Root class="shadow-sm">
<Card.Header>
<Card.Title class="flex items-center gap-3 text-xl font-bold">
<div class="bg-muted rounded-full p-2">
<History class="h-6 w-6" />
</div>
Recent Activity
</Card.Title>
</Card.Header>
<Card.Content class="pb-6">
<div class="space-y-4">
{#each question.recentBets as bet}
<div class="flex items-center justify-between rounded-xl border p-4">
<div class="flex items-center gap-4">
<HoverCard.Root>
<HoverCard.Trigger>
<button
class="flex cursor-pointer items-center gap-3 text-left"
onclick={() => goto(`/user/${bet.user.username}`)}
>
<Avatar.Root class="h-10 w-10">
<Avatar.Image src={getPublicUrl(bet.user.image)} alt={bet.user.name} />
<Avatar.Fallback class="text-sm"
>{bet.user.name.charAt(0)}</Avatar.Fallback
>
</Avatar.Root>
<div>
<div class="font-semibold hover:underline">{bet.user.name}</div>
<div class="text-muted-foreground text-sm">@{bet.user.username}</div>
</div>
</button>
</HoverCard.Trigger>
<HoverCard.Content class="w-80">
<UserProfilePreview userId={bet.user.id} />
</HoverCard.Content>
</HoverCard.Root>
<Badge variant="destructive" class={bet.side ? 'bg-success/80!' : ''}>
{bet.side ? 'YES' : 'NO'}
</Badge>
</div>
<div class="text-right">
<div class="text-lg font-bold">${bet.amount.toFixed(2)}</div>
<div class="text-muted-foreground text-sm">
{new Date(bet.createdAt).toLocaleDateString()}
</div>
</div>
</div>
{/each}
</div>
</Card.Content>
</Card.Root>
{/if}
</div>
{/if}
</div>

View file

@ -1,6 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
// @ts-ignore
import { chart } from 'svelte-apexcharts';
// it doens't have types idk
import { Skeleton } from '$lib/components/ui/skeleton';
import * as Card from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';