diff --git a/src/components/homepage/SimpleHeroSearch.tsx b/src/components/homepage/SimpleHeroSearch.tsx index 36051833..94d688be 100644 --- a/src/components/homepage/SimpleHeroSearch.tsx +++ b/src/components/homepage/SimpleHeroSearch.tsx @@ -1,13 +1,8 @@ -import { useState } from 'react'; -import { Search } from 'lucide-react'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; +import { AutocompleteSearch } from '@/components/search/AutocompleteSearch'; + export function SimpleHeroSearch() { - const [searchTerm, setSearchTerm] = useState(''); - const handleSearch = () => { - console.log('Searching for:', searchTerm); - }; - return
+ return ( +

@@ -20,17 +15,18 @@ export function SimpleHeroSearch() { The ultimate theme park database. Discover parks, track rides, and connect with enthusiasts.

- {/* Simple Search */} + {/* Modern Autocomplete Search */}
-
- - setSearchTerm(e.target.value)} className="pl-12 pr-24 h-14 text-lg bg-background border-border rounded-full shadow-lg" onKeyDown={e => e.key === 'Enter' && handleSearch()} /> - -
+

-
; +
+ ); } \ No newline at end of file diff --git a/src/components/parks/ParkSearch.tsx b/src/components/parks/ParkSearch.tsx index 2115f7cb..7dd1ed4b 100644 --- a/src/components/parks/ParkSearch.tsx +++ b/src/components/parks/ParkSearch.tsx @@ -1,38 +1,28 @@ -import { useState } from 'react'; -import { Search, X } from 'lucide-react'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; +import { AutocompleteSearch } from '@/components/search/AutocompleteSearch'; +import { SearchResult } from '@/hooks/useSearch'; interface ParkSearchProps { value: string; onChange: (value: string) => void; placeholder?: string; + onResultSelect?: (result: SearchResult) => void; } export function ParkSearch({ value, onChange, - placeholder = "Search parks, locations, descriptions..." + placeholder = "Search parks, locations, descriptions...", + onResultSelect }: ParkSearchProps) { return ( -
- - onChange(e.target.value)} - className="pl-10 pr-10 bg-muted/50 border-border/50 focus:border-primary/50 transition-colors" - /> - {value && ( - - )} -
+ onChange(query)} + onResultSelect={onResultSelect} + showRecentSearches={false} + className="w-full" + /> ); } \ No newline at end of file diff --git a/src/components/search/AutocompleteSearch.tsx b/src/components/search/AutocompleteSearch.tsx new file mode 100644 index 00000000..d2139615 --- /dev/null +++ b/src/components/search/AutocompleteSearch.tsx @@ -0,0 +1,310 @@ +import { useRef, useEffect, useState } from 'react'; +import { Search, X, Clock, Zap } from 'lucide-react'; +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 { useSearch, SearchResult } from '@/hooks/useSearch'; +import { useNavigate } from 'react-router-dom'; + +interface AutocompleteSearchProps { + onResultSelect?: (result: SearchResult) => void; + onSearch?: (query: string) => void; + placeholder?: string; + className?: string; + types?: ('park' | 'ride' | 'company')[]; + limit?: number; + showRecentSearches?: boolean; + variant?: 'default' | 'hero'; +} + +export function AutocompleteSearch({ + onResultSelect, + onSearch, + placeholder = "Search parks, rides, or companies...", + className = "", + types = ['park', 'ride', 'company'], + limit = 8, + showRecentSearches = true, + variant = 'default' +}: AutocompleteSearchProps) { + const navigate = useNavigate(); + const searchRef = useRef(null); + const inputRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + + const { + query, + setQuery, + results, + suggestions, + loading, + saveSearch, + clearRecentSearches + } = useSearch({ types, limit }); + + const displayItems = query.length > 0 ? results : (showRecentSearches ? suggestions : []); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (searchRef.current && !searchRef.current.contains(event.target as Node)) { + setIsOpen(false); + setSelectedIndex(-1); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Handle keyboard navigation + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!isOpen) return; + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setSelectedIndex(prev => Math.min(prev + 1, displayItems.length - 1)); + break; + case 'ArrowUp': + event.preventDefault(); + setSelectedIndex(prev => Math.max(prev - 1, -1)); + break; + case 'Enter': + event.preventDefault(); + if (selectedIndex >= 0 && displayItems[selectedIndex]) { + handleResultClick(displayItems[selectedIndex]); + } else if (query) { + handleSearch(); + } + break; + case 'Escape': + setIsOpen(false); + setSelectedIndex(-1); + inputRef.current?.blur(); + break; + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, selectedIndex, displayItems, query]); + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setQuery(value); + setIsOpen(true); + setSelectedIndex(-1); + }; + + const handleInputFocus = () => { + setIsOpen(true); + }; + + const handleResultClick = (result: SearchResult | { id: string; type: string; title: string; subtitle: string; data: any }) => { + if (result.type === 'suggestion') { + setQuery(result.title); + setIsOpen(false); + return; + } + + const searchResult = result as SearchResult; + saveSearch(searchResult.title); + + if (onResultSelect) { + onResultSelect(searchResult); + } else { + // Default navigation + switch (searchResult.type) { + case 'park': + navigate(`/parks/${searchResult.slug || searchResult.id}`); + break; + case 'ride': + navigate(`/rides/${searchResult.id}`); + break; + case 'company': + navigate(`/companies/${searchResult.id}`); + break; + } + } + + setIsOpen(false); + setSelectedIndex(-1); + }; + + const handleSearch = () => { + if (!query.trim()) return; + + saveSearch(query); + + if (onSearch) { + onSearch(query); + } else { + // Default search behavior - navigate to search results + navigate(`/search?q=${encodeURIComponent(query)}`); + } + + setIsOpen(false); + setSelectedIndex(-1); + }; + + const handleClear = () => { + setQuery(''); + setIsOpen(false); + setSelectedIndex(-1); + inputRef.current?.focus(); + }; + + const getResultIcon = (result: SearchResult) => { + switch (result.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 isHero = variant === 'hero'; + + return ( +
+
+ + e.key === 'Enter' && handleSearch()} + className={`${isHero ? 'pl-12 pr-24 h-14 text-lg rounded-full' : 'pl-10 pr-10'} bg-background/95 backdrop-blur border-border/50 focus:border-primary/50 transition-all duration-300 ${ + isOpen ? 'shadow-lg shadow-primary/10' : '' + }`} + /> + {query && ( + + )} + {isHero && ( + + )} +
+ + {isOpen && displayItems.length > 0 && ( +
+
+ {query.length === 0 && showRecentSearches && suggestions.length > 0 && ( + <> +
+ Recent searches + +
+ + + )} + + {displayItems.map((item, index) => ( +
handleResultClick(item)} + className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors ${ + index === selectedIndex + ? 'bg-accent/10 border border-accent/20' + : 'hover:bg-muted/50' + }`} + > + {item.type === 'suggestion' ? ( + <> + +
+
{item.title}
+
+ + ) : ( + <> +
{getResultIcon(item as SearchResult)}
+
+
+ {item.title} + + {item.type} + +
+
+ {item.subtitle} +
+
+ {(item as SearchResult).rating && (item as SearchResult).rating! > 0 && ( +
+ + {(item as SearchResult).rating!.toFixed(1)} +
+ )} + + )} +
+ ))} + + {loading && ( +
+
+
+ )} + + {query.length > 0 && results.length > 0 && ( + <> + + + + )} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/hooks/useSearch.tsx b/src/hooks/useSearch.tsx new file mode 100644 index 00000000..686e601d --- /dev/null +++ b/src/hooks/useSearch.tsx @@ -0,0 +1,204 @@ +import { useState, useEffect, useMemo } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { Park, Ride, Company } from '@/types/database'; + +export interface SearchResult { + id: string; + type: 'park' | 'ride' | 'company'; + title: string; + subtitle: string; + image?: string; + rating?: number; + slug?: string; + data: Park | Ride | Company; +} + +interface UseSearchOptions { + types?: ('park' | 'ride' | 'company')[]; + limit?: number; + minQuery?: number; + debounceMs?: number; +} + +export function useSearch(options: UseSearchOptions = {}) { + const { + types = ['park', 'ride', 'company'], + limit = 10, + minQuery = 2, + debounceMs = 300 + } = options; + + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [recentSearches, setRecentSearches] = useState([]); + + // Debounced query + const [debouncedQuery, setDebouncedQuery] = useState(''); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(query); + }, debounceMs); + + return () => clearTimeout(timer); + }, [query, debounceMs]); + + // Load recent searches from localStorage + useEffect(() => { + const stored = localStorage.getItem('thrillwiki_recent_searches'); + if (stored) { + setRecentSearches(JSON.parse(stored)); + } + }, []); + + // Search function + const search = async (searchQuery: string) => { + if (searchQuery.length < minQuery) { + setResults([]); + return; + } + + setLoading(true); + try { + const searchResults: SearchResult[] = []; + + // Search parks + if (types.includes('park')) { + const { data: parks } = await supabase + .from('parks') + .select(` + *, + location:locations(*), + operator:companies!parks_operator_id_fkey(*) + `) + .or(`name.ilike.%${searchQuery}%,description.ilike.%${searchQuery}%`) + .limit(Math.ceil(limit / types.length)); + + parks?.forEach((park) => { + searchResults.push({ + id: park.id, + type: 'park', + title: park.name, + subtitle: `${park.location?.city || ''}, ${park.location?.country || ''}`.replace(/^, |, $/, ''), + image: park.banner_image_url || park.card_image_url, + rating: park.average_rating, + slug: park.slug, + data: park + }); + }); + } + + // Search rides + if (types.includes('ride')) { + const { data: rides } = await supabase + .from('rides') + .select(` + *, + park:parks!inner(name, slug), + manufacturer:companies!rides_manufacturer_id_fkey(*) + `) + .or(`name.ilike.%${searchQuery}%,description.ilike.%${searchQuery}%`) + .limit(Math.ceil(limit / types.length)); + + rides?.forEach((ride) => { + searchResults.push({ + id: ride.id, + type: 'ride', + title: ride.name, + subtitle: `at ${ride.park?.name || 'Unknown Park'}`, + image: ride.image_url, + rating: ride.average_rating, + data: ride + }); + }); + } + + // Search companies + if (types.includes('company')) { + const { data: companies } = await supabase + .from('companies') + .select('*') + .or(`name.ilike.%${searchQuery}%,description.ilike.%${searchQuery}%`) + .limit(Math.ceil(limit / types.length)); + + companies?.forEach((company) => { + searchResults.push({ + id: company.id, + type: 'company', + title: company.name, + subtitle: company.company_type?.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()) || 'Company', + image: company.logo_url, + data: company + }); + }); + } + + // Sort by relevance (exact matches first, then partial matches) + searchResults.sort((a, b) => { + const aExact = a.title.toLowerCase().startsWith(searchQuery.toLowerCase()); + const bExact = b.title.toLowerCase().startsWith(searchQuery.toLowerCase()); + + if (aExact && !bExact) return -1; + if (!aExact && bExact) return 1; + + return a.title.localeCompare(b.title); + }); + + setResults(searchResults.slice(0, limit)); + } catch (error) { + console.error('Search error:', error); + setResults([]); + } finally { + setLoading(false); + } + }; + + // Effect for debounced search + useEffect(() => { + if (debouncedQuery) { + search(debouncedQuery); + } else { + setResults([]); + } + }, [debouncedQuery]); + + // Save search to recent searches + const saveSearch = (searchQuery: string) => { + if (!searchQuery.trim()) return; + + const updated = [searchQuery, ...recentSearches.filter(s => s !== searchQuery)].slice(0, 5); + setRecentSearches(updated); + localStorage.setItem('thrillwiki_recent_searches', JSON.stringify(updated)); + }; + + // Clear recent searches + const clearRecentSearches = () => { + setRecentSearches([]); + localStorage.removeItem('thrillwiki_recent_searches'); + }; + + // Get suggestions (recent searches when no query) + const suggestions = useMemo(() => { + if (query.length > 0) return []; + return recentSearches.map(search => ({ + id: search, + type: 'suggestion' as const, + title: search, + subtitle: 'Recent search', + data: null + })); + }, [query, recentSearches]); + + return { + query, + setQuery, + results, + suggestions, + loading, + recentSearches, + saveSearch, + clearRecentSearches, + search + }; +} \ No newline at end of file diff --git a/src/pages/Rides.tsx b/src/pages/Rides.tsx index 054cf4e5..bc06a04b 100644 --- a/src/pages/Rides.tsx +++ b/src/pages/Rides.tsx @@ -1,11 +1,11 @@ import { useState, useEffect } from 'react'; import { Header } from '@/components/layout/Header'; import { Card, CardContent } 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, Zap, Clock, Star } from 'lucide-react'; +import { Filter, SlidersHorizontal, Zap, Clock, Star } from 'lucide-react'; +import { AutocompleteSearch } from '@/components/search/AutocompleteSearch'; import { Ride } from '@/types/database'; import { supabase } from '@/integrations/supabase/client'; import { useNavigate } from 'react-router-dom'; @@ -160,13 +160,13 @@ export default function Rides() { {/* Search and Filters */}
-
- - + setSearchQuery(e.target.value)} - className="pl-10" + types={['ride']} + limit={8} + onSearch={(query) => setSearchQuery(query)} + showRecentSearches={false} />
diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx new file mode 100644 index 00000000..af9b1cb6 --- /dev/null +++ b/src/pages/Search.tsx @@ -0,0 +1,233 @@ +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 } from 'lucide-react'; +import { AutocompleteSearch } from '@/components/search/AutocompleteSearch'; +import { useSearch, SearchResult } from '@/hooks/useSearch'; + +export default function SearchPage() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const initialQuery = searchParams.get('q') || ''; + + const [activeTab, setActiveTab] = useState('all'); + const { + query, + setQuery, + results, + loading, + search + } = useSearch({ + types: ['park', 'ride', 'company'], + limit: 50 + }); + + useEffect(() => { + if (initialQuery) { + setQuery(initialQuery); + } + }, [initialQuery, setQuery]); + + const filteredResults = results.filter(result => + activeTab === 'all' || result.type === activeTab + ); + + const resultCounts = { + all: results.length, + park: results.filter(r => r.type === 'park').length, + ride: results.filter(r => r.type === 'ride').length, + 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 ( +
+
+ +
+ {/* Search Header */} +
+
+ +

Search Results

+
+ + {query && ( +

+ Results for "{query}" +

+ )} + + {/* Search Bar */} +
+ { + const params = new URLSearchParams(); + params.set('q', newQuery); + navigate(`/search?${params.toString()}`, { replace: true }); + }} + /> +
+
+ + {/* Results */} + {query && ( + <> + {/* Filter Tabs */} + + + + 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 +

+ +
+ )} + + )} + + {/* Initial State */} + {!query && ( +
+
🔍
+

Start your search

+

+ Search for theme parks, rides, or companies to get started +

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