Files
sapiens-web2/client/src/pages/Article.tsx
kimjaehyeon0101 36e910112f Add articles to the Chayan Asli media outlet page
Implement parsing logic for article content to distinguish between main text and section headers, updating the Article page component and removing an unused API endpoint.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 069d4324-6c40-4355-955e-c714a50de1ea
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3df548ff-50ae-432f-9be4-25d34eccc983/069d4324-6c40-4355-955e-c714a50de1ea/pIEDxt1
2025-09-30 02:46:48 +00:00

405 lines
16 KiB
TypeScript

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 { 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: 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 });
};
const parseArticleContent = (content: string) => {
const sectionRegex = /##SECTION##(.+?)##/g;
const parts: { type: 'section' | 'text'; content: string }[] = [];
let lastIndex = 0;
let match;
while ((match = sectionRegex.exec(content)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: 'text', content: content.slice(lastIndex, match.index) });
}
parts.push({ type: 'section', content: match[1] });
lastIndex = match.index + match[0].length;
}
if (lastIndex < content.length) {
parts.push({ type: 'text', content: content.slice(lastIndex) });
}
return parts;
};
if (articleLoading) {
return (
<div className="min-h-screen bg-background">
<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>
</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>
);
}
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 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 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" data-testid="text-article-excerpt">
{article.excerpt}
</p>
)}
<div
className="text-lg leading-relaxed"
data-testid="text-article-content"
>
{parseArticleContent(article.content).map((part, index) => {
if (part.type === 'section') {
return (
<h3
key={index}
className="text-2xl font-bold mt-8 mb-4 text-gray-800"
>
{part.content}
</h3>
);
}
return (
<p key={index} className="mb-4 whitespace-pre-wrap">
{part.content}
</p>
);
})}
</div>
</div>
</article>
{/* 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">
{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>
) : 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>
);
}