Files
sapiens-web3/client/src/pages/Article.tsx
kimjaehyeon0101 06e79e1e25 Update logo source to fix display issues across the platform
Corrected image source path for the SAPIENS logo in multiple components (Footer, Header, LoginModal, AdminDashboard, Article, AuctionGuide, Auctions, Community, Home, Landing, MediaOutlet, Report) from `/attached_assets/검은색 로고_1760521542271.png` to `/attached_assets/sapiens_black_logo.png` to resolve display errors.

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/uLfJUnK
2025-10-15 09:52:56 +00:00

468 lines
18 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, Search, User, LogOut, Settings } 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 LoginModal from "@/components/LoginModal";
import SearchModal from "@/components/SearchModal";
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, isAuthenticated } = useAuth();
const [betAmounts, setBetAmounts] = useState<Record<string, string>>({});
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
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 handleLogout = async () => {
try {
const response = await fetch("/api/logout", {
method: "POST",
credentials: "include",
});
if (response.ok) {
toast({
title: "Logout Successful",
description: "You have been successfully logged out.",
});
queryClient.invalidateQueries({ queryKey: ["/api/auth/user"] });
}
} catch (error) {
toast({
title: "Logout Error",
description: "An error occurred during logout.",
variant: "destructive",
});
}
};
const handleAdminPage = () => {
setLocation("/admin");
};
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 'TBD';
return new Intl.DateTimeFormat('en-US', {
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">Article Not Found</h1>
<Button onClick={() => setLocation("/")}>Return Home</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-3">
{outlet?.imageUrl ? (
<img
src={outlet.imageUrl}
alt={outlet.name}
className="w-10 h-10 rounded-full object-cover cursor-pointer hover:ring-2 hover:ring-blue-400 transition-all"
onClick={() => setLocation(`/media/${outlet.slug}`)}
data-testid="image-outlet-header-profile"
/>
) : (
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<span className="text-gray-600 font-bold text-sm">
{outlet?.name.charAt(0)}
</span>
</div>
)}
{outlet && (
<div className="flex items-center space-x-2">
<span className="text-3xl 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 className="flex items-center space-x-4">
<div
className="relative cursor-pointer flex items-center"
onClick={() => setIsSearchModalOpen(true)}
data-testid="search-container"
>
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<div className="pl-10 pr-16 py-1.5 bg-gray-50 border border-gray-200 rounded-md flex items-center space-x-2 hover:bg-gray-100 transition-colors">
<span className="text-sm text-gray-500">search in</span>
<img
src="/attached_assets/sapiens_black_logo.png"
alt="SAPIENS"
className="h-4 w-auto"
/>
</div>
</div>
{isAuthenticated && user ? (
<>
<Button
variant="ghost"
size="sm"
onClick={handleAdminPage}
data-testid="button-admin-dashboard"
>
Admin Dashboard
</Button>
<div className="flex items-center space-x-2 px-3 py-1 bg-gray-100 rounded-md">
<User className="h-4 w-4 text-gray-600" />
<span className="text-sm font-medium text-gray-700" data-testid="user-name">
{user.firstName} {user.lastName}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
data-testid="button-logout"
>
<LogOut className="h-4 w-4" />
</Button>
</>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setIsLoginModalOpen(true)}
data-testid="button-login"
>
Login
</Button>
)}
<Button
variant="ghost"
size="sm"
data-testid="button-settings"
>
<Settings className="h-4 w-4" />
</Button>
</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>
{/* Modals */}
<LoginModal
isOpen={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)}
/>
<SearchModal
isOpen={isSearchModalOpen}
onClose={() => setIsSearchModalOpen(false)}
/>
{/* Footer */}
<Footer />
</div>
);
}