Files
sapiens-web3/client/src/pages/Auctions.tsx
kimjaehyeon0101 fa4fc1892e Add a divider between the logo and page title on auction pages
Adds a vertical divider element (`div` with `h-8 w-px bg-gray-300`) in `client/src/pages/Auctions.tsx` to visually separate the Sapiens logo from the page title elements.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 069d4324-6c40-4355-955e-c714a50de1ea
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3df548ff-50ae-432f-9be4-25d34eccc983/069d4324-6c40-4355-955e-c714a50de1ea/yHCvWBg
2025-09-30 00:36:23 +00:00

608 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient } from "@/lib/queryClient";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useAuth } from "@/hooks/useAuth";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { isUnauthorizedError } from "@/lib/authUtils";
import AuctionCard from "@/components/AuctionCard";
import type { Auction, MediaOutlet } from "@shared/schema";
import { BookOpen, Settings, User, LogOut, Sun, Moon, Monitor, Globe } from "lucide-react";
import { Link } from "wouter";
import Footer from "@/components/Footer";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import LoginModal from "@/components/LoginModal";
export default function Auctions() {
const { user, isAuthenticated } = useAuth();
const { toast } = useToast();
const [showBidModal, setShowBidModal] = useState(false);
const [selectedAuction, setSelectedAuction] = useState<Auction | null>(null);
const [bidForm, setBidForm] = useState({
amount: "",
qualityScore: ""
});
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'light');
const [language, setLanguage] = useState(() => localStorage.getItem('language') || 'en');
useEffect(() => {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
localStorage.setItem('theme', theme);
}, [theme]);
useEffect(() => {
localStorage.setItem('language', language);
}, [language]);
const handleThemeChange = (newTheme: string) => {
setTheme(newTheme);
};
const handleLanguageChange = (newLanguage: string) => {
setLanguage(newLanguage);
};
const languages = [
{ code: 'en', name: 'English' },
{ code: 'fr', name: 'Français' },
{ code: 'de', name: 'Deutsch' },
{ code: 'it', name: 'Italiano' },
{ code: 'hi', name: 'हिन्दी' },
{ code: 'ar', name: 'العربية' },
{ code: 'ja', name: '日本語' },
{ code: 'ko', name: '한국어' },
{ code: 'zh-TW', name: '繁體中文' },
{ code: 'zh-CN', name: '简体中文' },
];
const handleLogout = async () => {
try {
const response = await fetch("/api/logout", {
method: "POST",
credentials: "include",
});
if (response.ok) {
toast({
title: "Logged Out",
description: "You have been successfully logged out.",
});
queryClient.invalidateQueries({ queryKey: ["/api/auth/user"] });
}
} catch (error) {
toast({
title: "Logout Error",
description: "An error occurred while logging out.",
variant: "destructive",
});
}
};
const { data: auctions = [], isLoading: auctionsLoading } = useQuery<Auction[]>({
queryKey: ["/api/auctions"],
});
const { data: mediaOutlets = [] } = useQuery<MediaOutlet[]>({
queryKey: ["/api/media-outlets"],
});
const bidMutation = useMutation({
mutationFn: async (data: { auctionId: string; amount: string; qualityScore: number }) => {
await apiRequest("POST", `/api/auctions/${data.auctionId}/bid`, {
amount: data.amount,
qualityScore: data.qualityScore
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/auctions"] });
toast({
title: "Success",
description: "Your bid has been placed successfully.",
});
setShowBidModal(false);
setBidForm({ amount: "", qualityScore: "" });
},
onError: (error: Error) => {
if (isUnauthorizedError(error)) {
toast({
title: "Unauthorized",
description: "You are logged out. Logging in again...",
variant: "destructive",
});
setTimeout(() => {
window.location.href = "/api/login";
}, 500);
return;
}
toast({
title: "Error",
description: "Failed to place bid. Please try again.",
variant: "destructive"
});
}
});
const handleBidClick = (auction: Auction) => {
if (!isAuthenticated) {
toast({
title: "Login Required",
description: "Please log in to place a bid.",
variant: "destructive"
});
setIsLoginModalOpen(true);
return;
}
setSelectedAuction(auction);
setShowBidModal(true);
};
const handleBidSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedAuction) return;
const amount = parseFloat(bidForm.amount);
const qualityScore = parseInt(bidForm.qualityScore);
const currentBid = parseFloat(selectedAuction.currentBid || "0");
if (amount <= currentBid) {
toast({
title: "Invalid Bid",
description: `Bid must be higher than current bid of $${currentBid}`,
variant: "destructive"
});
return;
}
if (qualityScore < 1 || qualityScore > 100) {
toast({
title: "Invalid Quality Score",
description: "Quality score must be between 1 and 100",
variant: "destructive"
});
return;
}
bidMutation.mutate({
auctionId: selectedAuction.id,
amount: bidForm.amount,
qualityScore
});
};
const getMediaOutletName = (mediaOutletId: string) => {
const outlet = mediaOutlets.find(o => o.id === mediaOutletId);
return outlet?.name || "Unknown Outlet";
};
const formatPrice = (price: string) => {
const num = parseFloat(price);
return `$${num.toLocaleString()}`;
};
const getTimeRemaining = (endDate: string | Date) => {
const end = new Date(endDate);
const now = new Date();
const diff = end.getTime() - now.getTime();
if (diff <= 0) return "Ended";
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}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
const getStatusBadge = (auction: Auction) => {
const timeRemaining = getTimeRemaining(auction.endDate);
if (timeRemaining === "Ended") return { text: "Ended", variant: "secondary" as const };
const end = new Date(auction.endDate);
const now = new Date();
const hoursLeft = (end.getTime() - now.getTime()) / (1000 * 60 * 60);
if (hoursLeft <= 3) return { text: "Ending Soon", variant: "destructive" as const };
return { text: "Active", variant: "default" as const };
};
return (
<div className="flex flex-col 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-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/">
<img
src="/attached_assets/logo_black_1759181850935.png"
alt="SAPIENS"
className="h-5 w-auto cursor-pointer"
data-testid="logo-sapiens"
/>
</Link>
<div className="h-8 w-px bg-gray-300"></div>
<div>
<h1 className="text-base font-bold">Media Outlet Auctions</h1>
<p className="text-xs text-gray-600">Bid for exclusive editorial rights and content management privileges</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Link href="/auction-guide">
<Button variant="ghost" size="sm" data-testid="button-auction-guide">
<BookOpen className="h-4 w-4" />
</Button>
</Link>
{isAuthenticated && user ? (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
data-testid="button-settings"
>
<Settings className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Theme</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleThemeChange('light')}
className="cursor-pointer"
data-testid="theme-light"
>
<Sun className="h-4 w-4 mr-2" />
Light
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleThemeChange('dark')}
className="cursor-pointer"
data-testid="theme-dark"
>
<Moon className="h-4 w-4 mr-2" />
Dark
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleThemeChange('system')}
className="cursor-pointer"
data-testid="theme-system"
>
<Monitor className="h-4 w-4 mr-2" />
System
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Language</DropdownMenuLabel>
{languages.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => handleLanguageChange(lang.code)}
className="cursor-pointer"
data-testid={`language-${lang.code}`}
>
<Globe className="h-4 w-4 mr-2" />
{lang.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex items-center space-x-2 px-3 py-1 bg-gray-100 rounded-md cursor-pointer hover:bg-gray-200 transition-colors" data-testid="user-profile-chip">
<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>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleLogout}
className="cursor-pointer text-red-600"
data-testid="button-logout"
>
<LogOut className="h-4 w-4 mr-2" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
data-testid="button-settings"
>
<Settings className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Theme</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleThemeChange('light')}
className="cursor-pointer"
data-testid="theme-light"
>
<Sun className="h-4 w-4 mr-2" />
Light
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleThemeChange('dark')}
className="cursor-pointer"
data-testid="theme-dark"
>
<Moon className="h-4 w-4 mr-2" />
Dark
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleThemeChange('system')}
className="cursor-pointer"
data-testid="theme-system"
>
<Monitor className="h-4 w-4 mr-2" />
System
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Language</DropdownMenuLabel>
{languages.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => handleLanguageChange(lang.code)}
className="cursor-pointer"
data-testid={`language-${lang.code}`}
>
<Globe className="h-4 w-4 mr-2" />
{lang.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="sm"
onClick={() => setIsLoginModalOpen(true)}
data-testid="button-login"
>
<User className="h-4 w-4" />
</Button>
</>
)}
</div>
</div>
</div>
</header>
<main className="flex-1 max-w-7xl mx-auto px-4 py-3 w-full">
{/* Auction Explanation */}
<div className="mb-4">
<Card>
<CardContent className="p-3">
<h3 className="font-semibold mb-2 text-sm">How Auctions Work</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-xs">
<div>
<h4 className="font-medium mb-1">Bid Amount</h4>
<p className="text-gray-600">Your maximum willingness to pay for editorial control</p>
</div>
<div>
<h4 className="font-medium mb-1">Quality Score</h4>
<p className="text-gray-600">Platform assessment of your content quality and engagement</p>
</div>
<div>
<h4 className="font-medium mb-1">Final Ranking</h4>
<p className="text-gray-600">Combination of bid amount and quality score</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Auctions Grid */}
{auctionsLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-6 animate-pulse">
<div className="h-20 bg-muted rounded mb-4"></div>
<div className="space-y-3">
<div className="h-4 bg-muted rounded"></div>
<div className="h-4 bg-muted rounded"></div>
<div className="h-4 bg-muted rounded"></div>
</div>
<div className="h-10 bg-muted rounded mt-4"></div>
</CardContent>
</Card>
))}
</div>
) : auctions.length === 0 ? (
<Card>
<CardContent className="p-12 text-center">
<i className="fas fa-gavel text-4xl text-muted-foreground mb-4"></i>
<h3 className="text-xl font-semibold mb-2">No Active Auctions</h3>
<p className="text-muted-foreground">
There are currently no active media outlet auctions. Check back later for new opportunities!
</p>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{auctions.map((auction) => {
const status = getStatusBadge(auction);
return (
<Card key={auction.id} data-testid={`card-auction-${auction.id}`}>
<CardContent className="p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="font-semibold text-lg line-clamp-1">{getMediaOutletName(auction.mediaOutletId)}</h3>
</div>
<Badge variant={status.variant}>{status.text}</Badge>
</div>
<div className="space-y-3 mb-4">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Current Bid:</span>
<span className="font-semibold">{formatPrice(auction.currentBid || "0")}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Quality Score:</span>
<span className="font-semibold text-gray-900">{auction.qualityScore || 0}/100</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Duration:</span>
<span className="font-semibold">{auction.duration || 30} days</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Time Remaining:</span>
<span className={`font-semibold ${status.variant === 'destructive' ? 'text-destructive' : ''}`}>
{getTimeRemaining(auction.endDate)}
</span>
</div>
</div>
<Button
className="w-full bg-gray-600 hover:bg-gray-700 text-white"
onClick={() => handleBidClick(auction)}
disabled={getTimeRemaining(auction.endDate) === "Ended"}
data-testid={`button-bid-${auction.id}`}
>
{getTimeRemaining(auction.endDate) === "Ended" ? "Auction Ended" : "Place Bid"}
</Button>
</CardContent>
</Card>
);
})}
</div>
)}
</main>
{/* Bid Modal */}
<Dialog open={showBidModal} onOpenChange={setShowBidModal}>
<DialogContent data-testid="modal-bid">
<DialogHeader>
<DialogTitle>Place Bid</DialogTitle>
</DialogHeader>
{selectedAuction && (
<div className="space-y-4">
<div className="bg-muted rounded-lg p-4">
<h4 className="font-semibold mb-2">{selectedAuction.title}</h4>
<p className="text-sm text-muted-foreground mb-3">{selectedAuction.description}</p>
<div className="flex justify-between text-sm">
<span>Current Bid:</span>
<span className="font-semibold">{formatPrice(selectedAuction.currentBid || "0")}</span>
</div>
</div>
<form onSubmit={handleBidSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Bid Amount ($)</label>
<Input
type="number"
step="0.01"
min={parseFloat(selectedAuction.currentBid || "0") + 1}
value={bidForm.amount}
onChange={(e) => setBidForm(prev => ({ ...prev, amount: e.target.value }))}
onInvalid={(e) => (e.target as HTMLInputElement).setCustomValidity('Please enter a bid amount')}
onInput={(e) => (e.target as HTMLInputElement).setCustomValidity('')}
placeholder={`Minimum: $${(parseFloat(selectedAuction.currentBid || "0") + 1).toFixed(2)}`}
required
data-testid="input-bid-amount"
/>
<p className="text-xs text-muted-foreground mt-1">
Must be higher than current bid
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">Quality Score (1-100)</label>
<Input
type="number"
min="1"
max="100"
value={bidForm.qualityScore}
onChange={(e) => setBidForm(prev => ({ ...prev, qualityScore: e.target.value }))}
onInvalid={(e) => (e.target as HTMLInputElement).setCustomValidity('Please enter a quality score (1-100)')}
onInput={(e) => (e.target as HTMLInputElement).setCustomValidity('')}
placeholder="Your self-assessed quality score"
required
data-testid="input-quality-score"
/>
<p className="text-xs text-muted-foreground mt-1">
Based on your content quality, engagement, and editorial standards
</p>
</div>
<div className="bg-accent/50 rounded-lg p-3 text-sm">
<h5 className="font-medium mb-1">Bidding Formula</h5>
<p className="text-muted-foreground">
Your ranking = (Bid Amount × Quality Score) / 100. Higher scores win at lower costs.
</p>
</div>
<div className="flex space-x-2">
<Button
type="submit"
disabled={bidMutation.isPending}
className="bg-gray-600 hover:bg-gray-700 text-white"
data-testid="button-submit-bid"
>
{bidMutation.isPending ? "Placing Bid..." : "Place Bid"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => setShowBidModal(false)}
data-testid="button-cancel-bid"
>
Cancel
</Button>
</div>
</form>
</div>
)}
</DialogContent>
</Dialog>
{/* Login Modal */}
<LoginModal
isOpen={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)}
/>
<Footer />
</div>
);
}