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:
gpt-engineer-app[bot]
2025-11-12 03:44:01 +00:00
parent 2ccfe8c48a
commit 361231bfac
8 changed files with 345 additions and 28 deletions

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

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

View File

@@ -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 && (
<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">
<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>

View File

@@ -0,0 +1,36 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/lib/supabaseClient';
import { queryKeys } from '@/lib/queryKeys';
/**
* Hook to fetch company preview data for hover cards
*/
export function useCompanyPreview(slug: string | undefined, enabled = true) {
return useQuery({
queryKey: queryKeys.companies.detail(slug || ''),
queryFn: async () => {
if (!slug) throw new Error('Slug is required');
const { data, error } = await supabase
.from('companies')
.select(`
id,
name,
slug,
company_type,
person_type,
headquarters_location,
founded_year,
logo_url
`)
.eq('slug', slug)
.maybeSingle();
if (error) throw error;
return data;
},
enabled: enabled && !!slug,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 15 * 60 * 1000, // 15 minutes
});
}

View File

@@ -0,0 +1,39 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/lib/supabaseClient';
import { queryKeys } from '@/lib/queryKeys';
/**
* Hook to fetch park preview data for hover cards
*/
export function useParkPreview(slug: string | undefined, enabled = true) {
return useQuery({
queryKey: queryKeys.parks.detail(slug || ''),
queryFn: async () => {
if (!slug) throw new Error('Slug is required');
const { data, error } = await supabase
.from('parks')
.select(`
id,
name,
slug,
park_type,
status,
card_image_url,
ride_count,
coaster_count,
average_rating,
review_count,
location:locations(city, state_province, country)
`)
.eq('slug', slug)
.maybeSingle();
if (error) throw error;
return data;
},
enabled: enabled && !!slug,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 15 * 60 * 1000, // 15 minutes
});
}

View File

@@ -106,6 +106,11 @@ export const queryKeys = {
maintenanceTables: () => ['admin', 'maintenance-tables'] as const,
},
// Companies queries
companies: {
detail: (slug: string) => ['companies', 'detail', slug] as const,
},
// Analytics queries
analytics: {
all: ['analytics'] as const,

View File

@@ -1,5 +1,7 @@
import { useState, lazy, Suspense, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import { CompanyPreviewCard } from '@/components/preview/CompanyPreviewCard';
import { Header } from '@/components/layout/Header';
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
import { trackPageView } from '@/lib/viewTracking';
@@ -435,12 +437,19 @@ export default function ParkDetail() {
<Users className="w-4 h-4 text-muted-foreground" />
<div>
<div className="font-medium">Operator</div>
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<Link
to={`/operators/${park.operator.slug}`}
className="text-sm text-primary hover:underline"
>
{park.operator.name}
</Link>
</HoverCardTrigger>
<HoverCardContent side="right" className="w-auto">
<CompanyPreviewCard slug={park.operator.slug} />
</HoverCardContent>
</HoverCard>
</div>
</div>}

View File

@@ -1,5 +1,8 @@
import { useState, lazy, Suspense, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import { CompanyPreviewCard } from '@/components/preview/CompanyPreviewCard';
import { ParkPreviewCard } from '@/components/preview/ParkPreviewCard';
import { Header } from '@/components/layout/Header';
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
import { trackPageView } from '@/lib/viewTracking';
@@ -255,10 +258,20 @@ export default function RideDetail() {
<h1 className="text-4xl md:text-6xl font-bold text-white mb-2">
{ride.name}
</h1>
<div className="flex items-center text-white/90 text-lg">
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<Link
to={`/parks/${ride.park.slug}`}
className="flex items-center text-white/90 text-lg hover:text-white transition-colors"
>
<MapPin className="w-5 h-5 mr-2" />
{ride.park.name}
</div>
<span className="hover:underline">{ride.park.name}</span>
</Link>
</HoverCardTrigger>
<HoverCardContent side="bottom" align="start" className="w-auto">
<ParkPreviewCard slug={ride.park.slug} />
</HoverCardContent>
</HoverCard>
<div className="mt-3">
<VersionIndicator
entityType="ride"
@@ -471,12 +484,19 @@ export default function RideDetail() {
<Users className="w-4 h-4 text-muted-foreground" />
<div>
<div className="font-medium">Manufacturer</div>
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<Link
to={`/manufacturers/${ride.manufacturer.slug}`}
className="text-sm text-primary hover:underline"
>
{ride.manufacturer.name}
</Link>
</HoverCardTrigger>
<HoverCardContent side="right" className="w-auto">
<CompanyPreviewCard slug={ride.manufacturer.slug} />
</HoverCardContent>
</HoverCard>
</div>
</div>
)}
@@ -486,12 +506,19 @@ export default function RideDetail() {
<Users className="w-4 h-4 text-muted-foreground" />
<div>
<div className="font-medium">Designer</div>
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<Link
to={`/designers/${ride.designer.slug}`}
className="text-sm text-primary hover:underline"
>
{ride.designer.name}
</Link>
</HoverCardTrigger>
<HoverCardContent side="right" className="w-auto">
<CompanyPreviewCard slug={ride.designer.slug} />
</HoverCardContent>
</HoverCard>
</div>
</div>
)}