Files
sapiens-web3/client/src/pages/Article.tsx
kimjaehyeon0101 1fedd40899 Update header layout for media outlets and articles
Restructures the header component in Article, Community, MediaOutlet, and Report pages to display the logo on the left and the media outlet name below it, accommodating a two-line layout.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 9a264234-c5d7-4dcc-adf3-a954b149b30d
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3df548ff-50ae-432f-9be4-25d34eccc983/9a264234-c5d7-4dcc-adf3-a954b149b30d/uCJPlBt
2025-10-15 05:28:58 +00:00

376 lines
15 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, IdCard } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import Footer from "@/components/Footer";
import { useAuth } from "@/hooks/useAuth";
import type { Article, PredictionMarket, MediaOutlet } from "@shared/schema";
export default function Article() {
const [, params] = useRoute("/articles/:slug");
const [, setLocation] = useLocation();
const { toast } = useToast();
const { user } = useAuth();
const [betAmounts, setBetAmounts] = useState<Record<string, string>>({});
const { data: articleData, isLoading: articleLoading } = useQuery<Article & { outlet?: MediaOutlet }>({
queryKey: ["/api/articles", params?.slug],
enabled: !!params?.slug
});
const article = articleData;
const outlet = articleData?.outlet;
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("POST", `/api/prediction-markets/${marketId}/bets`, { side, amount });
},
onSuccess: () => {
toast({
title: "Bet Placed Successfully",
description: "Your prediction market bet has been placed."
});
queryClient.invalidateQueries({ queryKey: ["/api/articles", params?.slug, "markets"] });
setBetAmounts({});
},
onError: (error: any) => {
toast({
title: "Bet Failed",
description: error.message || "An error occurred while placing your bet.",
variant: "destructive"
});
}
});
const formatCurrency = (amount: string | number | null) => {
if (!amount) return '₩0';
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW'
}).format(Number(amount));
};
const formatPercentage = (value: string | number | null) => {
if (!value) return '0.0%';
return `${(Number(value) * 100).toFixed(1)}%`;
};
const formatDate = (dateString: string | Date | null) => {
if (!dateString) return '미정';
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") => {
// Default bet amount (can be customized later)
const amount = 10000;
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-gray-50">
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="animate-pulse flex space-x-3">
<div className="rounded-full bg-gray-300 h-10 w-10"></div>
<div className="h-5 bg-gray-300 rounded w-24"></div>
</div>
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-6 py-8 pb-32">
<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-gray-50 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-gray-50">
{/* Header */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
{outlet?.imageUrl ? (
<img
src={outlet.imageUrl}
alt={outlet.name}
className="w-10 h-10 rounded-full object-cover cursor-pointer"
onClick={() => setLocation(`/media/${outlet.slug}`)}
data-testid="img-outlet-profile"
/>
) : (
<div
className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center cursor-pointer"
onClick={() => outlet && setLocation(`/media/${outlet.slug}`)}
data-testid="img-outlet-profile-fallback"
>
<span className="text-white font-bold text-lg">
{outlet?.name.charAt(0)}
</span>
</div>
)}
{outlet && (
<div className="flex flex-col justify-between h-16">
<div className="flex items-center h-10 cursor-pointer hover:opacity-80 transition-opacity" onClick={() => setLocation("/")}>
<img
src="/attached_assets/logo_black_1759162717640.png"
alt="SAPIENS"
className="h-5 w-auto"
data-testid="logo-sapiens"
/>
</div>
<div className="flex items-center space-x-2 h-6">
<span className="text-sm font-bold text-gray-900" data-testid="text-outlet-name-header">
{outlet.name}
</span>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
setLocation(`/media/${outlet.slug}/report`);
}}
className="h-6 px-2 flex items-center gap-1 text-xs border-gray-300 hover:bg-gray-100"
aria-label="View complete profile"
data-testid="button-profile"
>
<IdCard className="h-3 w-3" />
<span>About</span>
</Button>
</div>
</div>
)}
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-6 py-12 pb-32">
{/* Article Content */}
<article className="mb-16 max-w-4xl mx-auto">
{article.imageUrl && (
<div className="mb-8 -mx-6 md:mx-0">
<img
src={article.imageUrl}
alt={article.title}
className="w-full h-auto max-h-[500px] object-cover rounded-none md:rounded-lg"
data-testid="image-article-hero"
/>
</div>
)}
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4 text-gray-900 leading-tight" data-testid="text-article-title">{article.title}</h1>
{article.excerpt && (
<p className="text-xl text-gray-600 mb-0 leading-relaxed" data-testid="text-article-excerpt">
{article.excerpt}
</p>
)}
</header>
<div
className="text-base text-gray-800"
data-testid="text-article-content"
>
{parseArticleContent(article.content).map((part, index) => {
if (part.type === 'section') {
return (
<h3
key={index}
className="text-xl font-bold mt-10 mb-4 text-gray-900"
>
{part.content.trim()}
</h3>
);
}
return (
<p key={index} className="mb-4 whitespace-pre-wrap leading-loose">
{part.content.trim()}
</p>
);
})}
</div>
</article>
{/* Prediction Markets Section */}
<section className="max-w-4xl mx-auto">
<h2 className="text-xl font-bold mb-6">Related Prediction Markets</h2>
{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-2">
{markets.map((market) => (
<Card key={market.id} className="border border-gray-200">
<CardContent className="p-4">
{/* Question and Live Badge */}
<div className="flex items-start justify-between mb-3">
<h3 className="text-base font-medium flex-1 pr-4" data-testid={`text-market-question-${market.id}`}>
{market.question}
</h3>
{market.isActive && (
<Badge className="bg-red-500 text-white text-xs px-2 py-0.5 shrink-0">
LIVE
</Badge>
)}
</div>
{/* Percentages */}
<div className="flex items-center justify-between mb-3">
<div className="text-left">
<div className="text-4xl font-bold" data-testid={`text-yes-price-${market.id}`}>
{formatPercentage(market.yesPrice)}
</div>
<div className="text-sm text-gray-500 mt-0.5">YES</div>
</div>
<div className="text-right">
<div className="text-4xl font-bold" data-testid={`text-no-price-${market.id}`}>
{formatPercentage(market.noPrice)}
</div>
<div className="text-sm text-gray-500 mt-0.5">NO</div>
</div>
</div>
{/* Yes/No Buttons */}
<div className="grid grid-cols-2 gap-2 mb-3">
<Button
variant="outline"
className="w-full border-gray-300 hover:bg-gray-50"
onClick={() => handlePlaceBet(market.id, "yes")}
disabled={placeBetMutation.isPending}
data-testid={`button-bet-yes-${market.id}`}
>
Yes
</Button>
<Button
variant="outline"
className="w-full border-gray-300 hover:bg-gray-50"
onClick={() => handlePlaceBet(market.id, "no")}
disabled={placeBetMutation.isPending}
data-testid={`button-bet-no-${market.id}`}
>
No
</Button>
</div>
{/* Market Info */}
<div className="flex items-center justify-between text-sm text-gray-500 pt-3 border-t border-gray-200">
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-1">
<DollarSign className="h-4 w-4" />
<span data-testid={`text-total-volume-${market.id}`}>
{formatCurrency(market.totalVolume)} Vol.
</span>
</div>
<div>
<span>Politics</span>
</div>
</div>
<div className="flex items-center space-x-1">
<Clock className="h-4 w-4" />
<span>in 3 months</span>
</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">No Related Prediction Markets</h3>
<p>There are no prediction markets related to this article yet.</p>
</CardContent>
</Card>
)}
</section>
</main>
{/* Footer */}
<Footer />
</div>
);
}