diff --git a/src/components/parks/ParkFilters.tsx b/src/components/parks/ParkFilters.tsx new file mode 100644 index 00000000..5472c305 --- /dev/null +++ b/src/components/parks/ParkFilters.tsx @@ -0,0 +1,231 @@ +import { useState, useMemo } from 'react'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Slider } from '@/components/ui/slider'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { RotateCcw } from 'lucide-react'; +import { Park } from '@/types/database'; +import { FilterState } from '@/pages/Parks'; + +interface ParkFiltersProps { + filters: FilterState; + onFiltersChange: (filters: FilterState) => void; + parks: Park[]; +} + +export function ParkFilters({ filters, onFiltersChange, parks }: ParkFiltersProps) { + const countries = useMemo(() => { + const countrySet = new Set(); + parks.forEach(park => { + if (park.location?.country) { + countrySet.add(park.location.country); + } + }); + return Array.from(countrySet).sort(); + }, [parks]); + + const parkTypes = [ + { value: 'all', label: 'All Types' }, + { value: 'theme_park', label: 'Theme Parks' }, + { value: 'amusement_park', label: 'Amusement Parks' }, + { value: 'water_park', label: 'Water Parks' }, + { value: 'family_entertainment', label: 'Family Entertainment' } + ]; + + const statusOptions = [ + { value: 'all', label: 'All Status' }, + { value: 'operating', label: 'Operating' }, + { value: 'seasonal', label: 'Seasonal' }, + { value: 'under_construction', label: 'Under Construction' }, + { value: 'closed', label: 'Closed' } + ]; + + const maxRides = useMemo(() => { + return Math.max(...parks.map(p => p.ride_count || 0), 100); + }, [parks]); + + const currentYear = new Date().getFullYear(); + const minYear = 1900; + + const resetFilters = () => { + onFiltersChange({ + search: '', + parkType: 'all', + status: 'all', + country: 'all', + minRating: 0, + maxRating: 5, + minRides: 0, + maxRides: maxRides, + openingYearStart: null, + openingYearEnd: null, + }); + }; + + return ( +
+
+

Filter Parks

+ +
+ +
+ {/* Park Type */} +
+ + +
+ + {/* Status */} +
+ + +
+ + {/* Country */} +
+ + +
+ + {/* Opening Year Range */} +
+ +
+ onFiltersChange({ + ...filters, + openingYearStart: e.target.value ? parseInt(e.target.value) : null + })} + /> + onFiltersChange({ + ...filters, + openingYearEnd: e.target.value ? parseInt(e.target.value) : null + })} + /> +
+
+
+ + + +
+ {/* Rating Range */} +
+
+ +
+ + {filters.minRating.toFixed(1)} - {filters.maxRating.toFixed(1)} stars + +
+
+
+ + onFiltersChange({ ...filters, minRating: min, maxRating: max }) + } + min={0} + max={5} + step={0.1} + className="w-full" + /> +
+ 0 stars + 5 stars +
+
+
+ + {/* Ride Count Range */} +
+
+ +
+ + {filters.minRides} - {filters.maxRides} rides + +
+
+
+ + onFiltersChange({ ...filters, minRides: min, maxRides: max }) + } + min={0} + max={maxRides} + step={1} + className="w-full" + /> +
+ 0 rides + {maxRides} rides +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/parks/ParkGridView.tsx b/src/components/parks/ParkGridView.tsx new file mode 100644 index 00000000..e8e7a8f0 --- /dev/null +++ b/src/components/parks/ParkGridView.tsx @@ -0,0 +1,21 @@ +import { ParkCard } from './ParkCard'; +import { Park } from '@/types/database'; + +interface ParkGridViewProps { + parks: Park[]; + onParkClick: (park: Park) => void; +} + +export function ParkGridView({ parks, onParkClick }: ParkGridViewProps) { + return ( +
+ {parks.map((park) => ( + onParkClick(park)} + /> + ))} +
+ ); +} \ No newline at end of file diff --git a/src/components/parks/ParkListView.tsx b/src/components/parks/ParkListView.tsx new file mode 100644 index 00000000..58539bd1 --- /dev/null +++ b/src/components/parks/ParkListView.tsx @@ -0,0 +1,160 @@ +import { MapPin, Star, Users, Calendar, ExternalLink } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Park } from '@/types/database'; + +interface ParkListViewProps { + parks: Park[]; + onParkClick: (park: Park) => void; +} + +export function ParkListView({ parks, onParkClick }: ParkListViewProps) { + 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 '🏰'; + case 'amusement_park': return '🎢'; + case 'water_park': return '🏊'; + case 'family_entertainment': return '🎪'; + default: return '🎡'; + } + }; + + const formatParkType = (type: string) => { + return type.split('_').map(word => + word.charAt(0).toUpperCase() + word.slice(1) + ).join(' '); + }; + + return ( +
+ {parks.map((park) => ( + onParkClick(park)} + > + +
+ {/* Image */} +
+ {park.card_image_url ? ( + {park.name} + ) : ( +
+ + {getParkTypeIcon(park.park_type)} + +
+ )} + + {/* Status Badge */} + + {park.status.replace('_', ' ').toUpperCase()} + +
+ + {/* Content */} +
+
+ {/* Header */} +
+
+
+

+ {park.name} +

+ {getParkTypeIcon(park.park_type)} +
+ + {park.location && ( +
+ + {park.location.city && `${park.location.city}, `} + {park.location.state_province && `${park.location.state_province}, `} + {park.location.country} +
+ )} +
+ + {/* Rating */} + {park.average_rating > 0 && ( +
+ + {park.average_rating.toFixed(1)} + ({park.review_count}) +
+ )} +
+ + {/* Description */} + {park.description && ( +

+ {park.description} +

+ )} + + {/* Tags */} +
+ + {formatParkType(park.park_type)} + + {park.opening_date && ( + + + Opened {new Date(park.opening_date).getFullYear()} + + )} +
+
+ + {/* Stats and Actions */} +
+
+
+ {park.ride_count || 0} + rides +
+
+ {park.coaster_count || 0} + 🎢 +
+ {park.review_count > 0 && ( +
+ + {park.review_count} reviews +
+ )} +
+ + +
+
+
+
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/src/components/parks/ParkSearch.tsx b/src/components/parks/ParkSearch.tsx new file mode 100644 index 00000000..2115f7cb --- /dev/null +++ b/src/components/parks/ParkSearch.tsx @@ -0,0 +1,38 @@ +import { useState } from 'react'; +import { Search, X } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; + +interface ParkSearchProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +export function ParkSearch({ + value, + onChange, + placeholder = "Search parks, locations, descriptions..." +}: ParkSearchProps) { + return ( +
+ + onChange(e.target.value)} + className="pl-10 pr-10 bg-muted/50 border-border/50 focus:border-primary/50 transition-colors" + /> + {value && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/parks/ParkSortOptions.tsx b/src/components/parks/ParkSortOptions.tsx new file mode 100644 index 00000000..7286b2d8 --- /dev/null +++ b/src/components/parks/ParkSortOptions.tsx @@ -0,0 +1,61 @@ +import { SortAsc, SortDesc, ArrowUpDown } from 'lucide-react'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Button } from '@/components/ui/button'; +import { SortState } from '@/pages/Parks'; + +interface ParkSortOptionsProps { + sort: SortState; + onSortChange: (sort: SortState) => void; +} + +export function ParkSortOptions({ sort, onSortChange }: ParkSortOptionsProps) { + const sortOptions = [ + { value: 'name', label: 'Name' }, + { value: 'rating', label: 'Rating' }, + { value: 'rides', label: 'Ride Count' }, + { value: 'coasters', label: 'Coaster Count' }, + { value: 'reviews', label: 'Review Count' }, + { value: 'opening', label: 'Opening Date' }, + ]; + + const toggleDirection = () => { + onSortChange({ + ...sort, + direction: sort.direction === 'asc' ? 'desc' : 'asc' + }); + }; + + return ( +
+ + + +
+ ); +} \ No newline at end of file diff --git a/src/pages/Parks.tsx b/src/pages/Parks.tsx index 65c4549f..53163937 100644 --- a/src/pages/Parks.tsx +++ b/src/pages/Parks.tsx @@ -1,92 +1,231 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Header } from '@/components/layout/Header'; -import { ParkCard } from '@/components/parks/ParkCard'; -import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; -import { MapPin, Search, Filter, SlidersHorizontal } from 'lucide-react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + MapPin, + Grid3X3, + List, + Map, + Filter, + SortAsc, + Search, + ChevronDown, + Sliders, + X +} from 'lucide-react'; import { Park } from '@/types/database'; import { supabase } from '@/integrations/supabase/client'; import { useNavigate } from 'react-router-dom'; +import { ParkFilters } from '@/components/parks/ParkFilters'; +import { ParkGridView } from '@/components/parks/ParkGridView'; +import { ParkListView } from '@/components/parks/ParkListView'; +import { ParkSearch } from '@/components/parks/ParkSearch'; +import { ParkSortOptions } from '@/components/parks/ParkSortOptions'; +import { useToast } from '@/hooks/use-toast'; + +export interface FilterState { + search: string; + parkType: string; + status: string; + country: string; + minRating: number; + maxRating: number; + minRides: number; + maxRides: number; + openingYearStart: number | null; + openingYearEnd: number | null; +} + +export interface SortState { + field: string; + direction: 'asc' | 'desc'; +} + +const initialFilters: FilterState = { + search: '', + parkType: 'all', + status: 'all', + country: 'all', + minRating: 0, + maxRating: 5, + minRides: 0, + maxRides: 1000, + openingYearStart: null, + openingYearEnd: null, +}; + +const initialSort: SortState = { + field: 'name', + direction: 'asc' +}; export default function Parks() { const [parks, setParks] = useState([]); const [loading, setLoading] = useState(true); - const [searchQuery, setSearchQuery] = useState(''); - const [sortBy, setSortBy] = useState('name'); - const [filterType, setFilterType] = useState('all'); - const [filterStatus, setFilterStatus] = useState('all'); + const [filters, setFilters] = useState(initialFilters); + const [sort, setSort] = useState(initialSort); + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); + const [showFilters, setShowFilters] = useState(false); const navigate = useNavigate(); + const { toast } = useToast(); useEffect(() => { fetchParks(); - }, [sortBy, filterType, filterStatus]); + }, []); const fetchParks = async () => { try { - let query = supabase + setLoading(true); + const { data, error } = await supabase .from('parks') - .select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`); + .select(` + *, + location:locations(*), + operator:companies!parks_operator_id_fkey(*), + property_owner:companies!parks_property_owner_id_fkey(*) + `) + .order('name'); - // Apply filters - if (filterType !== 'all') { - query = query.eq('park_type', filterType); - } - if (filterStatus !== 'all') { - query = query.eq('status', filterStatus); - } - - // Apply sorting - switch (sortBy) { - case 'rating': - query = query.order('average_rating', { ascending: false }); - break; - case 'rides': - query = query.order('ride_count', { ascending: false }); - break; - case 'reviews': - query = query.order('review_count', { ascending: false }); - break; - default: - query = query.order('name'); - } - - const { data } = await query; + if (error) throw error; setParks(data || []); - } catch (error) { + } catch (error: any) { console.error('Error fetching parks:', error); + toast({ + variant: "destructive", + title: "Error loading parks", + description: error.message, + }); } finally { setLoading(false); } }; - const filteredParks = parks.filter(park => - park.name.toLowerCase().includes(searchQuery.toLowerCase()) || - park.location?.city?.toLowerCase().includes(searchQuery.toLowerCase()) || - park.location?.country?.toLowerCase().includes(searchQuery.toLowerCase()) - ); + const filteredAndSortedParks = useMemo(() => { + let filtered = parks.filter(park => { + // Search filter + if (filters.search) { + const searchTerm = filters.search.toLowerCase(); + const matchesSearch = + park.name.toLowerCase().includes(searchTerm) || + park.description?.toLowerCase().includes(searchTerm) || + park.location?.city?.toLowerCase().includes(searchTerm) || + park.location?.country?.toLowerCase().includes(searchTerm) || + park.location?.state_province?.toLowerCase().includes(searchTerm); + + if (!matchesSearch) return false; + } + + // Park type filter + if (filters.parkType !== 'all' && park.park_type !== filters.parkType) { + return false; + } + + // Status filter + if (filters.status !== 'all' && park.status !== filters.status) { + return false; + } + + // Country filter + if (filters.country !== 'all' && park.location?.country !== filters.country) { + return false; + } + + // Rating filter + const rating = park.average_rating || 0; + if (rating < filters.minRating || rating > filters.maxRating) { + return false; + } + + // Ride count filter + const rideCount = park.ride_count || 0; + if (rideCount < filters.minRides || rideCount > filters.maxRides) { + return false; + } + + // Opening year filter + if (filters.openingYearStart || filters.openingYearEnd) { + const openingYear = park.opening_date ? new Date(park.opening_date).getFullYear() : null; + if (openingYear) { + if (filters.openingYearStart && openingYear < filters.openingYearStart) return false; + if (filters.openingYearEnd && openingYear > filters.openingYearEnd) return false; + } else if (filters.openingYearStart || filters.openingYearEnd) { + return false; + } + } + + return true; + }); + + // Apply sorting + filtered.sort((a, b) => { + let aValue: any, bValue: any; + + switch (sort.field) { + case 'name': + aValue = a.name; + bValue = b.name; + break; + case 'rating': + aValue = a.average_rating || 0; + bValue = b.average_rating || 0; + break; + case 'rides': + aValue = a.ride_count || 0; + bValue = b.ride_count || 0; + break; + case 'coasters': + aValue = a.coaster_count || 0; + bValue = b.coaster_count || 0; + break; + case 'reviews': + aValue = a.review_count || 0; + bValue = b.review_count || 0; + break; + case 'opening': + aValue = a.opening_date ? new Date(a.opening_date).getTime() : 0; + bValue = b.opening_date ? new Date(b.opening_date).getTime() : 0; + break; + default: + aValue = a.name; + bValue = b.name; + } + + if (typeof aValue === 'string' && typeof bValue === 'string') { + const result = aValue.localeCompare(bValue); + return sort.direction === 'asc' ? result : -result; + } + + const result = aValue - bValue; + return sort.direction === 'asc' ? result : -result; + }); + + return filtered; + }, [parks, filters, sort]); + + const activeFilterCount = useMemo(() => { + let count = 0; + if (filters.search) count++; + if (filters.parkType !== 'all') count++; + if (filters.status !== 'all') count++; + if (filters.country !== 'all') count++; + if (filters.minRating > 0 || filters.maxRating < 5) count++; + if (filters.minRides > 0 || filters.maxRides < 1000) count++; + if (filters.openingYearStart || filters.openingYearEnd) count++; + return count; + }, [filters]); + + const clearAllFilters = () => { + setFilters(initialFilters); + setSort(initialSort); + }; const handleParkClick = (park: Park) => { navigate(`/parks/${park.slug}`); }; - const parkTypes = [ - { value: 'all', label: 'All Types' }, - { value: 'theme_park', label: 'Theme Parks' }, - { value: 'amusement_park', label: 'Amusement Parks' }, - { value: 'water_park', label: 'Water Parks' }, - { value: 'family_entertainment', label: 'Family Entertainment' } - ]; - - const statusOptions = [ - { value: 'all', label: 'All Status' }, - { value: 'operating', label: 'Operating' }, - { value: 'seasonal', label: 'Seasonal' }, - { value: 'under_construction', label: 'Under Construction' }, - { value: 'closed', label: 'Closed' } - ]; - if (loading) { return (
@@ -94,8 +233,13 @@ export default function Parks() {
+
+
+
+
+
- {[...Array(8)].map((_, i) => ( + {[...Array(12)].map((_, i) => (
))}
@@ -116,89 +260,118 @@ export default function Parks() {

Theme Parks

-

+

Discover amazing theme parks, amusement parks, and attractions worldwide

-
- {filteredParks.length} parks found - {parks.reduce((sum, park) => sum + park.ride_count, 0)} total rides + +
+
+ + {filteredAndSortedParks.length} parks + + + {parks.reduce((sum, park) => sum + (park.ride_count || 0), 0)} total rides + + + {parks.reduce((sum, park) => sum + (park.coaster_count || 0), 0)} coasters + +
+ + {activeFilterCount > 0 && ( + + )}
- {/* Search and Filters */} -
-
-
- - setSearchQuery(e.target.value)} - className="pl-10" + {/* Search and Controls */} +
+
+
+ setFilters(prev => ({ ...prev, search }))} />
+
- + - + - + setViewMode(v as any)} className="shrink-0"> + + + + + + + + +
+ + {/* Advanced Filters Panel */} + {showFilters && ( + + + + + + )}
- {/* Parks Grid */} - {filteredParks.length > 0 ? ( -
- {filteredParks.map((park) => ( - handleParkClick(park)} + {/* Results */} + {filteredAndSortedParks.length > 0 ? ( +
+ {viewMode === 'grid' ? ( + - ))} + ) : ( + + )}
) : (
- +
🎢

No parks found

-

- Try adjusting your search criteria or filters +

+ Try adjusting your search terms or filters

+
)}