From 90bb0216b7476627588315a0de0276f6188f0c0a Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 20 Sep 2025 00:55:36 +0000 Subject: [PATCH] Add park and ride pages --- src/App.tsx | 4 + src/components/layout/Header.tsx | 42 +- src/components/search/SearchDropdown.tsx | 67 ++++ src/components/search/SearchResults.tsx | 237 +++++++++++ src/pages/Manufacturers.tsx | 251 ++++++++++++ src/pages/RideDetail.tsx | 487 +++++++++++++++++++++++ 6 files changed, 1058 insertions(+), 30 deletions(-) create mode 100644 src/components/search/SearchDropdown.tsx create mode 100644 src/components/search/SearchResults.tsx create mode 100644 src/pages/Manufacturers.tsx create mode 100644 src/pages/RideDetail.tsx diff --git a/src/App.tsx b/src/App.tsx index 2eb50950..32afc193 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,9 @@ import { BrowserRouter, Routes, Route } from "react-router-dom"; import Index from "./pages/Index"; import Parks from "./pages/Parks"; import ParkDetail from "./pages/ParkDetail"; +import RideDetail from "./pages/RideDetail"; import Rides from "./pages/Rides"; +import Manufacturers from "./pages/Manufacturers"; import NotFound from "./pages/NotFound"; const queryClient = new QueryClient(); @@ -21,7 +23,9 @@ const App = () => ( } /> } /> } /> + } /> } /> + } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} } /> diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 46d96899..1d0ce14f 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -5,9 +5,9 @@ import { Input } from '@/components/ui/input'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { Badge } from '@/components/ui/badge'; import { Link, useNavigate } from 'react-router-dom'; +import { SearchDropdown } from '@/components/search/SearchDropdown'; export function Header() { - const [isSearchFocused, setIsSearchFocused] = useState(false); const navigate = useNavigate(); return ( @@ -49,7 +49,11 @@ export function Header() { Reviews - @@ -57,33 +61,7 @@ export function Header() { {/* Search Bar */}
-
- - setIsSearchFocused(true)} - onBlur={() => setIsSearchFocused(false)} - /> - {isSearchFocused && ( -
-
Popular searches
-
- - Roller Coasters - - - Disney World - - - Cedar Point - -
-
- )} -
+
{/* User Actions */} @@ -123,7 +101,11 @@ export function Header() { Reviews -
diff --git a/src/components/search/SearchDropdown.tsx b/src/components/search/SearchDropdown.tsx new file mode 100644 index 00000000..dd38a81f --- /dev/null +++ b/src/components/search/SearchDropdown.tsx @@ -0,0 +1,67 @@ +import { useState, useRef, useEffect } from 'react'; +import { Search } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { SearchResults } from './SearchResults'; + +export function SearchDropdown() { + const [query, setQuery] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const searchRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (searchRef.current && !searchRef.current.contains(event.target as Node)) { + setIsOpen(false); + setIsFocused(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setQuery(value); + setIsOpen(value.length >= 1); + }; + + const handleInputFocus = () => { + setIsFocused(true); + if (query.length >= 1) { + setIsOpen(true); + } + }; + + const handleClose = () => { + setIsOpen(false); + setQuery(''); + inputRef.current?.blur(); + }; + + return ( +
+
+ + +
+ + {isOpen && ( +
+ +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/search/SearchResults.tsx b/src/components/search/SearchResults.tsx new file mode 100644 index 00000000..1fe6fe52 --- /dev/null +++ b/src/components/search/SearchResults.tsx @@ -0,0 +1,237 @@ +import { useState, useEffect } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { MapPin, Star, Search as SearchIcon } from 'lucide-react'; +import { Park, Ride, Company } from '@/types/database'; +import { supabase } from '@/integrations/supabase/client'; +import { useNavigate } from 'react-router-dom'; + +interface SearchResultsProps { + query: string; + onClose: () => void; +} + +type SearchResult = { + type: 'park' | 'ride' | 'company'; + data: Park | Ride | Company; +}; + +export function SearchResults({ query, onClose }: SearchResultsProps) { + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + useEffect(() => { + if (query.length >= 2) { + searchContent(); + } else { + setResults([]); + } + }, [query]); + + const searchContent = async () => { + setLoading(true); + try { + const searchTerm = `%${query.toLowerCase()}%`; + + // Search parks + const { data: parks } = await supabase + .from('parks') + .select(`*, location:locations(*)`) + .or(`name.ilike.${searchTerm},description.ilike.${searchTerm}`) + .limit(5); + + // Search rides + const { data: rides } = await supabase + .from('rides') + .select(`*, park:parks!inner(name, slug)`) + .or(`name.ilike.${searchTerm},description.ilike.${searchTerm}`) + .limit(5); + + // Search companies + const { data: companies } = await supabase + .from('companies') + .select('*') + .or(`name.ilike.${searchTerm},description.ilike.${searchTerm}`) + .limit(3); + + const allResults: SearchResult[] = [ + ...(parks || []).map(park => ({ type: 'park' as const, data: park })), + ...(rides || []).map(ride => ({ type: 'ride' as const, data: ride })), + ...(companies || []).map(company => ({ type: 'company' as const, data: company })) + ]; + + setResults(allResults); + } catch (error) { + console.error('Search error:', error); + } finally { + setLoading(false); + } + }; + + const handleResultClick = (result: SearchResult) => { + onClose(); + + switch (result.type) { + case 'park': + navigate(`/parks/${(result.data as Park).slug}`); + break; + case 'ride': + const ride = result.data as Ride; + if (ride.park && typeof ride.park === 'object' && 'slug' in ride.park) { + navigate(`/parks/${ride.park.slug}/rides/${ride.slug}`); + } + break; + case 'company': + // Navigate to manufacturer page when implemented + console.log('Company clicked:', result.data); + break; + } + }; + + const getResultIcon = (result: SearchResult) => { + switch (result.type) { + case 'park': + const park = result.data as Park; + switch (park.park_type) { + case 'theme_park': return '🏰'; + case 'amusement_park': return '🎢'; + case 'water_park': return '🏊'; + default: return '🎡'; + } + case 'ride': + const ride = result.data as Ride; + switch (ride.category) { + case 'roller_coaster': return '🎢'; + case 'water_ride': return '🌊'; + case 'dark_ride': return '🎭'; + default: return '🎡'; + } + case 'company': + return '🏭'; + } + }; + + const getResultTitle = (result: SearchResult) => { + return result.data.name; + }; + + const getResultSubtitle = (result: SearchResult) => { + switch (result.type) { + case 'park': + const park = result.data as Park; + return park.location ? `${park.location.city}, ${park.location.country}` : 'Theme Park'; + case 'ride': + const ride = result.data as Ride; + return ride.park && typeof ride.park === 'object' && 'name' in ride.park + ? `at ${ride.park.name}` + : 'Ride'; + case 'company': + const company = result.data as Company; + return company.company_type.replace('_', ' '); + } + }; + + const getResultRating = (result: SearchResult) => { + if (result.type === 'park' || result.type === 'ride') { + const data = result.data as Park | Ride; + return data.average_rating > 0 ? data.average_rating : null; + } + return null; + }; + + if (query.length < 2) { + return ( +
+ +

+ Start typing to search parks, rides, and manufacturers... +

+
+ ); + } + + if (loading) { + return ( +
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+ ); + } + + if (results.length === 0) { + return ( +
+ +

+ No results found for "{query}" +

+

+ Try searching for park names, ride names, or locations +

+
+ ); + } + + return ( +
+
+ {results.map((result, index) => ( + handleResultClick(result)} + > + +
+
{getResultIcon(result)}
+
+
+

+ {getResultTitle(result)} +

+ + {result.type} + +
+

+ {getResultSubtitle(result)} +

+
+ {getResultRating(result) && ( +
+ + + {getResultRating(result)?.toFixed(1)} + +
+ )} +
+
+
+ ))} +
+ + {results.length > 0 && ( +
+ +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/pages/Manufacturers.tsx b/src/pages/Manufacturers.tsx new file mode 100644 index 00000000..6b20e69b --- /dev/null +++ b/src/pages/Manufacturers.tsx @@ -0,0 +1,251 @@ +import { useState, useEffect } from 'react'; +import { Header } from '@/components/layout/Header'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; +import { Search, Filter, SlidersHorizontal, Globe, MapPin } from 'lucide-react'; +import { Company } from '@/types/database'; +import { supabase } from '@/integrations/supabase/client'; +import { useNavigate } from 'react-router-dom'; + +export default function Manufacturers() { + const [companies, setCompanies] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState('name'); + const [filterType, setFilterType] = useState('all'); + const navigate = useNavigate(); + + useEffect(() => { + fetchCompanies(); + }, [sortBy, filterType]); + + const fetchCompanies = async () => { + try { + let query = supabase + .from('companies') + .select('*'); + + // Apply filters + if (filterType !== 'all') { + query = query.eq('company_type', filterType); + } + + // Apply sorting + switch (sortBy) { + case 'founded': + query = query.order('founded_year', { ascending: false, nullsFirst: false }); + break; + default: + query = query.order('name'); + } + + const { data } = await query; + setCompanies(data || []); + } catch (error) { + console.error('Error fetching companies:', error); + } finally { + setLoading(false); + } + }; + + const filteredCompanies = companies.filter(company => + company.name.toLowerCase().includes(searchQuery.toLowerCase()) || + company.headquarters_location?.toLowerCase().includes(searchQuery.toLowerCase()) || + company.description?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const getCompanyIcon = (type: string) => { + switch (type) { + case 'manufacturer': return '🏭'; + case 'operator': return '🎡'; + case 'designer': return '📐'; + case 'contractor': return '🔨'; + default: return '🏢'; + } + }; + + const formatCompanyType = (type: string) => { + return type.split('_').map(word => + word.charAt(0).toUpperCase() + word.slice(1) + ).join(' '); + }; + + const companyTypes = [ + { value: 'all', label: 'All Types' }, + { value: 'manufacturer', label: 'Manufacturers' }, + { value: 'operator', label: 'Operators' }, + { value: 'designer', label: 'Designers' }, + { value: 'contractor', label: 'Contractors' } + ]; + + if (loading) { + return ( +
+
+
+
+
+
+ {[...Array(8)].map((_, i) => ( +
+ ))} +
+
+
+
+ ); + } + + return ( +
+
+ +
+ {/* Page Header */} +
+
+
🏭
+

Manufacturers & Companies

+
+

+ Explore the companies behind your favorite rides and attractions +

+
+ {filteredCompanies.length} companies found + {companies.filter(c => c.company_type === 'manufacturer').length} manufacturers +
+
+ + {/* Search and Filters */} +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+
+ + + +
+
+
+ + {/* Companies Grid */} + {filteredCompanies.length > 0 ? ( +
+ {filteredCompanies.map((company) => ( + + +
+
+
+ {getCompanyIcon(company.company_type)} + + {formatCompanyType(company.company_type)} + +
+ + {company.name} + +
+ {company.logo_url && ( +
+ {`${company.name} +
+ )} +
+
+ + + {company.description && ( +

+ {company.description} +

+ )} + +
+ {company.founded_year && ( +
+ Founded: + {company.founded_year} +
+ )} + + {company.headquarters_location && ( +
+ + + {company.headquarters_location} + +
+ )} +
+ + {company.website_url && ( + + )} +
+
+ ))} +
+ ) : ( +
+
🏭
+

No companies found

+

+ Try adjusting your search criteria or filters +

+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/pages/RideDetail.tsx b/src/pages/RideDetail.tsx new file mode 100644 index 00000000..6f8a029a --- /dev/null +++ b/src/pages/RideDetail.tsx @@ -0,0 +1,487 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { Header } from '@/components/layout/Header'; +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, + Zap, + ArrowLeft, + Users, + Ruler, + Timer, + TrendingUp, + Camera, + Heart, + AlertTriangle +} from 'lucide-react'; +import { Ride } from '@/types/database'; +import { supabase } from '@/integrations/supabase/client'; + +export default function RideDetail() { + const { parkSlug, rideSlug } = useParams<{ parkSlug: string; rideSlug: string }>(); + const navigate = useNavigate(); + const [ride, setRide] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (parkSlug && rideSlug) { + fetchRideData(); + } + }, [parkSlug, rideSlug]); + + const fetchRideData = async () => { + try { + // First get park to find park_id + const { data: parkData } = await supabase + .from('parks') + .select('id') + .eq('slug', parkSlug) + .maybeSingle(); + + if (parkData) { + // Then get ride details + const { data: rideData } = await supabase + .from('rides') + .select(` + *, + park:parks!inner(name, slug, location:locations(*)), + manufacturer:companies!rides_manufacturer_id_fkey(*), + designer:companies!rides_designer_id_fkey(*) + `) + .eq('park_id', parkData.id) + .eq('slug', rideSlug) + .maybeSingle(); + + setRide(rideData); + } + } catch (error) { + console.error('Error fetching ride data:', error); + } finally { + setLoading(false); + } + }; + + 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 getRideIcon = (category: string) => { + switch (category) { + case 'roller_coaster': return '🎢'; + case 'water_ride': return '🌊'; + case 'dark_ride': return '🎭'; + case 'flat_ride': return '🎡'; + case 'kiddie_ride': return '🎠'; + case 'transportation': return '🚂'; + default: return '🎢'; + } + }; + + const formatCategory = (category: string) => { + return category.split('_').map(word => + word.charAt(0).toUpperCase() + word.slice(1) + ).join(' '); + }; + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + + if (!ride || !ride.park) { + return ( +
+
+
+
+

Ride Not Found

+

+ The ride you're looking for doesn't exist or has been removed. +

+ +
+
+
+ ); + } + + return ( +
+
+ +
+ {/* Back Button */} + + + {/* Hero Section */} +
+
+ {ride.image_url ? ( + {ride.name} + ) : ( +
+
+ {getRideIcon(ride.category)} +
+
+ )} +
+ + {/* Ride Title Overlay */} +
+
+
+
+ + {ride.status.replace('_', ' ').toUpperCase()} + + + {formatCategory(ride.category)} + +
+

+ {ride.name} +

+
+ + {ride.park.name} +
+
+ + {ride.average_rating > 0 && ( +
+
+ + {ride.average_rating.toFixed(1)} +
+
+ {ride.review_count} reviews +
+
+ )} +
+
+
+
+ + {/* Quick Stats */} +
+ {ride.max_speed_kmh && ( + + + +
{ride.max_speed_kmh}
+
km/h
+
+
+ )} + + {ride.max_height_meters && ( + + + +
{ride.max_height_meters}
+
meters
+
+
+ )} + + {ride.length_meters && ( + + + +
{Math.round(ride.length_meters)}
+
meters long
+
+
+ )} + + {ride.duration_seconds && ( + + + +
{Math.floor(ride.duration_seconds / 60)}:{(ride.duration_seconds % 60).toString().padStart(2, '0')}
+
duration
+
+
+ )} + + {ride.capacity_per_hour && ( + + + +
{ride.capacity_per_hour}
+
riders/hour
+
+
+ )} + + {ride.inversions && ride.inversions > 0 && ( + + +
🔄
+
{ride.inversions}
+
inversions
+
+
+ )} +
+ + {/* Requirements & Warnings */} + {(ride.height_requirement || ride.age_requirement) && ( + + +
+ +
+

+ Ride Requirements +

+
+ {ride.height_requirement && ( +
Minimum height: {ride.height_requirement}cm
+ )} + {ride.age_requirement && ( +
Minimum age: {ride.age_requirement} years
+ )} +
+
+
+
+
+ )} + + {/* Main Content */} + + + Overview + Specifications + Reviews + Photos + + + +
+
+ {/* Description */} + {ride.description && ( + + + About {ride.name} + + +

+ {ride.description} +

+
+
+ )} +
+ +
+ {/* Ride Information */} + + + Ride Information + + +
+
{getRideIcon(ride.category)}
+
+
Category
+
+ {formatCategory(ride.category)} +
+
+
+ + {ride.opening_date && ( +
+ +
+
Opened
+
+ {new Date(ride.opening_date).getFullYear()} +
+
+
+ )} + + {ride.manufacturer && ( +
+ +
+
Manufacturer
+
+ {ride.manufacturer.name} +
+
+
+ )} + + {ride.designer && ( +
+ +
+
Designer
+
+ {ride.designer.name} +
+
+
+ )} + + + +
+
Located at
+ +
+
+
+
+
+
+ + +
+ {/* Performance Stats */} + + + Performance + + + {ride.max_speed_kmh && ( +
+ Maximum Speed + {ride.max_speed_kmh} km/h +
+ )} + {ride.max_height_meters && ( +
+ Maximum Height + {ride.max_height_meters}m +
+ )} + {ride.length_meters && ( +
+ Track Length + {Math.round(ride.length_meters)}m +
+ )} + {ride.duration_seconds && ( +
+ Ride Duration + {Math.floor(ride.duration_seconds / 60)}:{(ride.duration_seconds % 60).toString().padStart(2, '0')} +
+ )} + {ride.inversions && ride.inversions > 0 && ( +
+ Inversions + {ride.inversions} +
+ )} +
+
+ + {/* Operational Info */} + + + Operational Details + + + {ride.capacity_per_hour && ( +
+ Capacity + {ride.capacity_per_hour} riders/hour +
+ )} + {ride.height_requirement && ( +
+ Height Requirement + {ride.height_requirement}cm minimum +
+ )} + {ride.age_requirement && ( +
+ Age Requirement + {ride.age_requirement}+ years +
+ )} +
+ Status + + {ride.status.replace('_', ' ')} + +
+
+
+
+
+ + +
+ +

Reviews Coming Soon

+

+ User reviews and ratings will be available soon +

+
+
+ + +
+ +

Photo Gallery Coming Soon

+

+ Photo galleries and media uploads will be available soon +

+
+
+
+
+
+ ); +} \ No newline at end of file