Adds a new route and component for media outlet auctions, allowing users to bid on administrative rights. 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/jvFIdY3
396 lines
15 KiB
TypeScript
396 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 { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { queryClient, apiRequest } from "@/lib/queryClient";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { Clock, Gavel, TrendingUp, User } from "lucide-react";
|
|
import type { MediaOutlet, Auction, Bid } from "@shared/schema";
|
|
|
|
export default function MediaOutletAuction() {
|
|
const [, params] = useRoute("/media/:slug/auction");
|
|
const [, setLocation] = useLocation();
|
|
const [bidAmount, setBidAmount] = useState("");
|
|
const [qualityScore, setQualityScore] = useState("");
|
|
const { user, isAuthenticated } = useAuth();
|
|
const { toast } = useToast();
|
|
|
|
const { data: outlet, isLoading: outletLoading } = useQuery<MediaOutlet>({
|
|
queryKey: ["/api/media-outlets", params?.slug],
|
|
enabled: !!params?.slug
|
|
});
|
|
|
|
const { data: auction, isLoading: auctionLoading } = useQuery<Auction>({
|
|
queryKey: ["/api/media-outlets", params?.slug, "auction"],
|
|
enabled: !!params?.slug
|
|
});
|
|
|
|
const placeBidMutation = useMutation({
|
|
mutationFn: async (bidData: { amount: number; qualityScore?: number }) => {
|
|
return apiRequest(`/api/media-outlets/${params?.slug}/auction/bids`, {
|
|
method: "POST",
|
|
body: JSON.stringify(bidData),
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
toast({
|
|
title: "입찰 성공",
|
|
description: "입찰이 성공적으로 등록되었습니다.",
|
|
});
|
|
setBidAmount("");
|
|
setQualityScore("");
|
|
queryClient.invalidateQueries({ queryKey: ["/api/media-outlets", params?.slug, "auction"] });
|
|
},
|
|
onError: (error: any) => {
|
|
toast({
|
|
title: "입찰 실패",
|
|
description: error.message || "입찰 중 오류가 발생했습니다.",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const handlePlaceBid = () => {
|
|
if (!bidAmount) {
|
|
toast({
|
|
title: "입찰 금액 필요",
|
|
description: "입찰 금액을 입력해주세요.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const amount = parseFloat(bidAmount);
|
|
if (isNaN(amount) || amount <= 0) {
|
|
toast({
|
|
title: "유효하지 않은 금액",
|
|
description: "올바른 입찰 금액을 입력해주세요.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (auction && amount <= auction.currentBid) {
|
|
toast({
|
|
title: "입찰 금액 부족",
|
|
description: `현재 최고 입찰가(${auction.currentBid}원)보다 높은 금액을 입력해주세요.`,
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const bidData: { amount: number; qualityScore?: number } = { amount };
|
|
if (qualityScore) {
|
|
const score = parseFloat(qualityScore);
|
|
if (!isNaN(score) && score >= 0 && score <= 100) {
|
|
bidData.qualityScore = score;
|
|
}
|
|
}
|
|
|
|
placeBidMutation.mutate(bidData);
|
|
};
|
|
|
|
const formatCurrency = (amount: number) => {
|
|
return new Intl.NumberFormat('ko-KR', {
|
|
style: 'currency',
|
|
currency: 'KRW'
|
|
}).format(amount);
|
|
};
|
|
|
|
const formatTimeRemaining = (endDate: string) => {
|
|
const end = new Date(endDate);
|
|
const now = new Date();
|
|
const diff = end.getTime() - now.getTime();
|
|
|
|
if (diff <= 0) return "경매 종료";
|
|
|
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
|
|
|
if (days > 0) return `${days}일 ${hours}시간 남음`;
|
|
if (hours > 0) return `${hours}시간 ${minutes}분 남음`;
|
|
return `${minutes}분 남음`;
|
|
};
|
|
|
|
if (outletLoading || auctionLoading) {
|
|
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-1/3 mb-4"></div>
|
|
<div className="h-64 bg-muted rounded mb-6"></div>
|
|
<div className="h-32 bg-muted rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!outlet) {
|
|
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">언론매체를 찾을 수 없습니다</h1>
|
|
<p className="text-muted-foreground mb-4">요청하신 언론매체가 존재하지 않습니다.</p>
|
|
<Button onClick={() => setLocation("/")}>홈으로 가기</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!auction) {
|
|
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-7xl 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={() => setLocation(`/media/${params?.slug}`)}>
|
|
← 언론매체로 돌아가기
|
|
</Button>
|
|
<Button variant="outline" onClick={() => setLocation("/")}>
|
|
홈
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="max-w-4xl mx-auto px-6 py-8">
|
|
<div className="text-center">
|
|
<h1 className="text-2xl font-bold mb-2">진행 중인 경매가 없습니다</h1>
|
|
<p className="text-muted-foreground mb-4">
|
|
{outlet.name}의 관리자 권한에 대한 경매가 현재 진행되고 있지 않습니다.
|
|
</p>
|
|
<Button onClick={() => setLocation(`/media/${params?.slug}`)}>
|
|
언론매체 페이지로 돌아가기
|
|
</Button>
|
|
</div>
|
|
</main>
|
|
</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-7xl 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={() => setLocation(`/media/${params?.slug}`)}>
|
|
← 언론매체로 돌아가기
|
|
</Button>
|
|
<Button variant="outline" onClick={() => setLocation("/")}>
|
|
홈
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="max-w-4xl mx-auto px-6 py-8">
|
|
{/* Outlet Header */}
|
|
<div className="mb-8">
|
|
<div className="flex items-start space-x-6 mb-6">
|
|
{outlet.imageUrl ? (
|
|
<img
|
|
src={outlet.imageUrl}
|
|
alt={outlet.name}
|
|
className="w-20 h-20 rounded-full object-cover"
|
|
style={{objectPosition: 'center top'}}
|
|
/>
|
|
) : (
|
|
<div className="w-20 h-20 bg-primary/20 rounded-full flex items-center justify-center">
|
|
<span className="text-primary font-bold text-2xl">
|
|
{outlet.name.charAt(0)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1">
|
|
<h1 className="text-3xl font-bold mb-2">{outlet.name} 관리자 권한 경매</h1>
|
|
<p className="text-lg text-muted-foreground mb-4">
|
|
{outlet.description}
|
|
</p>
|
|
<div className="flex items-center space-x-2">
|
|
<Badge variant="secondary" className="capitalize">
|
|
{outlet.category}
|
|
</Badge>
|
|
{outlet.tags?.map((tag) => (
|
|
<Badge key={tag} variant="outline">
|
|
{tag}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
{/* Auction Information */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center space-x-2">
|
|
<Gavel className="h-5 w-5" />
|
|
<span>경매 정보</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
|
<div className="flex items-center space-x-2">
|
|
<TrendingUp className="h-4 w-4 text-green-600" />
|
|
<span className="font-medium">현재 최고가</span>
|
|
</div>
|
|
<span className="text-2xl font-bold text-green-600">
|
|
{formatCurrency(auction.currentBid)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
|
<div className="flex items-center space-x-2">
|
|
<Clock className="h-4 w-4 text-orange-600" />
|
|
<span className="font-medium">남은 시간</span>
|
|
</div>
|
|
<span className="text-lg font-bold text-orange-600">
|
|
{formatTimeRemaining(auction.endDate)}
|
|
</span>
|
|
</div>
|
|
|
|
{auction.highestBidder && (
|
|
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
|
<div className="flex items-center space-x-2">
|
|
<User className="h-4 w-4 text-blue-600" />
|
|
<span className="font-medium">최고 입찰자</span>
|
|
</div>
|
|
<span className="text-lg font-bold text-blue-600">
|
|
{auction.highestBidder.slice(0, 3)}***
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{auction.qualityScore && (
|
|
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
|
<div className="flex items-center space-x-2">
|
|
<span className="font-medium">품질 점수</span>
|
|
</div>
|
|
<span className="text-lg font-bold">
|
|
{auction.qualityScore}/100
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Bidding Form */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>입찰하기</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!isAuthenticated ? (
|
|
<div className="text-center p-6">
|
|
<p className="text-muted-foreground mb-4">
|
|
입찰하려면 로그인이 필요합니다.
|
|
</p>
|
|
<Button onClick={() => setLocation("/")}>
|
|
로그인하러 가기
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="bidAmount">입찰 금액 (원)</Label>
|
|
<Input
|
|
id="bidAmount"
|
|
type="number"
|
|
value={bidAmount}
|
|
onChange={(e) => setBidAmount(e.target.value)}
|
|
placeholder={`${auction.currentBid + 1000} 이상`}
|
|
min={auction.currentBid + 1}
|
|
data-testid="input-bid-amount"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="qualityScore">품질 점수 (선택사항, 0-100)</Label>
|
|
<Input
|
|
id="qualityScore"
|
|
type="number"
|
|
value={qualityScore}
|
|
onChange={(e) => setQualityScore(e.target.value)}
|
|
placeholder="품질 점수를 입력하세요"
|
|
min={0}
|
|
max={100}
|
|
data-testid="input-quality-score"
|
|
/>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={handlePlaceBid}
|
|
disabled={placeBidMutation.isPending || !bidAmount}
|
|
className="w-full"
|
|
data-testid="button-place-bid"
|
|
>
|
|
{placeBidMutation.isPending ? "입찰 중..." : "입찰하기"}
|
|
</Button>
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
* 입찰이 성공하면 취소할 수 없습니다.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Auction Description */}
|
|
<Card className="mt-8">
|
|
<CardHeader>
|
|
<CardTitle>경매 안내</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="prose prose-sm max-w-none text-muted-foreground">
|
|
<p>
|
|
이 경매는 <strong>{outlet.name}</strong>의 관리자 권한을 획득하기 위한 경매입니다.
|
|
관리자 권한을 획득하면 다음과 같은 기능을 사용할 수 있습니다:
|
|
</p>
|
|
<ul className="list-disc list-inside space-y-1 mt-4">
|
|
<li>언론매체의 기사 관리 및 편집</li>
|
|
<li>예측시장 이벤트 생성 및 관리</li>
|
|
<li>언론매체 프로필 정보 수정</li>
|
|
<li>구독자 및 팔로워 관리</li>
|
|
</ul>
|
|
<p className="mt-4">
|
|
경매는 <strong>{formatTimeRemaining(auction.endDate)}</strong> 후에 종료됩니다.
|
|
최고 입찰자가 관리자 권한을 획득하게 됩니다.
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</main>
|
|
</div>
|
|
);
|
|
} |