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:
kimjaehyeon0101
2025-09-29 18:56:57 +00:00
parent 1cb229799f
commit b2c5d1ab38
2 changed files with 398 additions and 0 deletions

View File

@ -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} />

View 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>
);
}