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:
@ -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<Record<string, string>>({});
|
||||
|
||||
const { data: article, isLoading: articleLoading } = useQuery<Article>({
|
||||
queryKey: ["/api/articles", params?.slug],
|
||||
enabled: !!params?.slug
|
||||
});
|
||||
|
||||
const { data: predictionMarkets = [], isLoading: marketsLoading } = useQuery<PredictionMarket[]>({
|
||||
queryKey: ["/api/prediction-markets", { articleId: article?.id }],
|
||||
enabled: !!article?.id
|
||||
const { data: markets = [], isLoading: marketsLoading } = useQuery<PredictionMarket[]>({
|
||||
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 (
|
||||
<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>
|
||||
))}
|
||||
<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 className="flex items-center space-x-4">
|
||||
<Button variant="ghost" onClick={() => window.history.back()}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
뒤로가기
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setLocation("/")}>
|
||||
홈으로
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-300 rounded mb-4 w-3/4"></div>
|
||||
<div className="h-4 bg-gray-300 rounded mb-6 w-1/2"></div>
|
||||
<div className="h-64 bg-gray-300 rounded mb-6"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-300 rounded"></div>
|
||||
<div className="h-4 bg-gray-300 rounded"></div>
|
||||
<div className="h-4 bg-gray-300 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -40,30 +138,18 @@ export default function 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>
|
||||
<h1 className="text-2xl font-bold mb-4">기사를 찾을 수 없습니다</h1>
|
||||
<Button onClick={() => setLocation("/")}>홈으로 돌아가기</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">
|
||||
<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 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">
|
||||
@ -73,11 +159,12 @@ export default function Article() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" onClick={() => window.history.back()}>
|
||||
← Back
|
||||
<Button variant="ghost" onClick={() => window.history.back()} data-testid="button-back">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
뒤로가기
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => window.location.href = "/"}>
|
||||
Home
|
||||
<Button variant="outline" onClick={() => setLocation("/")} data-testid="button-home">
|
||||
홈으로
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -85,79 +172,197 @@ export default function Article() {
|
||||
</header>
|
||||
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
{/* Article Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center space-x-4 mb-6">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1560250097-0b93528c311a?ixlib=rb-4.0.3&w=48&h=48&fit=crop&crop=face"
|
||||
alt="Author"
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">Alex Karp</h2>
|
||||
<p className="text-sm text-muted-foreground">CEO of Palantir</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Article Content */}
|
||||
<article>
|
||||
{article.imageUrl && (
|
||||
<img
|
||||
src={article.imageUrl}
|
||||
alt={article.title}
|
||||
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>
|
||||
{article.tags?.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="prose prose-lg max-w-none text-foreground mb-8">
|
||||
<article className="mb-12">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-4" data-testid="text-article-title">{article.title}</h1>
|
||||
<div className="flex items-center space-x-4 text-muted-foreground mb-6">
|
||||
<span data-testid="text-article-date">{formatDate(article.publishedAt)}</span>
|
||||
<span>•</span>
|
||||
<span data-testid="text-article-author">{article.author}</span>
|
||||
{article.tags?.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{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 && (
|
||||
<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}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="whitespace-pre-wrap">
|
||||
<div
|
||||
className="text-lg leading-relaxed whitespace-pre-wrap"
|
||||
data-testid="text-article-content"
|
||||
>
|
||||
{article.content}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Related Prediction Markets */}
|
||||
{predictionMarkets.length > 0 && (
|
||||
<div className="mt-12 border-t border-border pt-8">
|
||||
<h3 className="text-xl font-bold mb-6">Related Prediction Markets</h3>
|
||||
|
||||
{/* Prediction Markets Section */}
|
||||
<section>
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<TrendingUp className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-2xl font-bold">관련 예측시장</h2>
|
||||
</div>
|
||||
|
||||
{marketsLoading ? (
|
||||
<div className="space-y-4">
|
||||
{predictionMarkets.slice(0, 3).map((market) => (
|
||||
<PredictionMarketCard key={market.id} market={market} />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<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>
|
||||
|
||||
{predictionMarkets.length > 3 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-6"
|
||||
data-testid="button-view-all-predictions"
|
||||
>
|
||||
View All Related Predictions
|
||||
</Button>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Yes Option */}
|
||||
<div className="border rounded-lg p-4 bg-green-50 border-green-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<TrendingUp className="h-5 w-5 text-green-600" />
|
||||
<span className="font-semibold text-green-900">YES</span>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user