mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-26 08:51:09 -05:00
Add hover preview cards
Adds hover-preview UX by introducing preview cards for entities and wiring hoverable links: - Implements CompanyPreviewCard and ParkPreviewCard components plus hooks to fetch preview data - Adds HoverCard usage to ParkDetail and RideDetail for operator, manufacturer, and designer links - Creates preview wrappers for manufacturer/designer/operator links and updates related pages to use hover previews - Includes supporting updates to query keys and preview hooks to fetch minimal data for previews
This commit is contained in:
80
src/components/preview/CompanyPreviewCard.tsx
Normal file
80
src/components/preview/CompanyPreviewCard.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Building2, MapPin, Calendar } from 'lucide-react';
|
||||
import { useCompanyPreview } from '@/hooks/preview/useCompanyPreview';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface CompanyPreviewCardProps {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export function CompanyPreviewCard({ slug }: CompanyPreviewCardProps) {
|
||||
const { data: company, isLoading } = useCompanyPreview(slug);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-80">
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-16 bg-muted rounded" />
|
||||
<div className="h-4 bg-muted rounded w-3/4" />
|
||||
<div className="h-4 bg-muted rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!company) {
|
||||
return (
|
||||
<div className="w-80 p-4 text-center text-muted-foreground">
|
||||
Company not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatCompanyType = (type: string) => {
|
||||
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-80 space-y-3">
|
||||
{/* Header with logo */}
|
||||
<div className="flex items-start gap-3">
|
||||
{company.logo_url ? (
|
||||
<img
|
||||
src={company.logo_url}
|
||||
alt={company.name}
|
||||
className="w-12 h-12 object-contain rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 bg-muted rounded flex items-center justify-center">
|
||||
<Building2 className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-base line-clamp-1">{company.name}</h3>
|
||||
<Badge variant="secondary" className="mt-1">
|
||||
{formatCompanyType(company.company_type)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location and Founded */}
|
||||
<div className="space-y-2 text-sm">
|
||||
{company.headquarters_location && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<MapPin className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="line-clamp-1">{company.headquarters_location}</span>
|
||||
</div>
|
||||
)}
|
||||
{company.founded_year && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Founded {company.founded_year}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click to view full details
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/components/preview/ParkPreviewCard.tsx
Normal file
112
src/components/preview/ParkPreviewCard.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { MapPin, Star, FerrisWheel, Zap } from 'lucide-react';
|
||||
import { useParkPreview } from '@/hooks/preview/useParkPreview';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
interface ParkPreviewCardProps {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export function ParkPreviewCard({ slug }: ParkPreviewCardProps) {
|
||||
const { data: park, isLoading } = useParkPreview(slug);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-80">
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-32 bg-muted rounded" />
|
||||
<div className="h-4 bg-muted rounded w-3/4" />
|
||||
<div className="h-4 bg-muted rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!park) {
|
||||
return (
|
||||
<div className="w-80 p-4 text-center text-muted-foreground">
|
||||
Park not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'operating':
|
||||
return 'bg-green-500/20 text-green-400 border-green-500/30';
|
||||
case 'seasonal':
|
||||
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30';
|
||||
case 'under_construction':
|
||||
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||
default:
|
||||
return 'bg-red-500/20 text-red-400 border-red-500/30';
|
||||
}
|
||||
};
|
||||
|
||||
const formatParkType = (type: string) => {
|
||||
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-80 space-y-3">
|
||||
{/* Image */}
|
||||
{park.card_image_url && (
|
||||
<div className="aspect-video rounded-lg overflow-hidden bg-muted">
|
||||
<img
|
||||
src={park.card_image_url}
|
||||
alt={park.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-base line-clamp-1 mb-2">{park.name}</h3>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge className={`${getStatusColor(park.status)} border text-xs`}>
|
||||
{park.status.replace('_', ' ').toUpperCase()}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{formatParkType(park.park_type)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
{park.location && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<MapPin className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="line-clamp-1">
|
||||
{[park.location.city, park.location.state_province, park.location.country]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<FerrisWheel className="w-4 h-4 text-primary" />
|
||||
<span className="font-medium">{park.ride_count || 0}</span>
|
||||
<span className="text-muted-foreground">rides</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-accent" />
|
||||
<span className="font-medium">{park.coaster_count || 0}</span>
|
||||
<span className="text-muted-foreground">coasters</span>
|
||||
</div>
|
||||
{park.average_rating && park.average_rating > 0 && (
|
||||
<div className="flex items-center gap-2 col-span-2">
|
||||
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
|
||||
<span className="font-medium">{park.average_rating.toFixed(1)}</span>
|
||||
<span className="text-muted-foreground">({park.review_count} reviews)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { Ride } from '@/types/database';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||
import { CompanyPreviewCard } from '@/components/preview/CompanyPreviewCard';
|
||||
|
||||
interface RideListViewProps {
|
||||
rides: Ride[];
|
||||
@@ -116,12 +118,19 @@ export function RideListView({ rides, onRideClick }: RideListViewProps) {
|
||||
{formatCategory(ride.category)}
|
||||
</Badge>
|
||||
{ride.manufacturer && (
|
||||
<Link to={`/manufacturers/${ride.manufacturer.slug}`}>
|
||||
<Badge variant="outline" className="text-xs backdrop-blur-sm border-accent/20 group-hover:border-accent/40 transition-colors duration-300 hover:bg-accent/10">
|
||||
<Factory className="w-3 h-3 mr-1" />
|
||||
{ride.manufacturer.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Link to={`/manufacturers/${ride.manufacturer.slug}`}>
|
||||
<Badge variant="outline" className="text-xs backdrop-blur-sm border-accent/20 group-hover:border-accent/40 transition-colors duration-300 hover:bg-accent/10 cursor-pointer">
|
||||
<Factory className="w-3 h-3 mr-1" />
|
||||
{ride.manufacturer.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="top" className="w-auto">
|
||||
<CompanyPreviewCard slug={ride.manufacturer.slug} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user