Add a page for media outlets to auction administrative rights
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
This commit is contained in:
@ -12,6 +12,7 @@ import AdminDashboard from "@/pages/AdminDashboard";
|
||||
import SuperAdminDashboard from "@/pages/SuperAdminDashboard";
|
||||
import Auctions from "@/pages/Auctions";
|
||||
import AuctionGuide from "@/pages/AuctionGuide";
|
||||
import MediaOutletAuction from "@/pages/MediaOutletAuction";
|
||||
import NotFound from "@/pages/not-found";
|
||||
|
||||
function Router() {
|
||||
@ -20,6 +21,7 @@ function Router() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/" component={isAuthenticated ? Home : Landing} />
|
||||
<Route path="/media/:slug/auction" component={MediaOutletAuction} />
|
||||
<Route path="/media/:slug" component={MediaOutlet} />
|
||||
<Route path="/articles/:slug" component={Article} />
|
||||
<Route path="/auctions" component={Auctions} />
|
||||
|
||||
396
client/src/pages/MediaOutletAuction.tsx
Normal file
396
client/src/pages/MediaOutletAuction.tsx
Normal file
@ -0,0 +1,396 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user