diff --git a/src/components/search/AdvancedRideFilters.tsx b/src/components/search/AdvancedRideFilters.tsx new file mode 100644 index 00000000..e4bb6633 --- /dev/null +++ b/src/components/search/AdvancedRideFilters.tsx @@ -0,0 +1,277 @@ +import { useState } from 'react'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { ChevronDown, Plus, X } from 'lucide-react'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; + +export interface TechnicalSpecFilter { + spec_name: string; + spec_value?: string; + min_value?: number; + max_value?: number; + operator: 'equals' | 'contains' | 'range' | 'has_spec'; +} + +export interface CoasterStatFilter { + stat_name: string; + min_value?: number; + max_value?: number; +} + +interface AdvancedRideFiltersProps { + technicalSpecFilters: TechnicalSpecFilter[]; + coasterStatFilters: CoasterStatFilter[]; + onTechnicalSpecFiltersChange: (filters: TechnicalSpecFilter[]) => void; + onCoasterStatFiltersChange: (filters: CoasterStatFilter[]) => void; +} + +const COMMON_TECH_SPECS = [ + 'Track Material', + 'Manufacturer Model', + 'Train Configuration', + 'Launch System', + 'Braking System', + 'Control System', + 'Safety System', +]; + +const COMMON_COASTER_STATS = [ + 'Max Speed', + 'Height', + 'Drop Height', + 'Length', + 'Duration', + 'Inversions', + 'G-Force', + 'Capacity', +]; + +export function AdvancedRideFilters({ + technicalSpecFilters, + coasterStatFilters, + onTechnicalSpecFiltersChange, + onCoasterStatFiltersChange, +}: AdvancedRideFiltersProps) { + const [isSpecsOpen, setIsSpecsOpen] = useState(false); + const [isStatsOpen, setIsStatsOpen] = useState(false); + + const addTechnicalSpecFilter = () => { + onTechnicalSpecFiltersChange([ + ...technicalSpecFilters, + { spec_name: '', operator: 'has_spec' }, + ]); + }; + + const updateTechnicalSpecFilter = (index: number, updates: Partial) => { + const updated = [...technicalSpecFilters]; + updated[index] = { ...updated[index], ...updates }; + onTechnicalSpecFiltersChange(updated); + }; + + const removeTechnicalSpecFilter = (index: number) => { + onTechnicalSpecFiltersChange(technicalSpecFilters.filter((_, i) => i !== index)); + }; + + const addCoasterStatFilter = () => { + onCoasterStatFiltersChange([ + ...coasterStatFilters, + { stat_name: '' }, + ]); + }; + + const updateCoasterStatFilter = (index: number, updates: Partial) => { + const updated = [...coasterStatFilters]; + updated[index] = { ...updated[index], ...updates }; + onCoasterStatFiltersChange(updated); + }; + + const removeCoasterStatFilter = (index: number) => { + onCoasterStatFiltersChange(coasterStatFilters.filter((_, i) => i !== index)); + }; + + return ( +
+ {/* Technical Specifications Filters */} + + +
+
+

Technical Specifications

+ {technicalSpecFilters.length > 0 && ( + {technicalSpecFilters.length} + )} +
+ +
+
+ + + {technicalSpecFilters.map((filter, index) => ( +
+
+
+
+ + +
+ +
+ + +
+
+ +
+ + {(filter.operator === 'equals' || filter.operator === 'contains') && ( +
+ + updateTechnicalSpecFilter(index, { spec_value: e.target.value })} + placeholder="Enter value to match" + /> +
+ )} +
+ ))} + + +
+
+ + {/* Coaster Statistics Filters */} + + +
+
+

Coaster Statistics

+ {coasterStatFilters.length > 0 && ( + {coasterStatFilters.length} + )} +
+ +
+
+ + + {coasterStatFilters.map((filter, index) => ( +
+
+
+
+ + +
+ +
+
+ + + updateCoasterStatFilter(index, { + min_value: e.target.value ? parseFloat(e.target.value) : undefined, + }) + } + placeholder="Min" + /> +
+
+ + + updateCoasterStatFilter(index, { + max_value: e.target.value ? parseFloat(e.target.value) : undefined, + }) + } + placeholder="Max" + /> +
+
+
+ +
+
+ ))} + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/search/SearchFilters.tsx b/src/components/search/SearchFilters.tsx index ef4f924e..df9f6a56 100644 --- a/src/components/search/SearchFilters.tsx +++ b/src/components/search/SearchFilters.tsx @@ -11,6 +11,7 @@ import { ChevronDown, Filter, X } from 'lucide-react'; import { Combobox } from '@/components/ui/combobox'; import { useCountries, useStatesProvinces, useManufacturers, useCompanyHeadquarters } from '@/hooks/useAutocompleteData'; import { DateRangePicker, DateRange } from '@/components/ui/date-range-picker'; +import { AdvancedRideFilters, TechnicalSpecFilter, CoasterStatFilter } from './AdvancedRideFilters'; export interface SearchFilters { // Park filters @@ -36,6 +37,8 @@ export interface SearchFilters { speedMax?: number; intensityLevel?: string; rideDateRange?: DateRange; + technicalSpecFilters?: TechnicalSpecFilter[]; + coasterStatFilters?: CoasterStatFilter[]; // Company filters companyType?: string; @@ -391,6 +394,16 @@ export function SearchFiltersComponent({ filters, onFiltersChange, activeTab }: /> + + {/* Advanced Ride Filters */} +
+ updateFilter('technicalSpecFilters', filters)} + onCoasterStatFiltersChange={(filters) => updateFilter('coasterStatFilters', filters)} + /> +
)} diff --git a/src/hooks/useAdvancedRideSearch.ts b/src/hooks/useAdvancedRideSearch.ts new file mode 100644 index 00000000..2cb7bb07 --- /dev/null +++ b/src/hooks/useAdvancedRideSearch.ts @@ -0,0 +1,177 @@ +import { useState, useEffect } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { TechnicalSpecFilter, CoasterStatFilter } from '@/components/search/AdvancedRideFilters'; +import { useDebounce } from './useDebounce'; + +interface AdvancedSearchOptions { + query?: string; + category?: string; + manufacturer?: string; + technicalSpecFilters?: TechnicalSpecFilter[]; + coasterStatFilters?: CoasterStatFilter[]; + speedMin?: number; + speedMax?: number; + heightMin?: number; + heightMax?: number; + limit?: number; +} + +export function useAdvancedRideSearch(options: AdvancedSearchOptions) { + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const debouncedQuery = useDebounce(options.query || '', 300); + + useEffect(() => { + const performSearch = async () => { + if (!debouncedQuery && !options.category && !options.manufacturer && + (!options.technicalSpecFilters || options.technicalSpecFilters.length === 0) && + (!options.coasterStatFilters || options.coasterStatFilters.length === 0)) { + setResults([]); + return; + } + + setLoading(true); + setError(null); + + try { + let query = supabase + .from('rides') + .select(` + *, + parks!inner(id, name, slug), + companies!rides_manufacturer_id_fkey(id, name, slug) + `); + + // Basic text search + if (debouncedQuery) { + query = query.or(`name.ilike.%${debouncedQuery}%,description.ilike.%${debouncedQuery}%`); + } + + // Category filter + if (options.category) { + query = query.eq('category', options.category); + } + + // Manufacturer filter + if (options.manufacturer) { + query = query.eq('manufacturer_id', options.manufacturer); + } + + // Speed range filter + if (options.speedMin !== undefined) { + query = query.gte('max_speed_kmh', options.speedMin); + } + if (options.speedMax !== undefined) { + query = query.lte('max_speed_kmh', options.speedMax); + } + + // Height range filter + if (options.heightMin !== undefined) { + query = query.gte('max_height_meters', options.heightMin); + } + if (options.heightMax !== undefined) { + query = query.lte('max_height_meters', options.heightMax); + } + + query = query.limit(options.limit || 50); + + const { data: initialResults, error: queryError } = await query; + + if (queryError) throw queryError; + + let filteredResults = initialResults || []; + + // Apply technical specification filters + if (options.technicalSpecFilters && options.technicalSpecFilters.length > 0) { + for (const filter of options.technicalSpecFilters) { + if (!filter.spec_name) continue; + + const { data: specsData, error: specsError } = await supabase + .from('ride_technical_specifications') + .select('ride_id, spec_name, spec_value') + .eq('spec_name', filter.spec_name); + + if (specsError) throw specsError; + + const matchingRideIds = new Set(); + + specsData?.forEach((spec) => { + let matches = false; + + switch (filter.operator) { + case 'has_spec': + matches = true; + break; + case 'equals': + matches = spec.spec_value === filter.spec_value; + break; + case 'contains': + matches = filter.spec_value ? + spec.spec_value.toLowerCase().includes(filter.spec_value.toLowerCase()) : + false; + break; + } + + if (matches) { + matchingRideIds.add(spec.ride_id); + } + }); + + filteredResults = filteredResults.filter((ride) => matchingRideIds.has(ride.id)); + } + } + + // Apply coaster statistics filters + if (options.coasterStatFilters && options.coasterStatFilters.length > 0) { + for (const filter of options.coasterStatFilters) { + if (!filter.stat_name) continue; + + let statsQuery = supabase + .from('ride_coaster_statistics') + .select('ride_id, stat_name, stat_value') + .eq('stat_name', filter.stat_name); + + if (filter.min_value !== undefined) { + statsQuery = statsQuery.gte('stat_value', filter.min_value); + } + if (filter.max_value !== undefined) { + statsQuery = statsQuery.lte('stat_value', filter.max_value); + } + + const { data: statsData, error: statsError } = await statsQuery; + + if (statsError) throw statsError; + + const matchingRideIds = new Set(statsData?.map((stat) => stat.ride_id) || []); + filteredResults = filteredResults.filter((ride) => matchingRideIds.has(ride.id)); + } + } + + setResults(filteredResults); + } catch (err) { + console.error('Advanced search error:', err); + setError(err instanceof Error ? err.message : 'Search failed'); + setResults([]); + } finally { + setLoading(false); + } + }; + + performSearch(); + }, [ + debouncedQuery, + options.category, + options.manufacturer, + options.speedMin, + options.speedMax, + options.heightMin, + options.heightMax, + options.limit, + JSON.stringify(options.technicalSpecFilters), + JSON.stringify(options.coasterStatFilters), + ]); + + return { results, loading, error }; +} \ No newline at end of file diff --git a/src/lib/entityTransformers.ts b/src/lib/entityTransformers.ts index fee57efc..083df374 100644 --- a/src/lib/entityTransformers.ts +++ b/src/lib/entityTransformers.ts @@ -36,43 +36,10 @@ export function transformParkData(submissionData: any): ParkInsert { /** * Transform ride submission data to database insert format + * Note: Relational data (technical_specs, coaster_stats, former_names) are now + * stored in separate tables and should not be included in the main ride insert. */ export function transformRideData(submissionData: any): RideInsert { - // Parse JSON fields if they're strings - let coasterStats = null; - let technicalSpecs = null; - let formerNames = null; - - try { - if (submissionData.coaster_stats) { - coasterStats = typeof submissionData.coaster_stats === 'string' - ? JSON.parse(submissionData.coaster_stats) - : submissionData.coaster_stats; - } - } catch (e) { - console.warn('Failed to parse coaster_stats:', e); - } - - try { - if (submissionData.technical_specs) { - technicalSpecs = typeof submissionData.technical_specs === 'string' - ? JSON.parse(submissionData.technical_specs) - : submissionData.technical_specs; - } - } catch (e) { - console.warn('Failed to parse technical_specs:', e); - } - - try { - if (submissionData.former_names) { - formerNames = typeof submissionData.former_names === 'string' - ? JSON.parse(submissionData.former_names) - : submissionData.former_names; - } - } catch (e) { - console.warn('Failed to parse former_names:', e); - } - return { name: submissionData.name, slug: submissionData.slug, @@ -99,9 +66,6 @@ export function transformRideData(submissionData: any): RideInsert { coaster_type: submissionData.coaster_type || null, seating_type: submissionData.seating_type || null, intensity_level: submissionData.intensity_level || null, - coaster_stats: coasterStats, - technical_specs: technicalSpecs, - former_names: formerNames || [], banner_image_url: submissionData.banner_image_url || null, banner_image_id: submissionData.banner_image_id || null, card_image_url: submissionData.card_image_url || null, @@ -136,20 +100,10 @@ export function transformCompanyData( /** * Transform ride model submission data to database insert format + * Note: Technical specifications are now stored in the ride_model_technical_specifications + * table and should not be included in the main ride model insert. */ export function transformRideModelData(submissionData: any): RideModelInsert { - let technicalSpecs = null; - - try { - if (submissionData.technical_specs) { - technicalSpecs = typeof submissionData.technical_specs === 'string' - ? JSON.parse(submissionData.technical_specs) - : submissionData.technical_specs; - } - } catch (e) { - console.warn('Failed to parse technical_specs:', e); - } - return { name: submissionData.name, slug: submissionData.slug, @@ -157,7 +111,6 @@ export function transformRideModelData(submissionData: any): RideModelInsert { category: submissionData.category, ride_type: submissionData.ride_type || null, description: submissionData.description || null, - technical_specs: technicalSpecs, }; } diff --git a/supabase/migrations/20251002120032_a30c373a-4813-4b73-b586-b9654fd068cd.sql b/supabase/migrations/20251002120032_a30c373a-4813-4b73-b586-b9654fd068cd.sql new file mode 100644 index 00000000..eb2ae587 --- /dev/null +++ b/supabase/migrations/20251002120032_a30c373a-4813-4b73-b586-b9654fd068cd.sql @@ -0,0 +1,59 @@ +-- ============================================ +-- Phase 5: Advanced Search Performance Indexes +-- ============================================ + +-- Search indexes for technical specifications +CREATE INDEX IF NOT EXISTS idx_ride_tech_specs_search +ON ride_technical_specifications(ride_id, spec_name, spec_value); + +CREATE INDEX IF NOT EXISTS idx_ride_tech_specs_category +ON ride_technical_specifications(category, spec_name); + +-- GIN index for flexible text search on spec values +CREATE INDEX IF NOT EXISTS idx_ride_tech_specs_value_gin +ON ride_technical_specifications USING gin(to_tsvector('english', spec_value)); + +-- Search indexes for coaster statistics +CREATE INDEX IF NOT EXISTS idx_ride_coaster_stats_search +ON ride_coaster_statistics(ride_id, stat_name, stat_value); + +CREATE INDEX IF NOT EXISTS idx_ride_coaster_stats_value_range +ON ride_coaster_statistics(stat_name, stat_value); + +-- Composite index for rides filtering +CREATE INDEX IF NOT EXISTS idx_rides_search_composite +ON rides(category, status, manufacturer_id, park_id); + +CREATE INDEX IF NOT EXISTS idx_rides_speed_height +ON rides(max_speed_kmh, max_height_meters) +WHERE max_speed_kmh IS NOT NULL OR max_height_meters IS NOT NULL; + +-- Ride model technical specifications indexes +CREATE INDEX IF NOT EXISTS idx_ride_model_tech_specs_search +ON ride_model_technical_specifications(ride_model_id, spec_name, spec_value); + +-- Park search optimization +CREATE INDEX IF NOT EXISTS idx_parks_search_composite +ON parks(park_type, status, operator_id, property_owner_id); + +CREATE INDEX IF NOT EXISTS idx_parks_location +ON parks(location_id) WHERE location_id IS NOT NULL; + +-- Company search optimization +CREATE INDEX IF NOT EXISTS idx_companies_type_search +ON companies(company_type, name); + +-- Text search indexes for fuzzy matching +CREATE INDEX IF NOT EXISTS idx_rides_name_gin +ON rides USING gin(to_tsvector('english', name)); + +CREATE INDEX IF NOT EXISTS idx_parks_name_gin +ON parks USING gin(to_tsvector('english', name)); + +CREATE INDEX IF NOT EXISTS idx_companies_name_gin +ON companies USING gin(to_tsvector('english', name)); + +COMMENT ON INDEX idx_ride_tech_specs_search IS 'Optimizes technical specification searches by ride and spec name'; +COMMENT ON INDEX idx_ride_coaster_stats_search IS 'Optimizes coaster statistics searches'; +COMMENT ON INDEX idx_rides_search_composite IS 'Composite index for common ride search filters'; +COMMENT ON INDEX idx_rides_name_gin IS 'Full-text search index for ride names'; \ No newline at end of file