mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:31:13 -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 { Ride } from '@/types/database';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||||
|
import { CompanyPreviewCard } from '@/components/preview/CompanyPreviewCard';
|
||||||
|
|
||||||
interface RideListViewProps {
|
interface RideListViewProps {
|
||||||
rides: Ride[];
|
rides: Ride[];
|
||||||
@@ -116,12 +118,19 @@ export function RideListView({ rides, onRideClick }: RideListViewProps) {
|
|||||||
{formatCategory(ride.category)}
|
{formatCategory(ride.category)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{ride.manufacturer && (
|
{ride.manufacturer && (
|
||||||
<Link to={`/manufacturers/${ride.manufacturer.slug}`}>
|
<HoverCard openDelay={300}>
|
||||||
<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">
|
<HoverCardTrigger asChild>
|
||||||
<Factory className="w-3 h-3 mr-1" />
|
<Link to={`/manufacturers/${ride.manufacturer.slug}`}>
|
||||||
{ride.manufacturer.name}
|
<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">
|
||||||
</Badge>
|
<Factory className="w-3 h-3 mr-1" />
|
||||||
</Link>
|
{ride.manufacturer.name}
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent side="top" className="w-auto">
|
||||||
|
<CompanyPreviewCard slug={ride.manufacturer.slug} />
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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,
|
maintenanceTables: () => ['admin', 'maintenance-tables'] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Companies queries
|
||||||
|
companies: {
|
||||||
|
detail: (slug: string) => ['companies', 'detail', slug] as const,
|
||||||
|
},
|
||||||
|
|
||||||
// Analytics queries
|
// Analytics queries
|
||||||
analytics: {
|
analytics: {
|
||||||
all: ['analytics'] as const,
|
all: ['analytics'] as const,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, lazy, Suspense, useEffect } from 'react';
|
import { useState, lazy, Suspense, useEffect } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
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 { Header } from '@/components/layout/Header';
|
||||||
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
||||||
import { trackPageView } from '@/lib/viewTracking';
|
import { trackPageView } from '@/lib/viewTracking';
|
||||||
@@ -435,12 +437,19 @@ export default function ParkDetail() {
|
|||||||
<Users className="w-4 h-4 text-muted-foreground" />
|
<Users className="w-4 h-4 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">Operator</div>
|
<div className="font-medium">Operator</div>
|
||||||
<Link
|
<HoverCard openDelay={300}>
|
||||||
to={`/operators/${park.operator.slug}`}
|
<HoverCardTrigger asChild>
|
||||||
className="text-sm text-primary hover:underline"
|
<Link
|
||||||
>
|
to={`/operators/${park.operator.slug}`}
|
||||||
{park.operator.name}
|
className="text-sm text-primary hover:underline"
|
||||||
</Link>
|
>
|
||||||
|
{park.operator.name}
|
||||||
|
</Link>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent side="right" className="w-auto">
|
||||||
|
<CompanyPreviewCard slug={park.operator.slug} />
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useState, lazy, Suspense, useEffect } from 'react';
|
import { useState, lazy, Suspense, useEffect } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
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 { Header } from '@/components/layout/Header';
|
||||||
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
||||||
import { trackPageView } from '@/lib/viewTracking';
|
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">
|
<h1 className="text-4xl md:text-6xl font-bold text-white mb-2">
|
||||||
{ride.name}
|
{ride.name}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center text-white/90 text-lg">
|
<HoverCard openDelay={300}>
|
||||||
<MapPin className="w-5 h-5 mr-2" />
|
<HoverCardTrigger asChild>
|
||||||
{ride.park.name}
|
<Link
|
||||||
</div>
|
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">
|
<div className="mt-3">
|
||||||
<VersionIndicator
|
<VersionIndicator
|
||||||
entityType="ride"
|
entityType="ride"
|
||||||
@@ -471,12 +484,19 @@ export default function RideDetail() {
|
|||||||
<Users className="w-4 h-4 text-muted-foreground" />
|
<Users className="w-4 h-4 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">Manufacturer</div>
|
<div className="font-medium">Manufacturer</div>
|
||||||
<Link
|
<HoverCard openDelay={300}>
|
||||||
to={`/manufacturers/${ride.manufacturer.slug}`}
|
<HoverCardTrigger asChild>
|
||||||
className="text-sm text-primary hover:underline"
|
<Link
|
||||||
>
|
to={`/manufacturers/${ride.manufacturer.slug}`}
|
||||||
{ride.manufacturer.name}
|
className="text-sm text-primary hover:underline"
|
||||||
</Link>
|
>
|
||||||
|
{ride.manufacturer.name}
|
||||||
|
</Link>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent side="right" className="w-auto">
|
||||||
|
<CompanyPreviewCard slug={ride.manufacturer.slug} />
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -486,12 +506,19 @@ export default function RideDetail() {
|
|||||||
<Users className="w-4 h-4 text-muted-foreground" />
|
<Users className="w-4 h-4 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">Designer</div>
|
<div className="font-medium">Designer</div>
|
||||||
<Link
|
<HoverCard openDelay={300}>
|
||||||
to={`/designers/${ride.designer.slug}`}
|
<HoverCardTrigger asChild>
|
||||||
className="text-sm text-primary hover:underline"
|
<Link
|
||||||
>
|
to={`/designers/${ride.designer.slug}`}
|
||||||
{ride.designer.name}
|
className="text-sm text-primary hover:underline"
|
||||||
</Link>
|
>
|
||||||
|
{ride.designer.name}
|
||||||
|
</Link>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent side="right" className="w-auto">
|
||||||
|
<CompanyPreviewCard slug={ride.designer.slug} />
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user