mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:11:11 -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>
|
||||
|
||||
36
src/hooks/preview/useCompanyPreview.ts
Normal file
36
src/hooks/preview/useCompanyPreview.ts
Normal 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
|
||||
});
|
||||
}
|
||||
39
src/hooks/preview/useParkPreview.ts
Normal file
39
src/hooks/preview/useParkPreview.ts
Normal 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
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
<Link
|
||||
to={`/operators/${park.operator.slug}`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{park.operator.name}
|
||||
</Link>
|
||||
<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>}
|
||||
|
||||
|
||||
@@ -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">
|
||||
<MapPin className="w-5 h-5 mr-2" />
|
||||
{ride.park.name}
|
||||
</div>
|
||||
<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" />
|
||||
<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>
|
||||
<Link
|
||||
to={`/manufacturers/${ride.manufacturer.slug}`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{ride.manufacturer.name}
|
||||
</Link>
|
||||
<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>
|
||||
<Link
|
||||
to={`/designers/${ride.designer.slug}`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{ride.designer.name}
|
||||
</Link>
|
||||
<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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user