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
This commit is contained in:
kimjaehyeon0101
2025-09-29 19:14:42 +00:00
parent df46319424
commit d6682e32d9
5 changed files with 410 additions and 97 deletions

View File

@ -14,6 +14,10 @@ run = ["npm", "run", "start"]
localPort = 5000 localPort = 5000
externalPort = 80 externalPort = 80
[[ports]]
localPort = 34047
externalPort = 3002
[[ports]] [[ports]]
localPort = 37531 localPort = 37531
externalPort = 3001 externalPort = 3001

View File

@ -1,69 +1,102 @@
import { useQuery } from "@tanstack/react-query"; import { useState } from "react";
import { useRoute } from "wouter"; import { useQuery, useMutation } from "@tanstack/react-query";
import { useRoute, useLocation } from "wouter";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; 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"; import type { Article, PredictionMarket } from "@shared/schema";
export default function Article() { export default function Article() {
const [, params] = useRoute("/articles/:slug"); const [, params] = useRoute("/articles/:slug");
const [, setLocation] = useLocation();
const { toast } = useToast();
const [betAmounts, setBetAmounts] = useState<Record<string, string>>({});
const { data: article, isLoading: articleLoading } = useQuery<Article>({ const { data: article, isLoading: articleLoading } = useQuery<Article>({
queryKey: ["/api/articles", params?.slug], queryKey: ["/api/articles", params?.slug],
enabled: !!params?.slug enabled: !!params?.slug
}); });
const { data: predictionMarkets = [], isLoading: marketsLoading } = useQuery<PredictionMarket[]>({ const { data: markets = [], isLoading: marketsLoading } = useQuery<PredictionMarket[]>({
queryKey: ["/api/prediction-markets", { articleId: article?.id }], queryKey: ["/api/articles", params?.slug, "markets"],
enabled: !!article?.id 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) { if (articleLoading) {
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<div className="max-w-4xl mx-auto px-6 py-8">
<div className="animate-pulse">
<div className="h-8 bg-muted rounded w-2/3 mb-4"></div>
<div className="h-64 bg-muted rounded mb-6"></div>
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-4 bg-muted rounded"></div>
))}
</div>
</div>
</div>
</div>
);
}
if (!article) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold mb-2">Article Not Found</h1>
<p className="text-muted-foreground mb-4">The article you're looking for doesn't exist.</p>
<Button onClick={() => window.history.back()}>Go Back</Button>
</div>
</div>
);
}
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 (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="bg-card border-b border-border sticky top-0 z-50"> <header className="bg-card border-b border-border sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-6 py-4"> <div className="max-w-4xl mx-auto px-6 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center text-primary-foreground font-bold text-lg"> <div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center text-primary-foreground font-bold text-lg">
@ -74,10 +107,11 @@ export default function Article() {
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Button variant="ghost" onClick={() => window.history.back()}> <Button variant="ghost" onClick={() => window.history.back()}>
Back <ArrowLeft className="h-4 w-4 mr-2" />
</Button> </Button>
<Button variant="outline" onClick={() => window.location.href = "/"}> <Button variant="outline" onClick={() => setLocation("/")}>
Home
</Button> </Button>
</div> </div>
</div> </div>
@ -85,36 +119,67 @@ export default function Article() {
</header> </header>
<main className="max-w-4xl mx-auto px-6 py-8"> <main className="max-w-4xl mx-auto px-6 py-8">
{/* Article Header */} <div className="animate-pulse">
<div className="mb-8"> <div className="h-8 bg-gray-300 rounded mb-4 w-3/4"></div>
<div className="flex items-center space-x-4 mb-6"> <div className="h-4 bg-gray-300 rounded mb-6 w-1/2"></div>
<img <div className="h-64 bg-gray-300 rounded mb-6"></div>
src="https://images.unsplash.com/photo-1560250097-0b93528c311a?ixlib=rb-4.0.3&w=48&h=48&fit=crop&crop=face" <div className="space-y-2">
alt="Author" <div className="h-4 bg-gray-300 rounded"></div>
className="w-12 h-12 rounded-full object-cover" <div className="h-4 bg-gray-300 rounded"></div>
/> <div className="h-4 bg-gray-300 rounded w-3/4"></div>
<div>
<h2 className="text-xl font-bold">Alex Karp</h2>
<p className="text-sm text-muted-foreground">CEO of Palantir</p>
</div> </div>
</div> </div>
</main>
</div>
);
}
if (!article) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4"> </h1>
<Button onClick={() => setLocation("/")}> </Button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="bg-card border-b border-border sticky top-0 z-50">
<div className="max-w-4xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center text-primary-foreground font-bold text-lg">
S
</div>
<span className="text-2xl font-bold tracking-tight">SAPIENS</span>
</div> </div>
<div className="flex items-center space-x-4">
<Button variant="ghost" onClick={() => window.history.back()} data-testid="button-back">
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={() => setLocation("/")} data-testid="button-home">
</Button>
</div>
</div>
</div>
</header>
<main className="max-w-4xl mx-auto px-6 py-8">
{/* Article Content */} {/* Article Content */}
<article> <article className="mb-12">
{article.imageUrl && ( <header className="mb-8">
<img <h1 className="text-4xl font-bold mb-4" data-testid="text-article-title">{article.title}</h1>
src={article.imageUrl} <div className="flex items-center space-x-4 text-muted-foreground mb-6">
alt={article.title} <span data-testid="text-article-date">{formatDate(article.publishedAt)}</span>
className="w-full h-64 object-cover rounded-lg mb-6"
/>
)}
<h1 className="text-3xl font-bold mb-4">{article.title}</h1>
<div className="flex items-center space-x-4 mb-6 text-sm text-muted-foreground">
<span>{formatDate(article.publishedAt!)}</span>
<span></span> <span></span>
<span data-testid="text-article-author">{article.author}</span>
{article.tags?.map((tag) => ( {article.tags?.map((tag) => (
<Badge key={tag} variant="secondary"> <Badge key={tag} variant="secondary">
{tag} {tag}
@ -122,41 +187,181 @@ export default function Article() {
))} ))}
</div> </div>
<div className="prose prose-lg max-w-none text-foreground mb-8"> {article.imageUrl && (
<div className="mb-8">
<img
src={article.imageUrl}
alt={article.title}
className="w-full h-96 object-cover rounded-xl"
data-testid="img-article-hero"
/>
</div>
)}
</header>
<div className="prose prose-lg max-w-none">
{article.excerpt && ( {article.excerpt && (
<p className="text-lg text-muted-foreground mb-6 font-medium"> <p className="text-lg text-muted-foreground mb-6 font-medium" data-testid="text-article-excerpt">
{article.excerpt} {article.excerpt}
</p> </p>
)} )}
<div className="whitespace-pre-wrap"> <div
className="text-lg leading-relaxed whitespace-pre-wrap"
data-testid="text-article-content"
>
{article.content} {article.content}
</div> </div>
</div> </div>
</article> </article>
{/* Related Prediction Markets */} {/* Prediction Markets Section */}
{predictionMarkets.length > 0 && ( <section>
<div className="mt-12 border-t border-border pt-8"> <div className="flex items-center space-x-3 mb-6">
<h3 className="text-xl font-bold mb-6">Related Prediction Markets</h3> <TrendingUp className="h-6 w-6 text-primary" />
<h2 className="text-2xl font-bold"> </h2>
</div>
{marketsLoading ? (
<div className="space-y-4"> <div className="space-y-4">
{predictionMarkets.slice(0, 3).map((market) => ( {Array.from({ length: 3 }).map((_, i) => (
<PredictionMarketCard key={market.id} market={market} /> <Card key={i} className="animate-pulse">
<CardContent className="p-6">
<div className="h-6 bg-gray-300 rounded mb-4 w-3/4"></div>
<div className="grid grid-cols-2 gap-4">
<div className="h-20 bg-gray-300 rounded"></div>
<div className="h-20 bg-gray-300 rounded"></div>
</div>
</CardContent>
</Card>
))} ))}
</div> </div>
) : markets.length > 0 ? (
<div className="space-y-6">
{markets.map((market) => (
<Card key={market.id} className="border-primary/20">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span data-testid={`text-market-question-${market.id}`}>{market.question}</span>
<Badge variant="outline" className="flex items-center space-x-1">
<Clock className="h-3 w-3" />
<span>{formatDate(market.resolutionDate)}</span>
</Badge>
</CardTitle>
</CardHeader>
{predictionMarkets.length > 3 && ( <CardContent className="p-6">
<Button <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
variant="outline" {/* Yes Option */}
className="w-full mt-6" <div className="border rounded-lg p-4 bg-green-50 border-green-200">
data-testid="button-view-all-predictions" <div className="flex items-center justify-between mb-3">
> <div className="flex items-center space-x-2">
View All Related Predictions <TrendingUp className="h-5 w-5 text-green-600" />
</Button> <span className="font-semibold text-green-900">YES</span>
)}
</div> </div>
<div className="text-right">
<div className="text-sm text-green-700"> </div>
<div className="font-bold text-green-900" data-testid={`text-yes-price-${market.id}`}>
{formatPercentage(market.yesPrice)}
</div>
</div>
</div>
<div className="space-y-3">
<Input
type="number"
placeholder="베팅 금액 (원)"
value={betAmounts[market.id] || ""}
onChange={(e) => handleBetAmountChange(market.id, e.target.value)}
className="border-green-300 focus:border-green-500"
data-testid={`input-bet-amount-${market.id}`}
/>
<Button
onClick={() => handlePlaceBet(market.id, "yes")}
disabled={placeBetMutation.isPending}
className="w-full bg-green-600 hover:bg-green-700"
data-testid={`button-bet-yes-${market.id}`}
>
<DollarSign className="h-4 w-4 mr-2" />
YES
</Button>
</div>
</div>
{/* No Option */}
<div className="border rounded-lg p-4 bg-red-50 border-red-200">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-2">
<TrendingDown className="h-5 w-5 text-red-600" />
<span className="font-semibold text-red-900">NO</span>
</div>
<div className="text-right">
<div className="text-sm text-red-700"> </div>
<div className="font-bold text-red-900" data-testid={`text-no-price-${market.id}`}>
{formatPercentage(market.noPrice)}
</div>
</div>
</div>
<div className="space-y-3">
<Input
type="number"
placeholder="베팅 금액 (원)"
value={betAmounts[market.id] || ""}
onChange={(e) => handleBetAmountChange(market.id, e.target.value)}
className="border-red-300 focus:border-red-500"
data-testid={`input-bet-amount-no-${market.id}`}
/>
<Button
onClick={() => handlePlaceBet(market.id, "no")}
disabled={placeBetMutation.isPending}
className="w-full bg-red-600 hover:bg-red-700"
data-testid={`button-bet-no-${market.id}`}
>
<DollarSign className="h-4 w-4 mr-2" />
NO
</Button>
</div>
</div>
</div>
{/* Market Stats */}
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="grid grid-cols-3 gap-4 text-center text-sm text-muted-foreground">
<div>
<div className="font-semibold" data-testid={`text-total-volume-${market.id}`}>
{formatCurrency(market.totalVolume)}
</div>
<div> </div>
</div>
<div>
<div className="font-semibold" data-testid={`text-total-bets-${market.id}`}>
{market.totalBets}
</div>
<div> </div>
</div>
<div>
<div className="font-semibold">
{formatDate(market.resolutionDate)}
</div>
<div> </div>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
<TrendingUp className="h-12 w-12 mx-auto mb-4 opacity-50" />
<h3 className="text-lg font-semibold mb-2"> </h3>
<p> .</p>
</CardContent>
</Card>
)} )}
</section>
</main> </main>
</div> </div>
); );

View File

@ -2,7 +2,7 @@ import type { Express } from "express";
import { createServer, type Server } from "http"; import { createServer, type Server } from "http";
import { storage } from "./storage"; import { storage } from "./storage";
import { setupAuth, isAuthenticated } from "./simpleAuth"; 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<Server> { export async function registerRoutes(app: Express): Promise<Server> {
// Auth middleware // Auth middleware
@ -170,7 +170,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
app.post('/api/auctions/:id/bid', isAuthenticated, async (req: any, res) => { app.post('/api/auctions/:id/bid', isAuthenticated, async (req: any, res) => {
try { try {
const userId = req.user.id; const userId = req.user.claims.sub;
const bidData = insertBidSchema.parse({ const bidData = insertBidSchema.parse({
...req.body, ...req.body,
auctionId: req.params.id, auctionId: req.params.id,
@ -197,7 +197,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
return res.status(404).json({ message: "No active auction found for this media outlet" }); 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({ const bidData = insertBidSchema.parse({
...req.body, ...req.body,
auctionId: auction.id, auctionId: auction.id,
@ -212,6 +212,39 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
}); });
// 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 // Media outlet request routes
app.get('/api/media-outlet-requests', isAuthenticated, async (req: any, res) => { app.get('/api/media-outlet-requests', isAuthenticated, async (req: any, res) => {
try { try {

View File

@ -7,6 +7,7 @@ import {
bids, bids,
mediaOutletRequests, mediaOutletRequests,
comments, comments,
predictionBets,
type User, type User,
type UpsertUser, type UpsertUser,
type MediaOutlet, type MediaOutlet,
@ -23,6 +24,8 @@ import {
type InsertMediaOutletRequest, type InsertMediaOutletRequest,
type Comment, type Comment,
type InsertComment, type InsertComment,
type PredictionBet,
type InsertPredictionBet,
} from "@shared/schema"; } from "@shared/schema";
import { db } from "./db"; import { db } from "./db";
import { eq, desc, and, ilike, sql } from "drizzle-orm"; import { eq, desc, and, ilike, sql } from "drizzle-orm";
@ -62,6 +65,11 @@ export interface IStorage {
createMediaOutletRequest(request: InsertMediaOutletRequest): Promise<MediaOutletRequest>; createMediaOutletRequest(request: InsertMediaOutletRequest): Promise<MediaOutletRequest>;
updateMediaOutletRequestStatus(id: string, status: string, reviewerId: string): Promise<MediaOutletRequest>; updateMediaOutletRequestStatus(id: string, status: string, reviewerId: string): Promise<MediaOutletRequest>;
// Prediction bet operations
createPredictionBet(bet: InsertPredictionBet): Promise<PredictionBet>;
getPredictionBetsByMarket(marketId: string): Promise<PredictionBet[]>;
getPredictionBetsByUser(userId: string): Promise<PredictionBet[]>;
// Comment operations // Comment operations
getCommentsByArticle(articleId: string): Promise<Comment[]>; getCommentsByArticle(articleId: string): Promise<Comment[]>;
createComment(comment: InsertComment): Promise<Comment>; createComment(comment: InsertComment): Promise<Comment>;
@ -287,6 +295,51 @@ export class DatabaseStorage implements IStorage {
return newComment; return newComment;
} }
// Prediction bet operations
async createPredictionBet(bet: InsertPredictionBet): Promise<PredictionBet> {
// 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<PredictionBet[]> {
return await db
.select()
.from(predictionBets)
.where(eq(predictionBets.marketId, marketId))
.orderBy(desc(predictionBets.createdAt));
}
async getPredictionBetsByUser(userId: string): Promise<PredictionBet[]> {
return await db
.select()
.from(predictionBets)
.where(eq(predictionBets.userId, userId))
.orderBy(desc(predictionBets.createdAt));
}
// Analytics operations // Analytics operations
async getAnalytics(): Promise<{ async getAnalytics(): Promise<{
totalArticles: number; totalArticles: number;

View File

@ -136,6 +136,17 @@ export const comments = pgTable("comments", {
updatedAt: timestamp("updated_at").defaultNow(), 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 // Insert schemas
export const insertUserSchema = createInsertSchema(users).omit({ export const insertUserSchema = createInsertSchema(users).omit({
id: true, id: true,
@ -183,6 +194,11 @@ export const insertCommentSchema = createInsertSchema(comments).omit({
updatedAt: true, updatedAt: true,
}); });
export const insertPredictionBetSchema = createInsertSchema(predictionBets).omit({
id: true,
createdAt: true,
});
// Types // Types
export type UpsertUser = typeof users.$inferInsert; export type UpsertUser = typeof users.$inferInsert;
export type User = typeof users.$inferSelect; export type User = typeof users.$inferSelect;
@ -200,3 +216,5 @@ export type InsertMediaOutletRequest = z.infer<typeof insertMediaOutletRequestSc
export type MediaOutletRequest = typeof mediaOutletRequests.$inferSelect; export type MediaOutletRequest = typeof mediaOutletRequests.$inferSelect;
export type InsertComment = z.infer<typeof insertCommentSchema>; export type InsertComment = z.infer<typeof insertCommentSchema>;
export type Comment = typeof comments.$inferSelect; export type Comment = typeof comments.$inferSelect;
export type InsertPredictionBet = z.infer<typeof insertPredictionBetSchema>;
export type PredictionBet = typeof predictionBets.$inferSelect;