From d6682e32d92c839a49ea87b8c288b16d9260ed09 Mon Sep 17 00:00:00 2001 From: kimjaehyeon0101 <47347352-kimjaehyeon0101@users.noreply.replit.com> Date: Mon, 29 Sep 2025 19:14:42 +0000 Subject: [PATCH] Add betting functionality to prediction markets for users Integrates prediction market betting with new API endpoints, database schema, and client-side UI elements. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 069d4324-6c40-4355-955e-c714a50de1ea Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3df548ff-50ae-432f-9be4-25d34eccc983/069d4324-6c40-4355-955e-c714a50de1ea/6XTzcDL --- .replit | 4 + client/src/pages/Article.tsx | 393 ++++++++++++++++++++++++++--------- server/routes.ts | 39 +++- server/storage.ts | 53 +++++ shared/schema.ts | 18 ++ 5 files changed, 410 insertions(+), 97 deletions(-) diff --git a/.replit b/.replit index a45f89c..b0526f5 100644 --- a/.replit +++ b/.replit @@ -14,6 +14,10 @@ run = ["npm", "run", "start"] localPort = 5000 externalPort = 80 +[[ports]] +localPort = 34047 +externalPort = 3002 + [[ports]] localPort = 37531 externalPort = 3001 diff --git a/client/src/pages/Article.tsx b/client/src/pages/Article.tsx index 4407df9..e268c20 100644 --- a/client/src/pages/Article.tsx +++ b/client/src/pages/Article.tsx @@ -1,37 +1,135 @@ -import { useQuery } from "@tanstack/react-query"; -import { useRoute } from "wouter"; +import { useState } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { useRoute, useLocation } from "wouter"; import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import PredictionMarketCard from "@/components/PredictionMarketCard"; +import { Input } from "@/components/ui/input"; +import { TrendingUp, TrendingDown, DollarSign, Clock, ArrowLeft } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest, queryClient } from "@/lib/queryClient"; import type { Article, PredictionMarket } from "@shared/schema"; export default function Article() { const [, params] = useRoute("/articles/:slug"); - + const [, setLocation] = useLocation(); + const { toast } = useToast(); + const [betAmounts, setBetAmounts] = useState>({}); + const { data: article, isLoading: articleLoading } = useQuery
({ queryKey: ["/api/articles", params?.slug], enabled: !!params?.slug }); - const { data: predictionMarkets = [], isLoading: marketsLoading } = useQuery({ - queryKey: ["/api/prediction-markets", { articleId: article?.id }], - enabled: !!article?.id + const { data: markets = [], isLoading: marketsLoading } = useQuery({ + queryKey: ["/api/articles", params?.slug, "markets"], + enabled: !!params?.slug }); + const placeBetMutation = useMutation({ + mutationFn: async ({ marketId, side, amount }: { marketId: string; side: "yes" | "no"; amount: number }) => { + return apiRequest(`/api/prediction-markets/${marketId}/bets`, { + method: "POST", + body: { side, amount } + }); + }, + onSuccess: () => { + toast({ + title: "베팅 성공", + description: "예측시장 베팅이 성공적으로 완료되었습니다." + }); + queryClient.invalidateQueries({ queryKey: ["/api/articles", params?.slug, "markets"] }); + setBetAmounts({}); + }, + onError: (error: any) => { + toast({ + title: "베팅 실패", + description: error.message || "베팅 처리 중 오류가 발생했습니다.", + variant: "destructive" + }); + } + }); + + const formatCurrency = (amount: string | number) => { + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: 'KRW' + }).format(Number(amount)); + }; + + const formatPercentage = (value: number) => { + return `${(value * 100).toFixed(1)}%`; + }; + + const formatDate = (dateString: string) => { + return new Intl.DateTimeFormat('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }).format(new Date(dateString)); + }; + + const handleBetAmountChange = (marketId: string, value: string) => { + setBetAmounts(prev => ({ + ...prev, + [marketId]: value + })); + }; + + const handlePlaceBet = (marketId: string, side: "yes" | "no") => { + const amount = parseFloat(betAmounts[marketId] || "0"); + if (amount <= 0) { + toast({ + title: "잘못된 금액", + description: "베팅 금액을 올바르게 입력해주세요.", + variant: "destructive" + }); + return; + } + + placeBetMutation.mutate({ marketId, side, amount }); + }; + if (articleLoading) { return (
-
-
-
-
-
- {Array.from({ length: 5 }).map((_, i) => ( -
- ))} +
+
+
+
+
+ S +
+ SAPIENS +
+ +
+ + +
-
+ + +
+
+
+
+
+
+
+
+
+
+
+
); } @@ -40,30 +138,18 @@ export default function Article() { return (
-

Article Not Found

-

The article you're looking for doesn't exist.

- +

기사를 찾을 수 없습니다

+
); } - const formatDate = (date: string | Date) => { - const d = new Date(date); - const now = new Date(); - const diffTime = Math.abs(now.getTime() - d.getTime()); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - - if (diffDays === 1) return "1 day ago"; - if (diffDays < 7) return `${diffDays} days ago`; - return d.toLocaleDateString(); - }; - return (
{/* Header */}
-
+
@@ -73,11 +159,12 @@ export default function Article() {
- -
@@ -85,79 +172,197 @@ export default function Article() {
- {/* Article Header */} -
-
- Author -
-

Alex Karp

-

CEO of Palantir

-
-
-
- {/* Article Content */} -
- {article.imageUrl && ( - {article.title} - )} - -

{article.title}

- -
- {formatDate(article.publishedAt!)} - - {article.tags?.map((tag) => ( - - {tag} - - ))} -
- -
+
+
+

{article.title}

+
+ {formatDate(article.publishedAt)} + + {article.author} + {article.tags?.map((tag) => ( + + {tag} + + ))} +
+ + {article.imageUrl && ( +
+ {article.title} +
+ )} +
+ +
{article.excerpt && ( -

+

{article.excerpt}

)} -
+
{article.content}
- {/* Related Prediction Markets */} - {predictionMarkets.length > 0 && ( -
-

Related Prediction Markets

- + {/* Prediction Markets Section */} +
+
+ +

관련 예측시장

+
+ + {marketsLoading ? (
- {predictionMarkets.slice(0, 3).map((market) => ( - + {Array.from({ length: 3 }).map((_, i) => ( + + +
+
+
+
+
+
+
))}
- - {predictionMarkets.length > 3 && ( - - )} -
- )} + ) : markets.length > 0 ? ( +
+ {markets.map((market) => ( + + + + {market.question} + + + {formatDate(market.resolutionDate)} + + + + + +
+ {/* Yes Option */} +
+
+
+ + YES +
+
+
현재 가격
+
+ {formatPercentage(market.yesPrice)} +
+
+
+ +
+ handleBetAmountChange(market.id, e.target.value)} + className="border-green-300 focus:border-green-500" + data-testid={`input-bet-amount-${market.id}`} + /> + +
+
+ + {/* No Option */} +
+
+
+ + NO +
+
+
현재 가격
+
+ {formatPercentage(market.noPrice)} +
+
+
+ +
+ handleBetAmountChange(market.id, e.target.value)} + className="border-red-300 focus:border-red-500" + data-testid={`input-bet-amount-no-${market.id}`} + /> + +
+
+
+ + {/* Market Stats */} +
+
+
+
+ {formatCurrency(market.totalVolume)} +
+
총 거래량
+
+
+
+ {market.totalBets} +
+
총 베팅 수
+
+
+
+ {formatDate(market.resolutionDate)} +
+
결과 발표일
+
+
+
+
+
+ ))} +
+ ) : ( + + + +

관련 예측시장이 없습니다

+

이 기사와 관련된 예측시장이 아직 생성되지 않았습니다.

+
+
+ )} +
); -} +} \ No newline at end of file diff --git a/server/routes.ts b/server/routes.ts index 21fe085..c81e1aa 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -2,7 +2,7 @@ import type { Express } from "express"; import { createServer, type Server } from "http"; import { storage } from "./storage"; import { setupAuth, isAuthenticated } from "./simpleAuth"; -import { insertArticleSchema, insertMediaOutletRequestSchema, insertBidSchema, insertCommentSchema } from "@shared/schema"; +import { insertArticleSchema, insertMediaOutletRequestSchema, insertBidSchema, insertCommentSchema, insertPredictionBetSchema } from "@shared/schema"; export async function registerRoutes(app: Express): Promise { // Auth middleware @@ -170,7 +170,7 @@ export async function registerRoutes(app: Express): Promise { app.post('/api/auctions/:id/bid', isAuthenticated, async (req: any, res) => { try { - const userId = req.user.id; + const userId = req.user.claims.sub; const bidData = insertBidSchema.parse({ ...req.body, auctionId: req.params.id, @@ -197,7 +197,7 @@ export async function registerRoutes(app: Express): Promise { return res.status(404).json({ message: "No active auction found for this media outlet" }); } - const userId = req.user.id; + const userId = req.user.claims.sub; const bidData = insertBidSchema.parse({ ...req.body, auctionId: auction.id, @@ -212,6 +212,39 @@ export async function registerRoutes(app: Express): Promise { } }); + // Prediction market betting endpoints + app.post('/api/prediction-markets/:marketId/bets', isAuthenticated, async (req: any, res) => { + try { + const userId = req.user.claims.sub; + const { side, amount } = req.body; + + // Validate request + if (!side || !amount || !["yes", "no"].includes(side)) { + return res.status(400).json({ message: "Invalid bet data" }); + } + + if (parseFloat(amount) <= 0) { + return res.status(400).json({ message: "Bet amount must be positive" }); + } + + const betData = insertPredictionBetSchema.parse({ + marketId: req.params.marketId, + userId, + side, + amount: amount.toString() + }); + + const bet = await storage.createPredictionBet(betData); + res.status(201).json(bet); + } catch (error) { + console.error("Error placing prediction bet:", error); + if (error.message === "Prediction market not found") { + return res.status(404).json({ message: "Prediction market not found" }); + } + res.status(500).json({ message: "Failed to place bet" }); + } + }); + // Media outlet request routes app.get('/api/media-outlet-requests', isAuthenticated, async (req: any, res) => { try { diff --git a/server/storage.ts b/server/storage.ts index 8d96152..5fe6834 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -7,6 +7,7 @@ import { bids, mediaOutletRequests, comments, + predictionBets, type User, type UpsertUser, type MediaOutlet, @@ -23,6 +24,8 @@ import { type InsertMediaOutletRequest, type Comment, type InsertComment, + type PredictionBet, + type InsertPredictionBet, } from "@shared/schema"; import { db } from "./db"; import { eq, desc, and, ilike, sql } from "drizzle-orm"; @@ -61,6 +64,11 @@ export interface IStorage { getMediaOutletRequests(status?: string): Promise; createMediaOutletRequest(request: InsertMediaOutletRequest): Promise; updateMediaOutletRequestStatus(id: string, status: string, reviewerId: string): Promise; + + // Prediction bet operations + createPredictionBet(bet: InsertPredictionBet): Promise; + getPredictionBetsByMarket(marketId: string): Promise; + getPredictionBetsByUser(userId: string): Promise; // Comment operations getCommentsByArticle(articleId: string): Promise; @@ -286,6 +294,51 @@ export class DatabaseStorage implements IStorage { const [newComment] = await db.insert(comments).values(comment).returning(); return newComment; } + + // Prediction bet operations + async createPredictionBet(bet: InsertPredictionBet): Promise { + // Get the current market to determine the price + const market = await this.getPredictionMarketById(bet.marketId); + if (!market) { + throw new Error("Prediction market not found"); + } + + // Use current market price for the bet + const price = bet.side === "yes" ? market.yesPrice : market.noPrice; + + const [newBet] = await db.insert(predictionBets).values({ + ...bet, + price: price.toString() + }).returning(); + + // Update market volume and bet count + await db + .update(predictionMarkets) + .set({ + totalVolume: sql`total_volume + ${bet.amount}`, + totalBets: sql`total_bets + 1`, + updatedAt: new Date() + }) + .where(eq(predictionMarkets.id, bet.marketId)); + + return newBet; + } + + async getPredictionBetsByMarket(marketId: string): Promise { + return await db + .select() + .from(predictionBets) + .where(eq(predictionBets.marketId, marketId)) + .orderBy(desc(predictionBets.createdAt)); + } + + async getPredictionBetsByUser(userId: string): Promise { + return await db + .select() + .from(predictionBets) + .where(eq(predictionBets.userId, userId)) + .orderBy(desc(predictionBets.createdAt)); + } // Analytics operations async getAnalytics(): Promise<{ diff --git a/shared/schema.ts b/shared/schema.ts index 4128fc3..5cbc05c 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -136,6 +136,17 @@ export const comments = pgTable("comments", { updatedAt: timestamp("updated_at").defaultNow(), }); +// Prediction market bets +export const predictionBets = pgTable("prediction_bets", { + id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), + marketId: varchar("market_id").notNull(), + userId: varchar("user_id").notNull(), + side: varchar("side", { enum: ["yes", "no"] }).notNull(), + amount: decimal("amount", { precision: 12, scale: 2 }).notNull(), + price: decimal("price", { precision: 5, scale: 4 }).notNull(), // Price at time of bet + createdAt: timestamp("created_at").defaultNow(), +}); + // Insert schemas export const insertUserSchema = createInsertSchema(users).omit({ id: true, @@ -183,6 +194,11 @@ export const insertCommentSchema = createInsertSchema(comments).omit({ updatedAt: true, }); +export const insertPredictionBetSchema = createInsertSchema(predictionBets).omit({ + id: true, + createdAt: true, +}); + // Types export type UpsertUser = typeof users.$inferInsert; export type User = typeof users.$inferSelect; @@ -200,3 +216,5 @@ export type InsertMediaOutletRequest = z.infer; export type Comment = typeof comments.$inferSelect; +export type InsertPredictionBet = z.infer; +export type PredictionBet = typeof predictionBets.$inferSelect;