feat: AI-powered prediction market (Hopium)
This commit is contained in:
parent
4fcc55fa72
commit
2a92c37d26
33 changed files with 7009 additions and 4518 deletions
134
website/src/routes/api/hopium/questions/+server.ts
Normal file
134
website/src/routes/api/hopium/questions/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
206
website/src/routes/api/hopium/questions/[id]/+server.ts
Normal file
206
website/src/routes/api/hopium/questions/[id]/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
119
website/src/routes/api/hopium/questions/[id]/bet/+server.ts
Normal file
119
website/src/routes/api/hopium/questions/[id]/bet/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
108
website/src/routes/api/hopium/questions/create/+server.ts
Normal file
108
website/src/routes/api/hopium/questions/create/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
345
website/src/routes/hopium/+page.svelte
Normal file
345
website/src/routes/hopium/+page.svelte
Normal 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>
|
||||
615
website/src/routes/hopium/[id]/+page.svelte
Normal file
615
website/src/routes/hopium/[id]/+page.svelte
Normal 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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Reference in a new issue