diff --git a/src/App.tsx b/src/App.tsx index 5f3e77b1..ad8b41c1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import ParkOwners from "./pages/ParkOwners"; import Auth from "./pages/Auth"; import Profile from "./pages/Profile"; import UserSettings from "./pages/UserSettings"; +import Search from "./pages/Search"; import NotFound from "./pages/NotFound"; import Terms from "./pages/Terms"; import Privacy from "./pages/Privacy"; @@ -43,6 +44,7 @@ function AppContent() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/search/EnhancedSearchResults.tsx b/src/components/search/EnhancedSearchResults.tsx new file mode 100644 index 00000000..415ad90b --- /dev/null +++ b/src/components/search/EnhancedSearchResults.tsx @@ -0,0 +1,242 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Star, MapPin, Zap, Factory, Clock, Users, Calendar, Ruler, Gauge, Building } from 'lucide-react'; +import { SearchResult } from '@/hooks/useSearch'; + +interface EnhancedSearchResultsProps { + results: SearchResult[]; + loading: boolean; + hasMore?: boolean; + onLoadMore?: () => void; +} + +export function EnhancedSearchResults({ results, loading, hasMore, onLoadMore }: EnhancedSearchResultsProps) { + const navigate = useNavigate(); + + const handleResultClick = (result: SearchResult) => { + switch (result.type) { + case 'park': + navigate(`/parks/${result.slug || result.id}`); + break; + case 'ride': + // Need to get park slug for ride navigation + navigate(`/rides/${result.id}`); + break; + case 'company': + navigate(`/companies/${result.id}`); + break; + } + }; + + const getTypeIcon = (type: string, size = 'w-6 h-6') => { + switch (type) { + case 'park': return ; + case 'ride': return ; + case 'company': return ; + default: return ; + } + }; + + const getTypeColor = (type: string) => { + switch (type) { + case 'park': + return 'bg-primary/10 text-primary border-primary/20'; + case 'ride': + return 'bg-secondary/10 text-secondary border-secondary/20'; + case 'company': + return 'bg-accent/10 text-accent border-accent/20'; + default: + return 'bg-muted text-muted-foreground'; + } + }; + + const renderParkDetails = (result: SearchResult) => { + if (result.type !== 'park') return null; + const parkData = result.data as any; // Type assertion for park-specific properties + + return ( +
+ {parkData?.ride_count && ( +
+ + {parkData.ride_count} rides +
+ )} + {parkData?.opening_date && ( +
+ + Opened {new Date(parkData.opening_date).getFullYear()} +
+ )} + {parkData?.status && ( + + {parkData.status.replace('_', ' ')} + + )} +
+ ); + }; + + const renderRideDetails = (result: SearchResult) => { + if (result.type !== 'ride') return null; + const rideData = result.data as any; // Type assertion for ride-specific properties + + return ( +
+ {rideData?.category && ( + + {rideData.category.replace('_', ' ')} + + )} + {rideData?.max_height_meters && ( +
+ + {rideData.max_height_meters}m +
+ )} + {rideData?.max_speed_kmh && ( +
+ + {rideData.max_speed_kmh} km/h +
+ )} + {rideData?.intensity_level && ( + + {rideData.intensity_level} + + )} +
+ ); + }; + + const renderCompanyDetails = (result: SearchResult) => { + if (result.type !== 'company') return null; + const companyData = result.data as any; // Type assertion for company-specific properties + + return ( +
+ {companyData?.company_type && ( + + {companyData.company_type.replace('_', ' ')} + + )} + {companyData?.founded_year && ( +
+ + Founded {companyData.founded_year} +
+ )} + {companyData?.headquarters_location && ( +
+ + {companyData.headquarters_location} +
+ )} +
+ ); + }; + + if (loading && results.length === 0) { + return ( +
+
+
+ ); + } + + if (results.length === 0) { + return null; + } + + return ( +
+
+ {results.map((result) => ( + handleResultClick(result)} + className="group cursor-pointer hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/50" + > + +
+ {/* Image placeholder or actual image */} +
+ {result.image ? ( + {result.title} + ) : ( +
+ {getTypeIcon(result.type, 'w-8 h-8')} +
+ )} +
+ + {/* Content */} +
+
+
+
+

+ {result.title} +

+ + {result.type} + +
+ +

+ {result.subtitle} +

+ + {/* Type-specific details */} + {result.type === 'park' && renderParkDetails(result)} + {result.type === 'ride' && renderRideDetails(result)} + {result.type === 'company' && renderCompanyDetails(result)} +
+ + {/* Rating */} + {result.rating && result.rating > 0 && ( +
+ + {result.rating.toFixed(1)} + {result.data?.review_count && ( + + ({result.data.review_count}) + + )} +
+ )} +
+
+
+
+
+ ))} +
+ + {/* Load More Button */} + {hasMore && ( +
+ +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/search/SearchFilters.tsx b/src/components/search/SearchFilters.tsx new file mode 100644 index 00000000..cc3df390 --- /dev/null +++ b/src/components/search/SearchFilters.tsx @@ -0,0 +1,387 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Slider } from '@/components/ui/slider'; +import { Badge } from '@/components/ui/badge'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { ChevronDown, Filter, X } from 'lucide-react'; + +export interface SearchFilters { + // Park filters + parkType?: string; + country?: string; + stateProvince?: string; + status?: string; + openingYearMin?: number; + openingYearMax?: number; + ratingMin?: number; + ratingMax?: number; + rideCountMin?: number; + rideCountMax?: number; + + // Ride filters + rideCategory?: string; + rideType?: string; + manufacturer?: string; + heightMin?: number; + heightMax?: number; + speedMin?: number; + speedMax?: number; + intensityLevel?: string; + + // Company filters + companyType?: string; + foundedYearMin?: number; + foundedYearMax?: number; + headquarters?: string; +} + +interface SearchFiltersProps { + filters: SearchFilters; + onFiltersChange: (filters: SearchFilters) => void; + activeTab: string; +} + +export function SearchFiltersComponent({ filters, onFiltersChange, activeTab }: SearchFiltersProps) { + const [isOpen, setIsOpen] = useState(false); + + const updateFilter = (key: keyof SearchFilters, value: any) => { + onFiltersChange({ ...filters, [key]: value }); + }; + + const clearFilters = () => { + onFiltersChange({}); + }; + + const hasActiveFilters = Object.values(filters).some(value => value !== undefined && value !== ''); + + const currentYear = new Date().getFullYear(); + + return ( + + + +
setIsOpen(!isOpen)}> +
+ + Filters + {hasActiveFilters && ( + + Active + + )} +
+
+ {hasActiveFilters && ( + + )} + +
+
+
+
+ + + + + {/* Park Filters */} + {(activeTab === 'all' || activeTab === 'park') && ( +
+

Park Filters

+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + updateFilter('country', e.target.value || undefined)} + /> +
+ +
+ + updateFilter('stateProvince', e.target.value || undefined)} + /> +
+
+ +
+
+ +
+ updateFilter('openingYearMin', e.target.value ? parseInt(e.target.value) : undefined)} + /> + updateFilter('openingYearMax', e.target.value ? parseInt(e.target.value) : undefined)} + /> +
+
+ +
+
+ + + {filters.ratingMin || 0} - {filters.ratingMax || 5} + +
+ { + updateFilter('ratingMin', min); + updateFilter('ratingMax', max); + }} + min={0} + max={5} + step={0.1} + className="w-full" + /> +
+ +
+
+ + + {filters.rideCountMin || 0} - {filters.rideCountMax || 100}+ + +
+ { + updateFilter('rideCountMin', min); + updateFilter('rideCountMax', max); + }} + min={0} + max={100} + step={1} + className="w-full" + /> +
+
+
+ )} + + {/* Ride Filters */} + {(activeTab === 'all' || activeTab === 'ride') && ( +
+

Ride Filters

+ +
+
+ + +
+ +
+ + +
+
+ +
+
+
+ + + {filters.heightMin || 0}m - {filters.heightMax || 200}m + +
+ { + updateFilter('heightMin', min); + updateFilter('heightMax', max); + }} + min={0} + max={200} + step={1} + className="w-full" + /> +
+ +
+
+ + + {filters.speedMin || 0} - {filters.speedMax || 200}+ km/h + +
+ { + updateFilter('speedMin', min); + updateFilter('speedMax', max); + }} + min={0} + max={200} + step={5} + className="w-full" + /> +
+
+
+ )} + + {/* Company Filters */} + {(activeTab === 'all' || activeTab === 'company') && ( +
+

Company Filters

+ +
+
+ + +
+ +
+ + updateFilter('headquarters', e.target.value || undefined)} + /> +
+
+ +
+ +
+ updateFilter('foundedYearMin', e.target.value ? parseInt(e.target.value) : undefined)} + /> + updateFilter('foundedYearMax', e.target.value ? parseInt(e.target.value) : undefined)} + /> +
+
+
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/search/SearchSortOptions.tsx b/src/components/search/SearchSortOptions.tsx new file mode 100644 index 00000000..54dc4652 --- /dev/null +++ b/src/components/search/SearchSortOptions.tsx @@ -0,0 +1,99 @@ +import { SortAsc, SortDesc, ArrowUpDown } from 'lucide-react'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Button } from '@/components/ui/button'; + +export interface SortOption { + field: string; + direction: 'asc' | 'desc'; +} + +interface SearchSortOptionsProps { + sort: SortOption; + onSortChange: (sort: SortOption) => void; + activeTab: string; +} + +export function SearchSortOptions({ sort, onSortChange, activeTab }: SearchSortOptionsProps) { + const getSortOptions = () => { + const commonOptions = [ + { value: 'relevance', label: 'Relevance' }, + { value: 'name', label: 'Name' }, + { value: 'rating', label: 'Rating' }, + { value: 'reviews', label: 'Review Count' }, + ]; + + const parkOptions = [ + ...commonOptions, + { value: 'rides', label: 'Ride Count' }, + { value: 'coasters', label: 'Coaster Count' }, + { value: 'opening', label: 'Opening Date' }, + ]; + + const rideOptions = [ + ...commonOptions, + { value: 'height', label: 'Height' }, + { value: 'speed', label: 'Speed' }, + { value: 'opening', label: 'Opening Date' }, + { value: 'intensity', label: 'Intensity' }, + ]; + + const companyOptions = [ + ...commonOptions.filter(opt => opt.value !== 'rating'), // Companies might not have ratings + { value: 'founded', label: 'Founded Year' }, + { value: 'parks', label: 'Parks Count' }, + { value: 'rides', label: 'Rides Count' }, + ]; + + switch (activeTab) { + case 'park': + return parkOptions; + case 'ride': + return rideOptions; + case 'company': + return companyOptions; + default: + return commonOptions; + } + }; + + const toggleDirection = () => { + onSortChange({ + ...sort, + direction: sort.direction === 'asc' ? 'desc' : 'asc' + }); + }; + + return ( +
+ + + +
+ ); +} \ No newline at end of file diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx index 65e740cd..97c05558 100644 --- a/src/pages/Search.tsx +++ b/src/pages/Search.tsx @@ -1,12 +1,13 @@ import { useState, useEffect } from 'react'; import { useSearchParams, useNavigate } from 'react-router-dom'; import { Header } from '@/components/layout/Header'; -import { Card, CardContent } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Search, MapPin, Zap, Star, Castle, FerrisWheel, Factory } from 'lucide-react'; +import { Search, Filter, SlidersHorizontal } from 'lucide-react'; import { AutocompleteSearch } from '@/components/search/AutocompleteSearch'; +import { SearchFiltersComponent, SearchFilters } from '@/components/search/SearchFilters'; +import { SearchSortOptions, SortOption } from '@/components/search/SearchSortOptions'; +import { EnhancedSearchResults } from '@/components/search/EnhancedSearchResults'; import { useSearch, SearchResult } from '@/hooks/useSearch'; export default function SearchPage() { @@ -15,6 +16,10 @@ export default function SearchPage() { const initialQuery = searchParams.get('q') || ''; const [activeTab, setActiveTab] = useState('all'); + const [filters, setFilters] = useState({}); + const [sort, setSort] = useState({ field: 'relevance', direction: 'desc' }); + const [showFilters, setShowFilters] = useState(false); + const { query, setQuery, @@ -32,9 +37,59 @@ export default function SearchPage() { } }, [initialQuery, setQuery]); - const filteredResults = results.filter(result => - activeTab === 'all' || result.type === activeTab - ); + // Filter and sort results + const filteredAndSortedResults = (() => { + let filtered = results.filter(result => + activeTab === 'all' || result.type === activeTab + ); + + // Apply filters + if (filters.country) { + filtered = filtered.filter(result => + result.subtitle?.toLowerCase().includes(filters.country!.toLowerCase()) + ); + } + + if (filters.stateProvince) { + filtered = filtered.filter(result => + result.subtitle?.toLowerCase().includes(filters.stateProvince!.toLowerCase()) + ); + } + + if (filters.ratingMin !== undefined || filters.ratingMax !== undefined) { + filtered = filtered.filter(result => { + if (!result.rating) return false; + const rating = result.rating; + const min = filters.ratingMin ?? 0; + const max = filters.ratingMax ?? 5; + return rating >= min && rating <= max; + }); + } + + // Sort results + filtered.sort((a, b) => { + const direction = sort.direction === 'asc' ? 1 : -1; + + switch (sort.field) { + case 'name': + return direction * a.title.localeCompare(b.title); + case 'rating': + return direction * ((b.rating || 0) - (a.rating || 0)); + case 'reviews': + return direction * (((b.data as any)?.review_count || 0) - ((a.data as any)?.review_count || 0)); + case 'rides': + return direction * (((b.data as any)?.ride_count || 0) - ((a.data as any)?.ride_count || 0)); + case 'opening': + const aDate = (a.data as any)?.opening_date ? new Date((a.data as any).opening_date).getTime() : 0; + const bDate = (b.data as any)?.opening_date ? new Date((b.data as any).opening_date).getTime() : 0; + return direction * (bDate - aDate); + default: // relevance + return 0; // Keep original order for relevance + } + }); + + return filtered; + })(); const resultCounts = { all: results.length, @@ -43,42 +98,6 @@ export default function SearchPage() { company: results.filter(r => r.type === 'company').length }; - const handleResultClick = (result: SearchResult) => { - switch (result.type) { - case 'park': - navigate(`/parks/${result.slug || result.id}`); - break; - case 'ride': - navigate(`/rides/${result.id}`); - break; - case 'company': - navigate(`/companies/${result.id}`); - break; - } - }; - - const getTypeIcon = (type: string) => { - switch (type) { - case 'park': return ; - case 'ride': return ; - case 'company': return ; - default: return ; - } - }; - - const getTypeColor = (type: string) => { - switch (type) { - case 'park': - return 'bg-primary/10 text-primary border-primary/20'; - case 'ride': - return 'bg-secondary/10 text-secondary border-secondary/20'; - case 'company': - return 'bg-accent/10 text-accent border-accent/20'; - default: - return 'bg-muted text-muted-foreground'; - } - }; - return (
@@ -88,12 +107,12 @@ export default function SearchPage() {
-

Search Results

+

Search

{query && (

- Results for "{query}" + Showing {filteredAndSortedResults.length} results for "{query}"

)} @@ -112,109 +131,95 @@ export default function SearchPage() {
- {/* Results */} + {/* Results Section */} {query && ( - <> - {/* Filter Tabs */} - - - - All ({resultCounts.all}) - - - Parks ({resultCounts.park}) - - - Rides ({resultCounts.ride}) - - - Companies ({resultCounts.company}) - - - +
+ {/* Tabs and Controls */} +
+ + + + All ({resultCounts.all}) + + + Parks ({resultCounts.park}) + + + Rides ({resultCounts.ride}) + + + Companies ({resultCounts.company}) + + + - {/* Loading State */} - {loading && ( -
-
-
- )} - - {/* Results Grid */} - {!loading && filteredResults.length > 0 && ( -
- {filteredResults.map((result) => ( - handleResultClick(result)} - className="group cursor-pointer hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/50" - > - -
-
{getTypeIcon(result.type)}
-
-
-

- {result.title} -

- - {result.type} - -
- -

- {result.subtitle} -

- -
-
- {result.type === 'park' && ( -
- -
- )} - {result.type === 'ride' && ( -
- -
- )} -
- - {result.rating && result.rating > 0 && ( -
- - {result.rating.toFixed(1)} -
- )} -
-
-
-
-
- ))} -
- )} - - {/* No Results */} - {!loading && query && filteredResults.length === 0 && ( -
- -

No results found

-

- Try searching for something else or adjust your search terms -

- +
- )} - +
+ + {/* Layout Grid */} +
+ {/* Filters Sidebar */} + {showFilters && ( +
+ +
+ )} + + {/* Results */} +
+ {!loading && filteredAndSortedResults.length === 0 && query && ( +
+ +

No results found

+

+ Try adjusting your search terms or filters +

+
+ + +
+
+ )} + + +
+
+
)} {/* Initial State */}