From 0d9926a5ae7d945629986552ebee5d2693ae2ddd Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 16 Oct 2025 15:38:28 +0000 Subject: [PATCH] Approve database migration --- src/components/profile/RideCreditCard.tsx | 178 ++++++-- src/components/profile/RideCreditFilters.tsx | 419 ++++++++++++++++++ src/components/profile/RideCreditsManager.tsx | 135 +++--- .../profile/SortableRideCreditCard.tsx | 32 +- src/hooks/useRideCreditFilters.ts | 234 ++++++++++ src/integrations/supabase/types.ts | 12 + src/types/database.ts | 12 +- src/types/ride-credits.ts | 64 +++ ...1_bde00850-3566-4b1a-9b16-d403561257c8.sql | 57 +++ 9 files changed, 1027 insertions(+), 116 deletions(-) create mode 100644 src/components/profile/RideCreditFilters.tsx create mode 100644 src/hooks/useRideCreditFilters.ts create mode 100644 src/types/ride-credits.ts create mode 100644 supabase/migrations/20251016153321_bde00850-3566-4b1a-9b16-d403561257c8.sql diff --git a/src/components/profile/RideCreditCard.tsx b/src/components/profile/RideCreditCard.tsx index 983d873d..fe480b62 100644 --- a/src/components/profile/RideCreditCard.tsx +++ b/src/components/profile/RideCreditCard.tsx @@ -3,13 +3,14 @@ import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; -import { Calendar, MapPin, Edit, Trash2, Plus, Minus, Check, X } from 'lucide-react'; +import { Calendar, MapPin, Edit, Trash2, Plus, Minus, Check, X, Star, Factory, Gauge, Ruler, Zap } from 'lucide-react'; import { Link } from 'react-router-dom'; import { format } from 'date-fns'; import { supabase } from '@/integrations/supabase/client'; import { toast } from 'sonner'; import { getErrorMessage } from '@/lib/errorHandler'; import { UserRideCredit } from '@/types/database'; +import { convertValueFromMetric, getDisplayUnit } from '@/lib/units'; import { AlertDialog, AlertDialogAction, @@ -45,6 +46,13 @@ export function RideCreditCard({ credit, position, maxPosition, viewMode, isEdit const rideSlug = credit.rides?.slug || ''; const imageUrl = credit.rides?.card_image_url; const category = credit.rides?.category || ''; + const location = credit.rides?.parks?.locations; + const manufacturer = credit.rides?.manufacturer; + const coasterType = credit.rides?.coaster_type; + const maxSpeed = credit.rides?.max_speed_kmh; + const maxHeight = credit.rides?.max_height_meters; + const inversions = credit.rides?.inversions || 0; + const intensityLevel = credit.rides?.intensity_level; const handleUpdateCount = async () => { try { @@ -123,62 +131,142 @@ export function RideCreditCard({ credit, position, maxPosition, viewMode, isEdit return ( -
+
{imageUrl && ( {rideName} )} -
-
- - {rideName} - - {isEditMode && maxPosition ? ( -
- # - setEditPosition(parseInt(e.target.value) || 1)} - onBlur={handlePositionChange} - onKeyDown={(e) => e.key === 'Enter' && handlePositionChange()} - className="w-14 h-6 text-xs p-1" - min="1" - max={maxPosition} - /> +
+ {/* Header Row */} +
+
+
+ + {rideName} + + {isEditMode && maxPosition ? ( +
+ # + setEditPosition(parseInt(e.target.value) || 1)} + onBlur={handlePositionChange} + onKeyDown={(e) => e.key === 'Enter' && handlePositionChange()} + className="w-14 h-6 text-xs p-1" + min="1" + max={maxPosition} + /> +
+ ) : ( + + #{position} + + )} + {getCategoryBadge(category)}
- ) : ( - - #{position} - - )} - {getCategoryBadge(category)} -
- - - - {parkName} - - - {credit.first_ride_date && ( -
- - First ride: {format(new Date(credit.first_ride_date), 'MMM d, yyyy')} + + + + {parkName} + {location?.city && `, ${location.city}`} + {location?.state_province && `, ${location.state_province}`} +
+
+ + {/* Stats Grid - Enhanced */} +
+ {manufacturer && ( +
+ + {manufacturer.name} +
+ )} + {coasterType && ( +
+ Type: + {coasterType.replace('_', ' ')} +
+ )} + {maxSpeed && ( +
+ + {Math.round(maxSpeed)} km/h +
+ )} + {maxHeight && ( +
+ + {Math.round(maxHeight)} m +
+ )} + {inversions > 0 && ( +
+ + {inversions} inversions +
+ )} + {intensityLevel && ( +
+ Intensity: + {intensityLevel} +
+ )} +
+ + {/* Dates */} +
+ {credit.first_ride_date && ( +
+ + First: {format(new Date(credit.first_ride_date), 'MMM d, yyyy')} +
+ )} + {credit.last_ride_date && ( +
+ + Last: {format(new Date(credit.last_ride_date), 'MMM d, yyyy')} +
+ )} +
+ + {/* Personal Rating */} + {credit.personal_rating && ( +
+ {Array.from({ length: 5 }, (_, i) => ( + + ))} + Your Rating +
+ )} + + {/* Personal Notes Preview */} + {credit.personal_notes && ( +

+ "{credit.personal_notes}" +

)}
-
+
{isEditing ? ( <> void; + onClearFilters: () => void; + credits: UserRideCredit[]; + activeFilterCount: number; + resultCount: number; + totalCount: number; +} + +export function RideCreditFilters({ + filters, + onFilterChange, + onClearFilters, + credits, + activeFilterCount, + resultCount, + totalCount, +}: RideCreditFiltersProps) { + // Extract unique values from credits for filter options + const filterOptions = useMemo(() => { + const countries = new Set(); + const states = new Set(); + const cities = new Set(); + const parks = new Map(); + const manufacturers = new Map(); + + credits.forEach(credit => { + const location = credit.rides?.parks?.locations; + if (location?.country) countries.add(location.country); + if (location?.state_province) states.add(location.state_province); + if (location?.city) cities.add(location.city); + + if (credit.rides?.parks?.id && credit.rides?.parks?.name) { + parks.set(credit.rides.parks.id, credit.rides.parks.name); + } + + if (credit.rides?.manufacturer?.id && credit.rides?.manufacturer?.name) { + manufacturers.set(credit.rides.manufacturer.id, credit.rides.manufacturer.name); + } + }); + + return { + countries: Array.from(countries).sort(), + states: Array.from(states).sort(), + cities: Array.from(cities).sort(), + parks: Array.from(parks.entries()).map(([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name)), + manufacturers: Array.from(manufacturers.entries()).map(([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name)), + }; + }, [credits]); + + const maxRideCount = useMemo(() => { + return Math.max(...credits.map(c => c.ride_count), 1); + }, [credits]); + + const categoryOptions = [ + { value: 'roller_coaster', label: '🎢 Coasters', icon: '🎢' }, + { value: 'flat_ride', label: '🎪 Flat Rides', icon: '🎪' }, + { value: 'water_ride', label: '🌊 Water Rides', icon: '🌊' }, + { value: 'dark_ride', label: '🌑 Dark Rides', icon: '🌑' }, + { value: 'transport_ride', label: '🚂 Transport', icon: '🚂' }, + ]; + + const toggleCategory = (category: string) => { + const current = filters.categories || []; + const updated = current.includes(category) + ? current.filter(c => c !== category) + : [...current, category]; + onFilterChange('categories', updated.length > 0 ? updated : undefined); + }; + + const toggleArrayFilter = (key: keyof FilterTypes, value: string) => { + const current = (filters[key] as string[]) || []; + const updated = current.includes(value) + ? current.filter(v => v !== value) + : [...current, value]; + onFilterChange(key, updated.length > 0 ? updated : undefined); + }; + + const removeFilter = (key: keyof FilterTypes, value?: string) => { + if (value && Array.isArray(filters[key])) { + const updated = (filters[key] as string[]).filter(v => v !== value); + onFilterChange(key, updated.length > 0 ? updated : undefined); + } else { + onFilterChange(key, undefined); + } + }; + + return ( +
+ {/* Search Bar - Always Visible */} +
+ + onFilterChange('searchQuery', e.target.value || undefined)} + className="pl-10 pr-10" + /> + {filters.searchQuery && ( + + )} +
+ + {/* Quick Category Chips */} +
+ {categoryOptions.map(cat => ( + toggleCategory(cat.value)} + > + {cat.label} + + ))} +
+ + {/* Collapsible Filter Sections */} +
+ {/* Geographic Filters */} + + +
+ + Location Filters + {((filters.countries?.length || 0) + (filters.statesProvinces?.length || 0) + (filters.cities?.length || 0)) > 0 && ( + + {(filters.countries?.length || 0) + (filters.statesProvinces?.length || 0) + (filters.cities?.length || 0)} + + )} +
+ +
+ + {filterOptions.countries.length > 0 && ( +
+ +
+ {filterOptions.countries.map(country => ( + toggleArrayFilter('countries', country)} + > + {country} + + ))} +
+
+ )} + + {filterOptions.states.length > 0 && ( +
+ +
+ {filterOptions.states.map(state => ( + toggleArrayFilter('statesProvinces', state)} + > + {state} + + ))} +
+
+ )} +
+
+ + {/* Park Filters */} + + +
+ + Park Filters + {(filters.parks?.length || 0) > 0 && ( + {filters.parks?.length} + )} +
+ +
+ + {filterOptions.parks.length > 0 && ( +
+ +
+ {filterOptions.parks.map(park => ( + toggleArrayFilter('parks', park.id)} + > + {park.name} + + ))} +
+
+ )} +
+
+ + {/* Ride Detail Filters */} + + +
+ + Ride Details + {(filters.manufacturers?.length || 0) > 0 && ( + {filters.manufacturers?.length} + )} +
+ +
+ + {filterOptions.manufacturers.length > 0 && ( +
+ +
+ {filterOptions.manufacturers.map(mfr => ( + toggleArrayFilter('manufacturers', mfr.id)} + > + {mfr.name} + + ))} +
+
+ )} + +
+ +
+ onFilterChange('hasInversions', checked ? true : undefined)} + /> + Has inversions +
+
+
+
+ + {/* Statistics Filters */} + + +
+ + Statistics + {((filters.minRideCount !== undefined) || (filters.minSpeed !== undefined) || (filters.minHeight !== undefined)) && ( + Active + )} +
+ +
+ +
+
+ + + {filters.minRideCount || 1} - {filters.maxRideCount || maxRideCount} + +
+ { + onFilterChange('minRideCount', min > 1 ? min : undefined); + onFilterChange('maxRideCount', max < maxRideCount ? max : undefined); + }} + className="w-full" + /> +
+
+
+ + {/* Experience Filters */} + + +
+ + My Experience + {(filters.hasNotes !== undefined || filters.hasPhotos !== undefined || filters.hasRating !== undefined) && ( + Active + )} +
+ +
+ +
+
+ onFilterChange('hasNotes', checked ? true : undefined)} + /> + + Has notes +
+
+ onFilterChange('hasPhotos', checked ? true : undefined)} + /> + + Has photos +
+
+ +
+ +
+ onFilterChange('hasRating', checked ? true : undefined)} + /> + Has rating +
+ {filters.hasRating && ( + onFilterChange('minUserRating', min > 1 ? min : undefined)} + className="w-full" + /> + )} +
+
+
+
+ + {/* Active Filters Display */} + {activeFilterCount > 0 && ( +
+
+ + Showing {resultCount} of {totalCount} credits + {activeFilterCount > 0 && ` (${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'} active)`} + + +
+ +
+ {filters.searchQuery && ( + + Search: {filters.searchQuery} + removeFilter('searchQuery')} /> + + )} + {filters.categories?.map(cat => ( + + {categoryOptions.find(c => c.value === cat)?.icon} {cat} + removeFilter('categories', cat)} /> + + ))} + {filters.countries?.map(country => ( + + 📍 {country} + removeFilter('countries', country)} /> + + ))} + {filters.manufacturers?.map(mfrId => { + const mfr = filterOptions.manufacturers.find(m => m.id === mfrId); + return mfr && ( + + 🏭 {mfr.name} + removeFilter('manufacturers', mfrId)} /> + + ); + })} + {filters.minRideCount && filters.minRideCount > 1 && ( + + {filters.minRideCount}+ rides + removeFilter('minRideCount')} /> + + )} + {filters.hasInversions && ( + + Has inversions + removeFilter('hasInversions')} /> + + )} +
+
+ )} +
+ ); +} diff --git a/src/components/profile/RideCreditsManager.tsx b/src/components/profile/RideCreditsManager.tsx index ba9708e4..8bb84b1f 100644 --- a/src/components/profile/RideCreditsManager.tsx +++ b/src/components/profile/RideCreditsManager.tsx @@ -9,7 +9,9 @@ import { getErrorMessage } from '@/lib/errorHandler'; import { AddRideCreditDialog } from './AddRideCreditDialog'; import { RideCreditCard } from './RideCreditCard'; import { SortableRideCreditCard } from './SortableRideCreditCard'; +import { RideCreditFilters } from './RideCreditFilters'; import { UserRideCredit } from '@/types/database'; +import { useRideCreditFilters } from '@/hooks/useRideCreditFilters'; import { DndContext, DragEndEvent, @@ -33,10 +35,18 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) { const [loading, setLoading] = useState(true); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [sortBy, setSortBy] = useState<'date' | 'count' | 'name' | 'custom'>('custom'); - const [filterCategory, setFilterCategory] = useState('all'); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isEditMode, setIsEditMode] = useState(false); + // Use the filter hook + const { + filters, + updateFilter, + clearFilters, + filteredCredits, + activeFilterCount, + } = useRideCreditFilters(credits); + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { @@ -47,25 +57,13 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) { useEffect(() => { fetchCredits(); - }, [userId, sortBy, filterCategory]); + }, [userId, sortBy]); const fetchCredits = async () => { try { setLoading(true); - // First, get ride IDs that match the category filter (if any) - let rideIdsToFilter: string[] | null = null; - if (filterCategory !== 'all') { - const { data: filteredRides, error: rideError } = await supabase - .from('rides') - .select('id') - .eq('category', filterCategory); - - if (rideError) throw rideError; - rideIdsToFilter = filteredRides.map(r => r.id); - } - - // Build main query + // Build main query with enhanced data let query = supabase .from('user_ride_credits') .select(` @@ -75,36 +73,46 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) { name, slug, category, + status, + coaster_type, + seating_type, + intensity_level, + max_speed_kmh, + max_height_meters, + length_meters, + inversions, card_image_url, parks:park_id ( id, name, - slug + slug, + park_type, + locations:location_id ( + country, + state_province, + city + ) + ), + manufacturer:manufacturer_id ( + id, + name + ), + designer:designer_id ( + id, + name ) ) `) .eq('user_id', userId); - // Apply ride ID filter if category filter is active - if (rideIdsToFilter && rideIdsToFilter.length > 0) { - query = query.in('ride_id', rideIdsToFilter); - } else if (rideIdsToFilter && rideIdsToFilter.length === 0) { - // No rides match the category - return empty - setCredits([]); - setLoading(false); - return; - } - // Apply sorting - default to sort_order when available if (sortBy === 'date') { query = query.order('first_ride_date', { ascending: false, nullsFirst: false }); } else if (sortBy === 'count') { query = query.order('ride_count', { ascending: false }); } else if (sortBy === 'name') { - // Note: Can't order by joined table - we'll sort client-side query = query.order('created_at', { ascending: false }); } else { - // Default to custom sort order query = query.order('sort_order', { ascending: true, nullsFirst: false }); } @@ -206,6 +214,9 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) { coasters: credits.filter(c => c.rides?.category === 'roller_coaster').length }; + // Use filtered credits for display + const displayCredits = filteredCredits; + if (loading) { return (
@@ -250,6 +261,21 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
+ {/* Filters */} + + + + + + {/* Controls */}
@@ -268,19 +294,6 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
- -