Files
thrilltrack-explorer/src/pages/ParkDetail.tsx
gpt-engineer-app[bot] fc7c2d5adc Refactor park detail address display
Implement the plan to refactor the address display in the park detail page. This includes updating the sidebar address to show the street address on its own line, followed by city, state, and postal code on the next line, and the country on a separate line. This change aims to create a more compact and natural address format.
2025-11-06 14:03:58 +00:00

693 lines
31 KiB
TypeScript

import { useState, lazy, Suspense, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
import { trackPageView } from '@/lib/viewTracking';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Separator } from '@/components/ui/separator';
import { MapPin, Star, Clock, Phone, Globe, Calendar, ArrowLeft, Users, Zap, Camera, Castle, FerrisWheel, Waves, Tent, Plus } from 'lucide-react';
import { formatLocationShort } from '@/lib/locationFormatter';
import { useAuth } from '@/hooks/useAuth';
import { ReviewsSection } from '@/components/reviews/ReviewsSection';
import { RideCard } from '@/components/rides/RideCard';
import { Park, Ride } from '@/types/database';
import { ParkLocationMap } from '@/components/maps/ParkLocationMap';
import { EntityPhotoGallery } from '@/components/upload/EntityPhotoGallery';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { AdminFormSkeleton } from '@/components/loading/PageSkeletons';
import { toast } from '@/hooks/use-toast';
import { useParkDetail } from '@/hooks/parks/useParkDetail';
import { useParkRides } from '@/hooks/parks/useParkRides';
import { usePhotoCount } from '@/hooks/photos/usePhotoCount';
// Lazy load admin forms
const RideForm = lazy(() => import('@/components/admin/RideForm').then(m => ({ default: m.RideForm })));
const ParkForm = lazy(() => import('@/components/admin/ParkForm').then(m => ({ default: m.ParkForm })));
import { getErrorMessage } from '@/lib/errorHandler';
import { SubmissionErrorBoundary } from '@/components/error/SubmissionErrorBoundary';
import { useUserRole } from '@/hooks/useUserRole';
import { Edit } from 'lucide-react';
import { VersionIndicator } from '@/components/versioning/VersionIndicator';
import { EntityHistoryTabs } from '@/components/history/EntityHistoryTabs';
import { useAuthModal } from '@/hooks/useAuthModal';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
export default function ParkDetail() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const { requireAuth } = useAuthModal();
const [isAddRideModalOpen, setIsAddRideModalOpen] = useState(false);
const [isEditParkModalOpen, setIsEditParkModalOpen] = useState(false);
const { isModerator } = useUserRole();
// Fetch park data with caching
const { data: park, isLoading: loading, error } = useParkDetail(slug);
// Fetch rides with caching
const { data: rides = [] } = useParkRides(park?.id, !!park?.id);
// Fetch photo count with caching
const { data: photoCount = 0, isLoading: statsLoading } = usePhotoCount('park', park?.id, !!park?.id);
// Update document title when park changes
useDocumentTitle(park?.name || 'Park Details');
// Update Open Graph meta tags
useOpenGraph({
title: park?.name || '',
description: park?.description ?? (park ? `${park.name} - A theme park${park.location ? ` in ${park.location.city}, ${park.location.country}` : ''}` : undefined),
imageUrl: park?.banner_image_url ?? undefined,
imageId: park?.banner_image_id ?? undefined,
type: 'website',
enabled: !!park
});
// Track page view when park is loaded
useEffect(() => {
if (park?.id) {
trackPageView('park', park.id);
}
}, [park?.id]);
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 getParkTypeIcon = (type: string) => {
switch (type) {
case 'theme_park':
return <Castle className="w-20 h-20" />;
case 'amusement_park':
return <FerrisWheel className="w-20 h-20" />;
case 'water_park':
return <Waves className="w-20 h-20" />;
case 'family_entertainment':
return <Tent className="w-20 h-20" />;
default:
return <FerrisWheel className="w-20 h-20" />;
}
};
const formatParkType = (type: string) => {
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
};
const handleRideSubmit = async (rideData: any) => {
try {
const { submitRideCreation } = await import('@/lib/entitySubmissionHelpers');
await submitRideCreation(
{
...rideData,
park_id: park?.id
},
user!.id
);
toast({
title: "Submission Sent",
description: "Your ride submission has been sent for moderation review.",
});
setIsAddRideModalOpen(false);
} catch (error) {
const errorMsg = getErrorMessage(error);
toast({
title: "Submission Failed",
description: errorMsg,
variant: "destructive"
});
}
};
const handleEditParkSubmit = async (parkData: any) => {
if (!user || !park) return;
try {
// Everyone goes through submission queue
const { submitParkUpdate } = await import('@/lib/entitySubmissionHelpers');
await submitParkUpdate(park.id, parkData, user.id);
toast({
title: "Edit Submitted",
description: isModerator()
? "Your edit has been submitted. You can approve it in the moderation queue."
: "Your park edit has been submitted for review.",
});
setIsEditParkModalOpen(false);
} catch (error) {
const errorMsg = getErrorMessage(error);
toast({
title: "Error",
description: errorMsg,
variant: "destructive"
});
}
};
if (loading) {
return <div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-64 bg-muted rounded-lg"></div>
<div className="h-8 bg-muted rounded w-1/2"></div>
<div className="h-4 bg-muted rounded w-1/3"></div>
</div>
</div>
</div>;
}
if (!park) {
return <div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<h1 className="text-2xl font-bold mb-4">Park Not Found</h1>
<p className="text-muted-foreground mb-6">
The park you're looking for doesn't exist or has been removed.
</p>
<Button onClick={() => navigate('/parks')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Parks
</Button>
</div>
</div>
</div>;
}
return <div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl">
{/* Back Button and Edit Button */}
<div className="flex items-center justify-between mb-6">
<Button variant="ghost" onClick={() => navigate('/parks')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Parks
</Button>
<Button
variant="outline"
onClick={() => requireAuth(() => setIsEditParkModalOpen(true), "Sign in to edit this park")}
>
<Edit className="w-4 h-4 mr-2" />
Edit Park
</Button>
</div>
{/* Hero Section */}
<div className="relative mb-8">
<div className="aspect-[21/9] bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 rounded-lg overflow-hidden relative">
{(park.banner_image_url || park.banner_image_id) ? (
<picture>
<source
media="(max-width: 768px)"
srcSet={getBannerUrls(park.banner_image_id ?? undefined).mobile ?? park.banner_image_url ?? undefined}
/>
<img
src={getBannerUrls(park.banner_image_id ?? undefined).desktop ?? park.banner_image_url ?? undefined}
alt={park.name}
className="w-full h-full object-cover"
loading="eager"
/>
</picture>
) : (
<div className="flex items-center justify-center h-full">
<div className="opacity-50">
{getParkTypeIcon(park.park_type)}
</div>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
{/* Park Title Overlay */}
<div className="absolute bottom-0 left-0 right-0 p-8">
<div className="flex items-end justify-between">
<div>
<div className="flex items-center gap-3 mb-2">
<Badge className={`${getStatusColor(park.status)} border`}>
{park.status.replace('_', ' ').toUpperCase()}
</Badge>
<Badge variant="outline" className="bg-black/20 text-white border-white/20">
{formatParkType(park.park_type)}
</Badge>
</div>
<h1 className="text-4xl md:text-6xl font-bold text-white mb-2">
{park.name}
</h1>
{park.location && <div className="flex items-center text-white/90 text-lg">
<MapPin className="w-5 h-5 mr-2" />
{formatLocationShort(park.location)}
</div>}
<div className="mt-3">
<VersionIndicator
entityType="park"
entityId={park.id}
entityName={park.name}
/>
</div>
</div>
{(park.average_rating ?? 0) > 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">{(park.average_rating ?? 0).toFixed(1)}</span>
</div>
<div className="text-white/70 text-sm">
{park.review_count} reviews
</div>
</div>}
</div>
</div>
</div>
</div>
{/* Quick Stats */}
<div className="relative mb-12 max-w-6xl mx-auto">
{/* Background decorative elements */}
<div className="absolute inset-0 bg-gradient-to-r from-primary/5 via-secondary/5 to-accent/5 rounded-3xl blur-xl"></div>
<div className="relative grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-gradient-to-br from-background/80 via-card/90 to-background/80 backdrop-blur-sm rounded-xl border border-border/50 shadow-md">
{/* Total Rides */}
<div className="group relative overflow-hidden">
<Card className="h-full border-0 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent hover:shadow-lg hover:shadow-primary/15 transition-all duration-300 hover:scale-[1.02]">
<CardContent className="p-4 text-center relative">
<div className="absolute top-1 right-1 opacity-20 group-hover:opacity-40 transition-opacity">
<FerrisWheel className="w-6 h-6 text-primary" />
</div>
<div className="text-2xl font-bold text-primary mb-1 group-hover:scale-105 transition-transform">
{park.ride_count}
</div>
<div className="text-xs font-medium text-muted-foreground">Total Rides</div>
</CardContent>
</Card>
</div>
{/* Roller Coasters */}
<div className="group relative overflow-hidden">
<Card className="h-full border-0 bg-gradient-to-br from-accent/10 via-accent/5 to-transparent hover:shadow-lg hover:shadow-accent/15 transition-all duration-300 hover:scale-[1.02]">
<CardContent className="p-4 text-center relative">
<div className="absolute top-1 right-1 opacity-20 group-hover:opacity-40 transition-opacity">
<Zap className="w-6 h-6 text-accent" />
</div>
<div className="text-2xl font-bold text-accent mb-1 group-hover:scale-105 transition-transform">
{park.coaster_count}
</div>
<div className="text-xs font-medium text-muted-foreground">Roller Coasters</div>
</CardContent>
</Card>
</div>
{/* Reviews */}
<div className="group relative overflow-hidden">
<Card className="h-full border-0 bg-gradient-to-br from-secondary/10 via-secondary/5 to-transparent hover:shadow-lg hover:shadow-secondary/15 transition-all duration-300 hover:scale-[1.02]">
<CardContent className="p-4 text-center relative">
<div className="absolute top-1 right-1 opacity-20 group-hover:opacity-40 transition-opacity">
<Star className="w-6 h-6 text-secondary" />
</div>
<div className="text-2xl font-bold text-secondary mb-1 group-hover:scale-105 transition-transform">
{park.review_count}
</div>
<div className="text-xs font-medium text-muted-foreground">Reviews</div>
{(park.average_rating ?? 0) > 0 && <div className="flex items-center justify-center gap-1 mt-1">
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
<span className="text-xs font-medium text-yellow-500">
{(park.average_rating ?? 0).toFixed(1)}
</span>
</div>}
</CardContent>
</Card>
</div>
{/* Operating Status */}
<div className="group relative overflow-hidden">
<Card className="h-full border-0 bg-gradient-to-br from-muted/20 via-muted/10 to-transparent hover:shadow-lg hover:shadow-muted/15 transition-all duration-300 hover:scale-[1.02]">
<CardContent className="p-4 text-center relative">
<div className="flex items-center justify-center mb-2 group-hover:scale-105 transition-transform">
<div className="p-2 rounded-full bg-gradient-to-br from-primary/20 to-accent/20">
{park.opening_date ? <Calendar className="w-5 h-5" /> : <Clock className="w-5 h-5" />}
</div>
</div>
<div className="text-xs font-medium text-foreground">
{park.opening_date ? `Opened ${park.opening_date.split('-')[0]}` : 'Opening Soon'}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
{/* Main Content */}
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-2 md:grid-cols-5">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="rides">
Rides {rides.length > 0 && `(${rides.length})`}
</TabsTrigger>
<TabsTrigger value="reviews">
Reviews {(park.review_count ?? 0) > 0 && `(${park.review_count})`}
</TabsTrigger>
<TabsTrigger value="photos">
Photos {!statsLoading && photoCount > 0 && `(${photoCount})`}
</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-6">
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
{/* Description */}
{park.description && <Card>
<CardHeader>
<CardTitle>About {park.name}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground leading-relaxed">
{park.description}
</p>
</CardContent>
</Card>}
{/* Featured Rides */}
{rides.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Featured Rides</CardTitle>
</CardHeader>
<CardContent>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 lg:gap-5 xl:gap-4">
{rides.slice(0, 4).map(ride => (
<RideCard
key={ride.id}
ride={ride}
showParkName={false}
parkSlug={park.slug}
className="h-full"
/>
))}
</div>
{rides.length > 4 && (
<div className="mt-4 text-center">
<Button
variant="outline"
onClick={() => navigate(`/parks/${park.slug}/rides/`)}
>
View All {park.ride_count} Rides
</Button>
</div>
)}
</CardContent>
</Card>
)}
</div>
<div className="space-y-6">
{/* Park Information */}
<Card>
<CardHeader>
<CardTitle>Park Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{park.opening_date && <div className="flex items-center gap-3">
<Calendar className="w-4 h-4 text-muted-foreground" />
<div>
<div className="font-medium">Opened</div>
<div className="text-sm text-muted-foreground">
{new Date(park.opening_date).getFullYear()}
</div>
</div>
</div>}
{park.operator && <div className="flex items-center gap-3">
<Users className="w-4 h-4 text-muted-foreground" />
<div>
<div className="font-medium">Operator</div>
<div className="text-sm text-muted-foreground">
{park.operator.name}
</div>
</div>
</div>}
{park.website_url && <div className="flex items-center gap-3">
<Globe className="w-4 h-4 text-muted-foreground" />
<div>
<div className="font-medium">Website</div>
<a href={park.website_url} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline">
Visit Website
</a>
</div>
</div>}
{park.phone && <div className="flex items-center gap-3">
<Phone className="w-4 h-4 text-muted-foreground" />
<div>
<div className="font-medium">Phone</div>
<div className="text-sm text-muted-foreground">
{park.phone}
</div>
</div>
</div>}
<Separator />
<div className="space-y-4">
<div className="font-medium">Location</div>
{park.location && (
<div className="space-y-3">
{/* Full Address Display */}
<div className="text-sm text-muted-foreground">
<div className="font-medium text-foreground mb-1">Address:</div>
<div className="space-y-1">
{/* Street Address on its own line if it exists */}
{park.location.street_address && (
<div>{park.location.street_address}</div>
)}
{/* City, State Postal on same line */}
{(park.location.city || park.location.state_province || park.location.postal_code) && (
<div>
{park.location.city}
{park.location.city && park.location.state_province && ', '}
{park.location.state_province}
{park.location.postal_code && ` ${park.location.postal_code}`}
</div>
)}
{/* Country on its own line */}
{park.location.country && (
<div>{park.location.country}</div>
)}
</div>
</div>
{/* Map Links */}
{park.location?.latitude && park.location?.longitude && (
<div className="flex gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
asChild
className="text-xs"
>
<a
href={`https://maps.apple.com/?q=${encodeURIComponent(park.name)}&ll=${park.location.latitude},${park.location.longitude}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
<MapPin className="w-3 h-3" />
Apple Maps
</a>
</Button>
<Button
variant="outline"
size="sm"
asChild
className="text-xs"
>
<a
href={`https://maps.google.com/?q=${encodeURIComponent(park.name)}&ll=${park.location.latitude},${park.location.longitude}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
<Globe className="w-3 h-3" />
Google Maps
</a>
</Button>
</div>
)}
</div>
)}
{park.location?.latitude && park.location?.longitude && (
<div className="mt-4">
<ParkLocationMap
latitude={Number(park.location.latitude)}
longitude={Number(park.location.longitude)}
parkName={park.name}
/>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
</TabsContent>
<TabsContent value="rides" className="mt-6">
{/* Header with Add Ride button */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">Rides at {park.name}</h2>
<Button onClick={() => requireAuth(() => setIsAddRideModalOpen(true), "Sign in to add a ride")}>
<Plus className="w-4 h-4 mr-2" />
Add Ride
</Button>
</div>
{/* Conditional rendering */}
{rides.length === 0 ? (
<Card className="border-dashed bg-muted/50">
<CardContent className="p-12 text-center">
<FerrisWheel className="w-16 h-16 mx-auto mb-4 text-muted-foreground/40" />
<h3 className="text-xl font-semibold mb-2">No rides yet</h3>
<p className="text-muted-foreground">
Be the first to add a ride to this park
</p>
</CardContent>
</Card>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{rides.map(ride => (
<RideCard
key={ride.id}
ride={ride}
showParkName={false}
parkSlug={park.slug}
/>
))}
</div>
<div className="mt-8 text-center">
<Button
variant="outline"
size="lg"
onClick={() => navigate(`/parks/${park.slug}/rides/`)}
>
View All {park.ride_count} Rides
</Button>
</div>
</>
)}
</TabsContent>
<TabsContent value="reviews" className="mt-6">
<ReviewsSection entityType="park" entityId={park.id} entityName={park.name} averageRating={park.average_rating ?? 0} reviewCount={park.review_count ?? 0} />
</TabsContent>
<TabsContent value="photos" className="mt-6">
<EntityPhotoGallery
entityId={park.id}
entityType="park"
entityName={park.name}
/>
</TabsContent>
<TabsContent value="history" className="mt-6">
<EntityHistoryTabs
entityType="park"
entityId={park.id}
entityName={park.name}
/>
</TabsContent>
</Tabs>
{/* Add Ride Modal */}
<Dialog open={isAddRideModalOpen} onOpenChange={setIsAddRideModalOpen}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Add New Ride to {park.name}</DialogTitle>
<DialogDescription>
Submit a new ride for moderation. All submissions are reviewed before being published.
</DialogDescription>
</DialogHeader>
<Suspense fallback={<AdminFormSkeleton />}>
<SubmissionErrorBoundary>
<RideForm
onSubmit={handleRideSubmit}
onCancel={() => setIsAddRideModalOpen(false)}
initialData={{ park_id: park.id }}
/>
</SubmissionErrorBoundary>
</Suspense>
</DialogContent>
</Dialog>
{/* Edit Park Modal */}
<Dialog open={isEditParkModalOpen} onOpenChange={setIsEditParkModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Park</DialogTitle>
<DialogDescription>
Make changes to the park information. {isModerator() ? 'Changes will be applied immediately.' : 'Your changes will be submitted for review.'}
</DialogDescription>
</DialogHeader>
<Suspense fallback={<AdminFormSkeleton />}>
<SubmissionErrorBoundary>
<ParkForm
onSubmit={handleEditParkSubmit}
onCancel={() => setIsEditParkModalOpen(false)}
initialData={{
id: park?.id,
name: park?.name,
slug: park?.slug,
description: park?.description ?? undefined,
park_type: park?.park_type,
status: park?.status,
opening_date: park?.opening_date ?? undefined,
opening_date_precision: (park?.opening_date_precision as 'day' | 'month' | 'year') ?? undefined,
closing_date: park?.closing_date ?? undefined,
closing_date_precision: (park?.closing_date_precision as 'day' | 'month' | 'year') ?? undefined,
location_id: park?.location?.id,
location: park?.location ? {
name: park.location.name || '',
city: park.location.city || '',
state_province: park.location.state_province || '',
country: park.location.country || '',
postal_code: park.location.postal_code || '',
latitude: park.location.latitude || 0,
longitude: park.location.longitude || 0,
timezone: park.location.timezone || '',
display_name: park.location.name || '',
} : undefined,
website_url: park?.website_url ?? undefined,
phone: park?.phone ?? undefined,
email: park?.email ?? undefined,
operator_id: park?.operator?.id,
property_owner_id: park?.property_owner?.id,
banner_image_url: park?.banner_image_url ?? undefined,
card_image_url: park?.card_image_url ?? undefined
}}
isEditing={true}
/>
</SubmissionErrorBoundary>
</Suspense>
</DialogContent>
</Dialog>
</main>
</div>;
}