Refactor: Update user list item interfaces

This commit is contained in:
gpt-engineer-app[bot]
2025-10-02 12:02:36 +00:00
parent 8ec0d59e75
commit ee1ea52f2e
5 changed files with 530 additions and 51 deletions

View File

@@ -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<TechnicalSpecFilter>) => {
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<CoasterStatFilter>) => {
const updated = [...coasterStatFilters];
updated[index] = { ...updated[index], ...updates };
onCoasterStatFiltersChange(updated);
};
const removeCoasterStatFilter = (index: number) => {
onCoasterStatFiltersChange(coasterStatFilters.filter((_, i) => i !== index));
};
return (
<div className="space-y-4">
{/* Technical Specifications Filters */}
<Collapsible open={isSpecsOpen} onOpenChange={setIsSpecsOpen}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer p-3 bg-muted/50 rounded-lg hover:bg-muted transition-colors">
<div className="flex items-center gap-2">
<h4 className="font-semibold">Technical Specifications</h4>
{technicalSpecFilters.length > 0 && (
<Badge variant="secondary">{technicalSpecFilters.length}</Badge>
)}
</div>
<ChevronDown className={`w-4 h-4 transition-transform ${isSpecsOpen ? 'rotate-180' : ''}`} />
</div>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-3">
{technicalSpecFilters.map((filter, index) => (
<div key={index} className="p-3 border rounded-lg space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Specification Name</Label>
<Select
value={filter.spec_name}
onValueChange={(value) => updateTechnicalSpecFilter(index, { spec_name: value })}
>
<SelectTrigger>
<SelectValue placeholder="Select specification" />
</SelectTrigger>
<SelectContent>
{COMMON_TECH_SPECS.map((spec) => (
<SelectItem key={spec} value={spec}>
{spec}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Filter Type</Label>
<Select
value={filter.operator}
onValueChange={(value: any) => updateTechnicalSpecFilter(index, { operator: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="has_spec">Has Specification</SelectItem>
<SelectItem value="equals">Equals Value</SelectItem>
<SelectItem value="contains">Contains Text</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => removeTechnicalSpecFilter(index)}
>
<X className="w-4 h-4" />
</Button>
</div>
{(filter.operator === 'equals' || filter.operator === 'contains') && (
<div className="space-y-2">
<Label>Value</Label>
<Input
value={filter.spec_value || ''}
onChange={(e) => updateTechnicalSpecFilter(index, { spec_value: e.target.value })}
placeholder="Enter value to match"
/>
</div>
)}
</div>
))}
<Button
variant="outline"
size="sm"
onClick={addTechnicalSpecFilter}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
Add Technical Spec Filter
</Button>
</CollapsibleContent>
</Collapsible>
{/* Coaster Statistics Filters */}
<Collapsible open={isStatsOpen} onOpenChange={setIsStatsOpen}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer p-3 bg-muted/50 rounded-lg hover:bg-muted transition-colors">
<div className="flex items-center gap-2">
<h4 className="font-semibold">Coaster Statistics</h4>
{coasterStatFilters.length > 0 && (
<Badge variant="secondary">{coasterStatFilters.length}</Badge>
)}
</div>
<ChevronDown className={`w-4 h-4 transition-transform ${isStatsOpen ? 'rotate-180' : ''}`} />
</div>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-3">
{coasterStatFilters.map((filter, index) => (
<div key={index} className="p-3 border rounded-lg space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 space-y-3">
<div className="space-y-2">
<Label>Statistic Name</Label>
<Select
value={filter.stat_name}
onValueChange={(value) => updateCoasterStatFilter(index, { stat_name: value })}
>
<SelectTrigger>
<SelectValue placeholder="Select statistic" />
</SelectTrigger>
<SelectContent>
{COMMON_COASTER_STATS.map((stat) => (
<SelectItem key={stat} value={stat}>
{stat}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Min Value</Label>
<Input
type="number"
value={filter.min_value || ''}
onChange={(e) =>
updateCoasterStatFilter(index, {
min_value: e.target.value ? parseFloat(e.target.value) : undefined,
})
}
placeholder="Min"
/>
</div>
<div className="space-y-2">
<Label>Max Value</Label>
<Input
type="number"
value={filter.max_value || ''}
onChange={(e) =>
updateCoasterStatFilter(index, {
max_value: e.target.value ? parseFloat(e.target.value) : undefined,
})
}
placeholder="Max"
/>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => removeCoasterStatFilter(index)}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={addCoasterStatFilter}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
Add Coaster Stat Filter
</Button>
</CollapsibleContent>
</Collapsible>
</div>
);
}

View File

@@ -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 }:
/>
</div>
</div>
{/* Advanced Ride Filters */}
<div className="pt-4 border-t">
<AdvancedRideFilters
technicalSpecFilters={filters.technicalSpecFilters || []}
coasterStatFilters={filters.coasterStatFilters || []}
onTechnicalSpecFiltersChange={(filters) => updateFilter('technicalSpecFilters', filters)}
onCoasterStatFiltersChange={(filters) => updateFilter('coasterStatFilters', filters)}
/>
</div>
</div>
)}

View File

@@ -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<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<string>();
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 };
}

View File

@@ -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,
};
}

View File

@@ -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';