Refactors the UI and CSS of the auction section and article view toggles to align with the site's existing visual theme, changing accent colors from orange to gray and updating button styles. 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/UXwUwik
590 lines
22 KiB
TypeScript
590 lines
22 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 { Input } from "@/components/ui/input";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
|
import { Gavel, Clock, TrendingUp, Search, Settings, User, LogOut, Grid, List, Edit } from "lucide-react";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { queryClient, apiRequest } from "@/lib/queryClient";
|
|
import ArticleCard from "@/components/ArticleCard";
|
|
import Footer from "@/components/Footer";
|
|
import LoginModal from "@/components/LoginModal";
|
|
import SearchModal from "@/components/SearchModal";
|
|
import type { MediaOutlet, Article, Auction } from "@shared/schema";
|
|
|
|
export default function MediaOutlet() {
|
|
const [, params] = useRoute("/media/:slug");
|
|
const [, setLocation] = useLocation();
|
|
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
|
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
|
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
|
const [isEditDescModalOpen, setIsEditDescModalOpen] = useState(false);
|
|
const [newDescription, setNewDescription] = useState("");
|
|
const [alternativeDescription, setAlternativeDescription] = useState("Partner at Type3 Capital and Non-Executive Director at TrueFi DAO with a strong background in fund management, venture capital, and digital assets");
|
|
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: articles = [], isLoading: articlesLoading } = useQuery<Article[]>({
|
|
queryKey: ["/api/media-outlets", params?.slug, "articles"],
|
|
enabled: !!params?.slug
|
|
});
|
|
|
|
const { data: auction, isLoading: auctionLoading } = useQuery<Auction>({
|
|
queryKey: ["/api/media-outlets", params?.slug, "auction"],
|
|
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 updateDescriptionMutation = useMutation({
|
|
mutationFn: async (description: string) => {
|
|
await apiRequest("PATCH", `/api/media-outlets/${params?.slug}`, { description });
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/media-outlets", params?.slug] });
|
|
toast({
|
|
title: "Success",
|
|
description: "Description updated successfully",
|
|
});
|
|
setIsEditDescModalOpen(false);
|
|
},
|
|
onError: () => {
|
|
toast({
|
|
title: "Error",
|
|
description: "Failed to update description",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const handleEditDescription = () => {
|
|
setNewDescription(outlet?.description || "");
|
|
setIsEditDescModalOpen(true);
|
|
};
|
|
|
|
const handleSaveDescription = (useAlternative: boolean = false) => {
|
|
const descToSave = useAlternative ? alternativeDescription : newDescription;
|
|
updateDescriptionMutation.mutate(descToSave);
|
|
};
|
|
|
|
const formatCurrency = (amount: string | null) => {
|
|
if (!amount) return "$0";
|
|
return new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: 'USD'
|
|
}).format(parseFloat(amount));
|
|
};
|
|
|
|
const formatTimeRemaining = (endDate: Date | string) => {
|
|
const end = new Date(endDate);
|
|
const now = new Date();
|
|
const diff = end.getTime() - now.getTime();
|
|
|
|
if (diff <= 0) return "Auction Ended";
|
|
|
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
|
|
|
if (days > 0) return `${days}d ${hours}h remaining`;
|
|
if (hours > 0) return `${hours}h remaining`;
|
|
return "Less than 1h remaining";
|
|
};
|
|
|
|
if (outletLoading) {
|
|
return (
|
|
<div className="flex flex-col min-h-screen bg-gray-50">
|
|
{/* Header - Same as Home */}
|
|
<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">
|
|
<img
|
|
src="/attached_assets/logo_black_1759162717640.png"
|
|
alt="SAPIENS"
|
|
className="h-6 w-auto"
|
|
data-testid="logo-sapiens"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-4">
|
|
<div
|
|
className="relative cursor-pointer"
|
|
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" />
|
|
<Input
|
|
type="text"
|
|
placeholder="Search the website"
|
|
className="w-80 pl-10 bg-gray-50 border-gray-200 cursor-pointer"
|
|
data-testid="input-search"
|
|
readOnly
|
|
/>
|
|
</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={() => setLocation("/auctions")}
|
|
data-testid="button-auctions"
|
|
>
|
|
Auctions
|
|
</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>
|
|
|
|
<div className="flex-1 max-w-7xl mx-auto px-6 py-4 w-full">
|
|
<div className="animate-pulse">
|
|
<div className="h-8 bg-gray-200 rounded w-1/3 mb-2"></div>
|
|
<div className="h-4 bg-gray-200 rounded w-2/3 mb-4"></div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="h-64 bg-gray-200 rounded-xl"></div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Footer />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!outlet) {
|
|
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-2">Media Outlet Not Found</h1>
|
|
<p className="text-gray-600 mb-4">The media outlet you're looking for doesn't exist.</p>
|
|
<Button onClick={() => setLocation("/")}>Go to Homepage</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col min-h-screen bg-gray-50">
|
|
{/* Header - Same as Home */}
|
|
<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">
|
|
<img
|
|
src="/attached_assets/logo_black_1759162717640.png"
|
|
alt="SAPIENS"
|
|
className="h-6 w-auto cursor-pointer"
|
|
data-testid="logo-sapiens"
|
|
onClick={() => setLocation("/")}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-4">
|
|
<div
|
|
className="relative cursor-pointer"
|
|
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" />
|
|
<Input
|
|
type="text"
|
|
placeholder="Search the website"
|
|
className="w-80 pl-10 bg-gray-50 border-gray-200 cursor-pointer"
|
|
data-testid="input-search"
|
|
readOnly
|
|
onClick={() => setIsSearchModalOpen(true)}
|
|
/>
|
|
</div>
|
|
|
|
{isAuthenticated && user ? (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setLocation("/auctions")}
|
|
data-testid="button-auctions"
|
|
>
|
|
Auctions
|
|
</Button>
|
|
|
|
<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="flex-1 max-w-7xl mx-auto px-6 py-4 w-full">
|
|
{/* Outlet Header */}
|
|
<div className="mb-4">
|
|
<div className="flex items-start space-x-4 mb-3">
|
|
{outlet.imageUrl ? (
|
|
<img
|
|
src={outlet.imageUrl}
|
|
alt={outlet.name}
|
|
className="w-16 h-16 rounded-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center">
|
|
<span className="text-gray-600 font-bold text-xl">
|
|
{outlet.name.charAt(0)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1">
|
|
<div className="flex items-center space-x-2 mb-2">
|
|
<h1 className="text-2xl font-bold">{outlet.name}</h1>
|
|
<Badge variant="secondary" className="capitalize">
|
|
{outlet.category}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center space-x-2 mb-2">
|
|
<p className="text-gray-600">{outlet.description}</p>
|
|
{user && (user.role === 'admin' || user.role === 'superadmin') && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleEditDescription}
|
|
data-testid="button-edit-description"
|
|
>
|
|
<Edit className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{outlet.tags && outlet.tags.length > 0 && (
|
|
<div className="flex items-center space-x-2 mb-2">
|
|
{outlet.tags.map((tag) => (
|
|
<Badge key={tag} variant="outline">
|
|
{tag}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Auction Section */}
|
|
{!auctionLoading && auction ? (
|
|
<Card className="border-gray-200 bg-gray-50">
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-4">
|
|
<div className="flex items-center space-x-2">
|
|
<Gavel className="h-5 w-5 text-gray-600" />
|
|
<span className="font-semibold text-gray-900">Management Rights Auction</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2 text-sm text-gray-700">
|
|
<TrendingUp className="h-4 w-4" />
|
|
<span>Current Highest Bid: {formatCurrency(auction.currentBid)}</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2 text-sm text-gray-700">
|
|
<Clock className="h-4 w-4" />
|
|
<span>{formatTimeRemaining(auction.endDate)}</span>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
onClick={() => setLocation(`/media/${params?.slug}/auction`)}
|
|
size="sm"
|
|
className="bg-gray-600 hover:bg-gray-700 text-white"
|
|
data-testid="button-auction"
|
|
>
|
|
Place Bid
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : !auctionLoading && !auction ? (
|
|
<Card className="border-gray-200 bg-gray-50">
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-2">
|
|
<Gavel className="h-5 w-5 text-gray-500" />
|
|
<span className="text-gray-700">No active auction currently</span>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled
|
|
data-testid="button-no-auction"
|
|
>
|
|
Auction Pending
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<Card className="border-gray-200 bg-gray-50">
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center space-x-2">
|
|
<div className="animate-pulse flex space-x-4">
|
|
<div className="rounded-full bg-gray-300 h-5 w-5"></div>
|
|
<div className="h-4 bg-gray-300 rounded w-24"></div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Articles Section */}
|
|
<div className="mb-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h2 className="text-xl font-bold">Latest Articles</h2>
|
|
<div className="flex items-center space-x-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setViewMode("grid")}
|
|
className={viewMode === "grid" ? "bg-gray-600 text-white hover:bg-gray-700" : ""}
|
|
data-testid="button-grid-view"
|
|
>
|
|
<Grid className="h-4 w-4 mr-2" />
|
|
Grid
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setViewMode("list")}
|
|
className={viewMode === "list" ? "bg-gray-600 text-white hover:bg-gray-700" : ""}
|
|
data-testid="button-list-view"
|
|
>
|
|
<List className="h-4 w-4 mr-2" />
|
|
List
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{articlesLoading ? (
|
|
<div className={`grid gap-4 ${viewMode === "grid" ? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3" : "grid-cols-1"}`}>
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="bg-white border border-gray-200 rounded-xl p-6 animate-pulse">
|
|
<div className="h-40 bg-gray-200 rounded mb-4"></div>
|
|
<div className="h-6 bg-gray-200 rounded mb-2"></div>
|
|
<div className="h-4 bg-gray-200 rounded w-2/3"></div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : articles.length > 0 ? (
|
|
<div className={`grid gap-4 ${viewMode === "grid" ? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3" : "grid-cols-1"}`}>
|
|
{articles.map((article) => (
|
|
<ArticleCard
|
|
key={article.id}
|
|
article={article}
|
|
outlet={outlet}
|
|
viewMode={viewMode}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="p-12 text-center">
|
|
<div className="text-4xl text-gray-400 mb-4">📰</div>
|
|
<h3 className="text-xl font-semibold mb-2">No Articles Yet</h3>
|
|
<p className="text-gray-600">
|
|
This media outlet doesn't have any published articles yet. Check back later!
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</main>
|
|
|
|
<Footer />
|
|
|
|
{/* Login Modal */}
|
|
<LoginModal
|
|
isOpen={isLoginModalOpen}
|
|
onClose={() => setIsLoginModalOpen(false)}
|
|
/>
|
|
|
|
{/* Search Modal */}
|
|
<SearchModal
|
|
isOpen={isSearchModalOpen}
|
|
onClose={() => setIsSearchModalOpen(false)}
|
|
/>
|
|
|
|
{/* Edit Description Modal */}
|
|
<Dialog open={isEditDescModalOpen} onOpenChange={setIsEditDescModalOpen}>
|
|
<DialogContent data-testid="modal-edit-description">
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Description</DialogTitle>
|
|
<DialogDescription>
|
|
Update the description for {outlet?.name}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="text-sm font-medium">Current Description</label>
|
|
<Textarea
|
|
value={newDescription}
|
|
onChange={(e) => setNewDescription(e.target.value)}
|
|
rows={3}
|
|
className="mt-1"
|
|
data-testid="textarea-description"
|
|
/>
|
|
</div>
|
|
|
|
{outlet?.slug === 'krysh-parker' && (
|
|
<div className="p-3 bg-blue-50 rounded-lg border border-blue-200">
|
|
<p className="text-sm font-medium text-blue-900 mb-2">Alternative Description</p>
|
|
<p className="text-sm text-blue-700 mb-3">{alternativeDescription}</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleSaveDescription(true)}
|
|
disabled={updateDescriptionMutation.isPending}
|
|
data-testid="button-use-alternative"
|
|
>
|
|
Use This Description
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex space-x-2">
|
|
<Button
|
|
onClick={() => handleSaveDescription(false)}
|
|
disabled={updateDescriptionMutation.isPending}
|
|
data-testid="button-save-description"
|
|
>
|
|
{updateDescriptionMutation.isPending ? "Saving..." : "Save Changes"}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsEditDescModalOpen(false)}
|
|
data-testid="button-cancel-edit"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|