mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 11:51:14 -05:00
feat: Enhance ride detail page with new sections
This commit is contained in:
63
src/components/rides/FormerNames.tsx
Normal file
63
src/components/rides/FormerNames.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { History } from 'lucide-react';
|
||||
|
||||
interface FormerName {
|
||||
name: string;
|
||||
from_year?: number;
|
||||
to_year?: number;
|
||||
}
|
||||
|
||||
interface FormerNamesProps {
|
||||
formerNames: FormerName[];
|
||||
currentName: string;
|
||||
}
|
||||
|
||||
export function FormerNames({ formerNames, currentName }: FormerNamesProps) {
|
||||
if (!formerNames || formerNames.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="w-5 h-5" />
|
||||
Ride History
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-2 h-2 mt-2 rounded-full bg-primary" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{currentName}</div>
|
||||
<div className="text-sm text-muted-foreground">Current name</div>
|
||||
</div>
|
||||
<Badge variant="default">Current</Badge>
|
||||
</div>
|
||||
|
||||
{formerNames.map((former, index) => (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-2 h-2 mt-2 rounded-full bg-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{former.name}</div>
|
||||
{(former.from_year || former.to_year) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{former.from_year && former.to_year
|
||||
? `${former.from_year} - ${former.to_year}`
|
||||
: former.from_year
|
||||
? `Since ${former.from_year}`
|
||||
: `Until ${former.to_year}`
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline">Former</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
83
src/components/rides/RatingDistribution.tsx
Normal file
83
src/components/rides/RatingDistribution.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
interface RatingDistributionProps {
|
||||
rideId: string;
|
||||
totalReviews: number;
|
||||
averageRating: number;
|
||||
}
|
||||
|
||||
interface RatingCount {
|
||||
rating: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function RatingDistribution({ rideId, totalReviews, averageRating }: RatingDistributionProps) {
|
||||
const [distribution, setDistribution] = useState<RatingCount[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDistribution() {
|
||||
const { data, error } = await supabase
|
||||
.from('reviews')
|
||||
.select('rating')
|
||||
.eq('ride_id', rideId)
|
||||
.eq('moderation_status', 'approved');
|
||||
|
||||
if (!error && data) {
|
||||
const counts = [5, 4, 3, 2, 1].map(rating => ({
|
||||
rating,
|
||||
count: data.filter(r => r.rating === rating).length
|
||||
}));
|
||||
setDistribution(counts);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
fetchDistribution();
|
||||
}, [rideId]);
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rating Breakdown</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-center pb-4 border-b border-border">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Star className="w-8 h-8 fill-yellow-400 text-yellow-400" />
|
||||
<span className="text-4xl font-bold">{averageRating.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Based on {totalReviews} {totalReviews === 1 ? 'review' : 'reviews'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{distribution.map(({ rating, count }) => {
|
||||
const percentage = totalReviews > 0 ? (count / totalReviews) * 100 : 0;
|
||||
return (
|
||||
<div key={rating} className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1 w-16">
|
||||
<span className="text-sm font-medium">{rating}</span>
|
||||
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
|
||||
</div>
|
||||
<Progress value={percentage} className="flex-1 h-2" />
|
||||
<span className="text-sm text-muted-foreground w-12 text-right">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
92
src/components/rides/RecentPhotosPreview.tsx
Normal file
92
src/components/rides/RecentPhotosPreview.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Camera } from 'lucide-react';
|
||||
|
||||
interface RecentPhotosPreviewProps {
|
||||
rideId: string;
|
||||
onViewAll: () => void;
|
||||
}
|
||||
|
||||
interface Photo {
|
||||
id: string;
|
||||
image_url: string;
|
||||
caption: string | null;
|
||||
}
|
||||
|
||||
export function RecentPhotosPreview({ rideId, onViewAll }: RecentPhotosPreviewProps) {
|
||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchPhotos() {
|
||||
const { data, error } = await supabase
|
||||
.from('reviews')
|
||||
.select('photos')
|
||||
.eq('ride_id', rideId)
|
||||
.eq('moderation_status', 'approved')
|
||||
.not('photos', 'is', null)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (!error && data) {
|
||||
const allPhotos: Photo[] = [];
|
||||
data.forEach((review: any) => {
|
||||
if (review.photos && Array.isArray(review.photos)) {
|
||||
review.photos.forEach((photo: any) => {
|
||||
if (allPhotos.length < 4) {
|
||||
allPhotos.push({
|
||||
id: photo.id || Math.random().toString(),
|
||||
image_url: photo.image_url || photo.url,
|
||||
caption: photo.caption || null
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
setPhotos(allPhotos);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
fetchPhotos();
|
||||
}, [rideId]);
|
||||
|
||||
if (loading || photos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Camera className="w-5 h-5" />
|
||||
Recent Photos
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{photos.map((photo) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="aspect-square rounded-lg overflow-hidden bg-accent cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={onViewAll}
|
||||
>
|
||||
<img
|
||||
src={photo.image_url}
|
||||
alt={photo.caption || 'Ride photo'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 text-center">
|
||||
<Button variant="outline" onClick={onViewAll} className="w-full">
|
||||
View All Photos
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
90
src/components/rides/RideHighlights.tsx
Normal file
90
src/components/rides/RideHighlights.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Zap, TrendingUp, Award, Sparkles } from 'lucide-react';
|
||||
|
||||
interface RideHighlight {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface RideHighlightsProps {
|
||||
ride: any;
|
||||
}
|
||||
|
||||
export function RideHighlights({ ride }: RideHighlightsProps) {
|
||||
const highlights: RideHighlight[] = [];
|
||||
|
||||
// Add speed highlight if notable
|
||||
if (ride.max_speed_kmh && ride.max_speed_kmh > 60) {
|
||||
highlights.push({
|
||||
icon: <Zap className="w-5 h-5 text-amber-500" />,
|
||||
label: 'High Speed',
|
||||
value: `${ride.max_speed_kmh} km/h`
|
||||
});
|
||||
}
|
||||
|
||||
// Add height highlight if notable
|
||||
if (ride.max_height_meters && ride.max_height_meters > 30) {
|
||||
highlights.push({
|
||||
icon: <TrendingUp className="w-5 h-5 text-blue-500" />,
|
||||
label: 'Tall Structure',
|
||||
value: `${ride.max_height_meters}m high`
|
||||
});
|
||||
}
|
||||
|
||||
// Add inversions highlight
|
||||
if (ride.inversions && ride.inversions > 0) {
|
||||
highlights.push({
|
||||
icon: <Sparkles className="w-5 h-5 text-purple-500" />,
|
||||
label: 'Inversions',
|
||||
value: `${ride.inversions} ${ride.inversions === 1 ? 'inversion' : 'inversions'}`
|
||||
});
|
||||
}
|
||||
|
||||
// Add rating highlight if high
|
||||
if (ride.average_rating >= 4.0) {
|
||||
highlights.push({
|
||||
icon: <Award className="w-5 h-5 text-yellow-500" />,
|
||||
label: 'Highly Rated',
|
||||
value: `${ride.average_rating.toFixed(1)} stars`
|
||||
});
|
||||
}
|
||||
|
||||
if (highlights.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ride Highlights</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{highlights.map((highlight, index) => (
|
||||
<div key={index} className="flex flex-col items-center text-center p-4 rounded-lg bg-accent/50">
|
||||
<div className="mb-2">{highlight.icon}</div>
|
||||
<div className="font-medium text-sm mb-1">{highlight.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{highlight.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{ride.intensity_level && (
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Intensity Level:</span>
|
||||
<Badge variant={
|
||||
ride.intensity_level === 'extreme' ? 'destructive' :
|
||||
ride.intensity_level === 'high' ? 'default' : 'secondary'
|
||||
}>
|
||||
{ride.intensity_level.charAt(0).toUpperCase() + ride.intensity_level.slice(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
102
src/components/rides/SimilarRides.tsx
Normal file
102
src/components/rides/SimilarRides.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
interface SimilarRidesProps {
|
||||
currentRideId: string;
|
||||
parkId: string;
|
||||
parkSlug: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface SimilarRide {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
image_url: string | null;
|
||||
average_rating: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function SimilarRides({ currentRideId, parkId, parkSlug, category }: SimilarRidesProps) {
|
||||
const [rides, setRides] = useState<SimilarRide[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchSimilarRides() {
|
||||
const { data, error } = await supabase
|
||||
.from('rides')
|
||||
.select('id, name, slug, image_url, average_rating, status')
|
||||
.eq('park_id', parkId)
|
||||
.eq('category', category)
|
||||
.neq('id', currentRideId)
|
||||
.order('average_rating', { ascending: false })
|
||||
.limit(4);
|
||||
|
||||
if (!error && data) {
|
||||
setRides(data);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
fetchSimilarRides();
|
||||
}, [currentRideId, parkId, category]);
|
||||
|
||||
if (loading || rides.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Similar Rides You Might Enjoy</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{rides.map((ride) => (
|
||||
<Link
|
||||
key={ride.id}
|
||||
to={`/parks/${parkSlug}/rides/${ride.slug}`}
|
||||
className="group block"
|
||||
>
|
||||
<div className="border border-border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
|
||||
{ride.image_url ? (
|
||||
<div className="aspect-video overflow-hidden">
|
||||
<img
|
||||
src={ride.image_url}
|
||||
alt={ride.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-video bg-accent flex items-center justify-center">
|
||||
<span className="text-muted-foreground">No image</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3">
|
||||
<h4 className="font-medium mb-1 group-hover:text-primary transition-colors">
|
||||
{ride.name}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
|
||||
<span>{ride.average_rating.toFixed(1)}</span>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span className="text-muted-foreground capitalize">{ride.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 text-center">
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/parks/${parkSlug}/rides`}>View All Rides</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,12 @@ import {
|
||||
import { ReviewsSection } from '@/components/reviews/ReviewsSection';
|
||||
import { MeasurementDisplay } from '@/components/ui/measurement-display';
|
||||
import { RidePhotoGallery } from '@/components/rides/RidePhotoGallery';
|
||||
import { RatingDistribution } from '@/components/rides/RatingDistribution';
|
||||
import { RideHighlights } from '@/components/rides/RideHighlights';
|
||||
import { SimilarRides } from '@/components/rides/SimilarRides';
|
||||
import { FormerNames } from '@/components/rides/FormerNames';
|
||||
import { RecentPhotosPreview } from '@/components/rides/RecentPhotosPreview';
|
||||
import { ParkLocationMap } from '@/components/maps/ParkLocationMap';
|
||||
import { Ride } from '@/types/database';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
|
||||
@@ -37,6 +43,7 @@ export default function RideDetail() {
|
||||
const navigate = useNavigate();
|
||||
const [ride, setRide] = useState<Ride | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
|
||||
useEffect(() => {
|
||||
if (parkSlug && rideSlug) {
|
||||
@@ -54,7 +61,7 @@ export default function RideDetail() {
|
||||
.maybeSingle();
|
||||
|
||||
if (parkData) {
|
||||
// Then get ride details
|
||||
// Then get ride details with park_id stored separately
|
||||
const { data: rideData } = await supabase
|
||||
.from('rides')
|
||||
.select(`
|
||||
@@ -67,6 +74,11 @@ export default function RideDetail() {
|
||||
.eq('slug', rideSlug)
|
||||
.maybeSingle();
|
||||
|
||||
if (rideData) {
|
||||
// Store park_id for easier access
|
||||
(rideData as any).currentParkId = parkData.id;
|
||||
}
|
||||
|
||||
setRide(rideData);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -193,14 +205,35 @@ export default function RideDetail() {
|
||||
</div>
|
||||
|
||||
{ride.average_rating > 0 && (
|
||||
<div className="bg-black/20 backdrop-blur-sm rounded-lg p-4 text-center">
|
||||
<div className="flex items-center gap-2 text-white mb-1">
|
||||
<Star className="w-5 h-5 fill-yellow-400 text-yellow-400" />
|
||||
<span className="text-2xl font-bold">{ride.average_rating.toFixed(1)}</span>
|
||||
<div className="bg-black/30 backdrop-blur-md rounded-lg p-6 text-center min-w-[160px]">
|
||||
<div className="flex items-center justify-center gap-2 text-white mb-2">
|
||||
<Star className="w-6 h-6 fill-yellow-400 text-yellow-400" />
|
||||
<span className="text-3xl font-bold">
|
||||
{ride.average_rating.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-white/70 text-sm">
|
||||
{ride.review_count} reviews
|
||||
<div className="flex items-center justify-center gap-1 mb-3">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`w-4 h-4 ${
|
||||
star <= Math.round(ride.average_rating)
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-white/40'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-white/90 text-sm mb-3">
|
||||
{ride.review_count} {ride.review_count === 1 ? "review" : "reviews"}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setActiveTab("reviews")}
|
||||
>
|
||||
Write Review
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -323,7 +356,7 @@ export default function RideDetail() {
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="specs">Specifications</TabsTrigger>
|
||||
@@ -347,9 +380,32 @@ export default function RideDetail() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<RideHighlights ride={ride} />
|
||||
|
||||
{ride.former_names && ride.former_names.length > 0 && (
|
||||
<FormerNames formerNames={ride.former_names} currentName={ride.name} />
|
||||
)}
|
||||
|
||||
<SimilarRides
|
||||
currentRideId={ride.id}
|
||||
parkId={(ride as any).currentParkId}
|
||||
parkSlug={parkSlug || ''}
|
||||
category={ride.category}
|
||||
/>
|
||||
|
||||
<RecentPhotosPreview
|
||||
rideId={ride.id}
|
||||
onViewAll={() => setActiveTab("photos")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<RatingDistribution
|
||||
rideId={ride.id}
|
||||
totalReviews={ride.review_count}
|
||||
averageRating={ride.average_rating}
|
||||
/>
|
||||
{/* Ride Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -457,6 +513,22 @@ export default function RideDetail() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{ride.park?.location?.latitude && ride.park?.location?.longitude && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Location</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ParkLocationMap
|
||||
latitude={ride.park.location.latitude}
|
||||
longitude={ride.park.location.longitude}
|
||||
parkName={ride.park.name}
|
||||
className="h-48"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
Reference in New Issue
Block a user