feat: Enhance ride detail page with new sections

This commit is contained in:
gpt-engineer-app[bot]
2025-09-29 18:58:22 +00:00
parent cf9fab4f8a
commit 13223fd833
6 changed files with 510 additions and 8 deletions

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

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

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

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

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

View File

@@ -29,6 +29,12 @@ import {
import { ReviewsSection } from '@/components/reviews/ReviewsSection'; import { ReviewsSection } from '@/components/reviews/ReviewsSection';
import { MeasurementDisplay } from '@/components/ui/measurement-display'; import { MeasurementDisplay } from '@/components/ui/measurement-display';
import { RidePhotoGallery } from '@/components/rides/RidePhotoGallery'; 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 { Ride } from '@/types/database';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
@@ -37,6 +43,7 @@ export default function RideDetail() {
const navigate = useNavigate(); const navigate = useNavigate();
const [ride, setRide] = useState<Ride | null>(null); const [ride, setRide] = useState<Ride | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState("overview");
useEffect(() => { useEffect(() => {
if (parkSlug && rideSlug) { if (parkSlug && rideSlug) {
@@ -54,7 +61,7 @@ export default function RideDetail() {
.maybeSingle(); .maybeSingle();
if (parkData) { if (parkData) {
// Then get ride details // Then get ride details with park_id stored separately
const { data: rideData } = await supabase const { data: rideData } = await supabase
.from('rides') .from('rides')
.select(` .select(`
@@ -67,6 +74,11 @@ export default function RideDetail() {
.eq('slug', rideSlug) .eq('slug', rideSlug)
.maybeSingle(); .maybeSingle();
if (rideData) {
// Store park_id for easier access
(rideData as any).currentParkId = parkData.id;
}
setRide(rideData); setRide(rideData);
} }
} catch (error) { } catch (error) {
@@ -193,14 +205,35 @@ export default function RideDetail() {
</div> </div>
{ride.average_rating > 0 && ( {ride.average_rating > 0 && (
<div className="bg-black/20 backdrop-blur-sm rounded-lg p-4 text-center"> <div className="bg-black/30 backdrop-blur-md rounded-lg p-6 text-center min-w-[160px]">
<div className="flex items-center gap-2 text-white mb-1"> <div className="flex items-center justify-center gap-2 text-white mb-2">
<Star className="w-5 h-5 fill-yellow-400 text-yellow-400" /> <Star className="w-6 h-6 fill-yellow-400 text-yellow-400" />
<span className="text-2xl font-bold">{ride.average_rating.toFixed(1)}</span> <span className="text-3xl font-bold">
{ride.average_rating.toFixed(1)}
</span>
</div> </div>
<div className="text-white/70 text-sm"> <div className="flex items-center justify-center gap-1 mb-3">
{ride.review_count} reviews {[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>
<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>
)} )}
</div> </div>
@@ -323,7 +356,7 @@ export default function RideDetail() {
)} )}
{/* Main Content */} {/* Main Content */}
<Tabs defaultValue="overview" className="w-full"> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-4"> <TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="overview">Overview</TabsTrigger> <TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="specs">Specifications</TabsTrigger> <TabsTrigger value="specs">Specifications</TabsTrigger>
@@ -347,9 +380,32 @@ export default function RideDetail() {
</CardContent> </CardContent>
</Card> </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>
<div className="space-y-6"> <div className="space-y-6">
<RatingDistribution
rideId={ride.id}
totalReviews={ride.review_count}
averageRating={ride.average_rating}
/>
{/* Ride Information */} {/* Ride Information */}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -457,6 +513,22 @@ export default function RideDetail() {
</div> </div>
</CardContent> </CardContent>
</Card> </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>
</div> </div>
</TabsContent> </TabsContent>