diff --git a/src/components/homepage/FeaturedParks.tsx b/src/components/homepage/FeaturedParks.tsx index 57d0e423..750a729b 100644 --- a/src/components/homepage/FeaturedParks.tsx +++ b/src/components/homepage/FeaturedParks.tsx @@ -145,7 +145,7 @@ export function FeaturedParks() {
- {topRatedParks.map((park) => ( + {topRated.data?.map((park) => (
- {mostRidesParks.map((park) => ( + {mostRides.data?.map((park) => ( ([]); - const [loading, setLoading] = useState(true); const [filter, setFilter] = useState<'all' | 'parks' | 'rides'>('all'); const [sortBy, setSortBy] = useState<'date' | 'rating'>('date'); - useEffect(() => { - fetchReviews(); - }, [userId, filter, sortBy]); - - const fetchReviews = async () => { - try { - setLoading(true); - let query = supabase - .from('reviews') - .select(` - id, - rating, - title, - content, - visit_date, - wait_time_minutes, - helpful_votes, - moderation_status, - created_at, - parks:park_id (id, name, slug), - rides:ride_id ( - id, - name, - slug, - parks:park_id (name, slug) - ) - `) - .eq('user_id', userId); - - if (filter === 'parks') { - query = query.not('park_id', 'is', null); - } else if (filter === 'rides') { - query = query.not('ride_id', 'is', null); - } - - if (sortBy === 'date') { - query = query.order('created_at', { ascending: false }); - } else { - query = query.order('rating', { ascending: false }); - } - - const { data, error } = await query; - - if (error) throw error; - setReviews(data || []); - } catch (error: unknown) { - toast.error(getErrorMessage(error)); - } finally { - setLoading(false); - } - }; + // Use cached user reviews hook + const { data: reviews = [], isLoading: loading } = useUserReviews(userId, filter, sortBy); const formatDate = (dateString: string) => { return new Date(dateString).toLocaleDateString('en-US', { diff --git a/src/components/search/SearchResults.tsx b/src/components/search/SearchResults.tsx index 453ed196..9f29bbe8 100644 --- a/src/components/search/SearchResults.tsx +++ b/src/components/search/SearchResults.tsx @@ -1,4 +1,6 @@ import { useState, useEffect } from 'react'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import { useGlobalSearch } from '@/hooks/search/useGlobalSearch'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -20,57 +22,20 @@ type SearchResult = { }; export function SearchResults({ query, onClose }: SearchResultsProps) { - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(false); const navigate = useNavigate(); - - useEffect(() => { - if (query.length >= 2) { - searchContent(); - } else { - setResults([]); - } - }, [query]); - - const searchContent = async () => { - setLoading(true); - try { - const searchTerm = `%${query.toLowerCase()}%`; - - // Search parks - const { data: parks } = await supabase - .from('parks') - .select(`*, location:locations(*)`) - .or(`name.ilike.${searchTerm},description.ilike.${searchTerm}`) - .limit(5); - - // Search rides - const { data: rides } = await supabase - .from('rides') - .select(`*, park:parks!inner(name, slug)`) - .or(`name.ilike.${searchTerm},description.ilike.${searchTerm}`) - .limit(5); - - // Search companies - const { data: companies } = await supabase - .from('companies') - .select('id, name, slug, description, company_type, logo_url, average_rating, review_count') - .or(`name.ilike.${searchTerm},description.ilike.${searchTerm}`) - .limit(3); - - const allResults: SearchResult[] = [ - ...(parks || []).map(park => ({ type: 'park' as const, data: park })), - ...(rides || []).map(ride => ({ type: 'ride' as const, data: ride })), - ...(companies || []).map(company => ({ type: 'company' as const, data: company })) - ]; - - setResults(allResults); - } catch (error: unknown) { - logger.error('Search failed', { error: getErrorMessage(error), query }); - } finally { - setLoading(false); - } - }; + + // Debounce search query + const debouncedQuery = useDebouncedValue(query, 300); + + // Use global search hook with caching + const { data, isLoading: loading } = useGlobalSearch(debouncedQuery); + + // Flatten results + const results: SearchResult[] = [ + ...(data?.parks || []).map(park => ({ type: 'park' as const, data: park })), + ...(data?.rides || []).map(ride => ({ type: 'ride' as const, data: ride })), + ...(data?.companies || []).map(company => ({ type: 'company' as const, data: company })), + ]; const handleResultClick = (result: SearchResult) => { onClose(); diff --git a/src/hooks/homepage/useFeaturedParks.ts b/src/hooks/homepage/useFeaturedParks.ts new file mode 100644 index 00000000..2465c921 --- /dev/null +++ b/src/hooks/homepage/useFeaturedParks.ts @@ -0,0 +1,63 @@ +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +/** + * Hook to fetch featured parks (top rated and most rides) + */ +export function useFeaturedParks() { + const topRated = useQuery({ + queryKey: queryKeys.homepage.featuredParks.topRated(), + queryFn: async () => { + const { data, error } = await supabase + .from('parks') + .select(` + *, + location:locations(*), + operator:companies!parks_operator_id_fkey(*) + `) + .order('average_rating', { ascending: false }) + .limit(3); + + if (error) throw error; + return data || []; + }, + staleTime: 10 * 60 * 1000, // 10 minutes - featured parks change rarely + gcTime: 30 * 60 * 1000, + refetchOnWindowFocus: false, + }); + + const mostRides = useQuery({ + queryKey: queryKeys.homepage.featuredParks.mostRides(), + queryFn: async () => { + const { data, error } = await supabase + .from('parks') + .select(` + *, + location:locations(*), + operator:companies!parks_operator_id_fkey(*) + `) + .order('ride_count', { ascending: false }) + .limit(3); + + if (error) throw error; + return data || []; + }, + staleTime: 10 * 60 * 1000, // 10 minutes + gcTime: 30 * 60 * 1000, + refetchOnWindowFocus: false, + }); + + return { + topRated: { + data: topRated.data, + isLoading: topRated.isLoading, + error: topRated.error, + }, + mostRides: { + data: mostRides.data, + isLoading: mostRides.isLoading, + error: mostRides.error, + }, + }; +} diff --git a/src/hooks/lists/useListItems.ts b/src/hooks/lists/useListItems.ts new file mode 100644 index 00000000..67f3eff2 --- /dev/null +++ b/src/hooks/lists/useListItems.ts @@ -0,0 +1,59 @@ +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +/** + * Hook to fetch list items with entities (batch fetching to avoid N+1) + */ +export function useListItems(listId: string | undefined, enabled = true) { + return useQuery({ + queryKey: queryKeys.lists.items(listId || ''), + queryFn: async () => { + if (!listId) return []; + + // Get items + const { data: items, error: itemsError } = await supabase + .from('user_top_list_items') + .select('*') + .eq('list_id', listId) + .order('position', { ascending: true }); + + if (itemsError) throw itemsError; + if (!items || items.length === 0) return []; + + // Group by entity type for batch fetching + const parkIds = items.filter(i => i.entity_type === 'park').map(i => i.entity_id); + const rideIds = items.filter(i => i.entity_type === 'ride').map(i => i.entity_id); + const companyIds = items.filter(i => i.entity_type === 'company').map(i => i.entity_id); + + // Batch fetch all entities in parallel + const [parksResult, ridesResult, companiesResult] = await Promise.all([ + parkIds.length > 0 + ? supabase.from('parks').select('id, name, slug, park_type, location_id').in('id', parkIds) + : Promise.resolve({ data: [] }), + rideIds.length > 0 + ? supabase.from('rides').select('id, name, slug, category, park_id').in('id', rideIds) + : Promise.resolve({ data: [] }), + companyIds.length > 0 + ? supabase.from('companies').select('id, name, slug, company_type').in('id', companyIds) + : Promise.resolve({ data: [] }), + ]); + + // Create entities map for quick lookup + const entitiesMap = new Map(); + (parksResult.data || []).forEach(p => entitiesMap.set(p.id, p)); + (ridesResult.data || []).forEach(r => entitiesMap.set(r.id, r)); + (companiesResult.data || []).forEach(c => entitiesMap.set(c.id, c)); + + // Map entities to items + return items.map(item => ({ + ...item, + entity: entitiesMap.get(item.entity_id), + })); + }, + enabled: enabled && !!listId, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 15 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/parks/useParkDetail.ts b/src/hooks/parks/useParkDetail.ts new file mode 100644 index 00000000..9c96e2da --- /dev/null +++ b/src/hooks/parks/useParkDetail.ts @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +/** + * Hook to fetch park detail with all relations + * Includes location, operator, property owner, and rides + */ +export function useParkDetail(slug: string | undefined, enabled = true) { + return useQuery({ + queryKey: queryKeys.parks.detail(slug || ''), + queryFn: async () => { + if (!slug) throw new Error('Slug is required'); + + const { data, error } = await supabase + .from('parks') + .select(` + *, + location:locations(*), + operator:companies!parks_operator_id_fkey(*), + property_owner:companies!parks_property_owner_id_fkey(*) + `) + .eq('slug', slug) + .maybeSingle(); + + if (error) throw error; + return data; + }, + enabled: enabled && !!slug, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 15 * 60 * 1000, // 15 minutes + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/parks/useParkRides.ts b/src/hooks/parks/useParkRides.ts new file mode 100644 index 00000000..161af730 --- /dev/null +++ b/src/hooks/parks/useParkRides.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +/** + * Hook to fetch all rides for a specific park + */ +export function useParkRides(parkId: string | undefined, enabled = true) { + return useQuery({ + queryKey: queryKeys.parks.rides(parkId || ''), + queryFn: async () => { + if (!parkId) throw new Error('Park ID is required'); + + const { data, error } = await supabase + .from('rides') + .select('*') + .eq('park_id', parkId) + .order('name'); + + if (error) throw error; + return data || []; + }, + enabled: enabled && !!parkId, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 15 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/photos/usePhotoCount.ts b/src/hooks/photos/usePhotoCount.ts new file mode 100644 index 00000000..3aa17ac3 --- /dev/null +++ b/src/hooks/photos/usePhotoCount.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +/** + * Hook to fetch photo count for an entity + */ +export function usePhotoCount(entityType: string, entityId: string | undefined, enabled = true) { + return useQuery({ + queryKey: ['photos', 'count', entityType, entityId || ''] as const, + queryFn: async () => { + if (!entityId) return 0; + + const { count, error } = await supabase + .from('photos') + .select('id', { count: 'exact', head: true }) + .eq('entity_type', entityType) + .eq('entity_id', entityId); + + if (error) throw error; + return count || 0; + }, + enabled: enabled && !!entityId, + staleTime: 10 * 60 * 1000, // 10 minutes - photo counts change rarely + gcTime: 20 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/reviews/useEntityReviews.ts b/src/hooks/reviews/useEntityReviews.ts new file mode 100644 index 00000000..b39483bf --- /dev/null +++ b/src/hooks/reviews/useEntityReviews.ts @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +/** + * Hook to fetch reviews for a specific entity (park or ride) + */ +export function useEntityReviews(entityType: 'park' | 'ride', entityId: string | undefined, enabled = true) { + return useQuery({ + queryKey: queryKeys.reviews.entity(entityType, entityId || ''), + queryFn: async () => { + if (!entityId) return []; + + const query = supabase + .from('reviews') + .select(` + *, + profiles!reviews_user_id_fkey(username, avatar_url, display_name) + `) + .eq('moderation_status', 'approved') + .order('created_at', { ascending: false }); + + if (entityType === 'park') { + query.eq('park_id', entityId); + } else { + query.eq('ride_id', entityId); + } + + const { data, error } = await query; + if (error) throw error; + return data || []; + }, + enabled: enabled && !!entityId, + staleTime: 3 * 60 * 1000, // 3 minutes + gcTime: 10 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/reviews/useUserReviews.ts b/src/hooks/reviews/useUserReviews.ts new file mode 100644 index 00000000..10a68445 --- /dev/null +++ b/src/hooks/reviews/useUserReviews.ts @@ -0,0 +1,61 @@ +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +/** + * Hook to fetch all reviews by a specific user + */ +export function useUserReviews( + userId: string | undefined, + filter: 'all' | 'parks' | 'rides', + sortBy: 'date' | 'rating', + enabled = true +) { + return useQuery({ + queryKey: queryKeys.reviews.user(userId || '', filter, sortBy), + queryFn: async () => { + if (!userId) return []; + + let query = supabase + .from('reviews') + .select(` + id, + rating, + title, + content, + visit_date, + wait_time_minutes, + helpful_votes, + moderation_status, + created_at, + parks:park_id (id, name, slug), + rides:ride_id ( + id, + name, + slug, + parks:park_id (name, slug) + ) + `) + .eq('user_id', userId); + + if (filter === 'parks') { + query = query.not('park_id', 'is', null); + } else if (filter === 'rides') { + query = query.not('ride_id', 'is', null); + } + + query = query.order( + sortBy === 'date' ? 'created_at' : 'rating', + { ascending: false } + ); + + const { data, error } = await query; + if (error) throw error; + return data || []; + }, + enabled: enabled && !!userId, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 15 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/rides/useRideDetail.ts b/src/hooks/rides/useRideDetail.ts new file mode 100644 index 00000000..f191a11e --- /dev/null +++ b/src/hooks/rides/useRideDetail.ts @@ -0,0 +1,51 @@ +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +/** + * Hook to fetch ride detail with park, manufacturer, and designer + */ +export function useRideDetail(parkSlug: string | undefined, rideSlug: string | undefined, enabled = true) { + return useQuery({ + queryKey: queryKeys.rides.detail(parkSlug || '', rideSlug || ''), + queryFn: async () => { + if (!parkSlug || !rideSlug) throw new Error('Both park and ride slugs are required'); + + // First get park to find park_id + const { data: parkData, error: parkError } = await supabase + .from('parks') + .select('id') + .eq('slug', parkSlug) + .maybeSingle(); + + if (parkError) throw parkError; + if (!parkData) return null; + + // Then get ride details + const { data: rideData, error: rideError } = await supabase + .from('rides') + .select(` + *, + park:parks!inner(id, name, slug, location:locations(*)), + manufacturer:companies!rides_manufacturer_id_fkey(*), + designer:companies!rides_designer_id_fkey(*) + `) + .eq('park_id', parkData.id) + .eq('slug', rideSlug) + .maybeSingle(); + + if (rideError) throw rideError; + + // Add currentParkId for easier access + if (rideData) { + return { ...rideData, currentParkId: parkData.id }; + } + + return rideData; + }, + enabled: enabled && !!parkSlug && !!rideSlug, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 15 * 60 * 1000, // 15 minutes + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/rides/useSimilarRides.ts b/src/hooks/rides/useSimilarRides.ts new file mode 100644 index 00000000..9c8dd9db --- /dev/null +++ b/src/hooks/rides/useSimilarRides.ts @@ -0,0 +1,50 @@ +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +/** + * Hook to fetch similar rides (same park and category) + */ +export function useSimilarRides( + currentRideId: string | undefined, + parkId: string | undefined, + category: string | undefined, + enabled = true +) { + return useQuery({ + queryKey: queryKeys.rides.similar(parkId || '', category || '', currentRideId || ''), + queryFn: async () => { + if (!currentRideId || !parkId || !category) return []; + + const { data, error } = await supabase + .from('rides') + .select(` + id, + name, + slug, + image_url, + average_rating, + status, + category, + description, + max_speed_kmh, + max_height_meters, + duration_seconds, + review_count, + park:parks!inner(name, slug) + `) + .eq('park_id', parkId) + .eq('category', category) + .neq('id', currentRideId) + .order('average_rating', { ascending: false }) + .limit(4); + + if (error) throw error; + return data || []; + }, + enabled: enabled && !!currentRideId && !!parkId && !!category, + staleTime: 10 * 60 * 1000, // 10 minutes - similar rides rarely change + gcTime: 20 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/search/useGlobalSearch.ts b/src/hooks/search/useGlobalSearch.ts new file mode 100644 index 00000000..4e022a70 --- /dev/null +++ b/src/hooks/search/useGlobalSearch.ts @@ -0,0 +1,53 @@ +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +/** + * Hook for global search across parks, rides, and companies + * Searches in parallel and caches results + */ +export function useGlobalSearch(query: string) { + return useQuery({ + queryKey: queryKeys.search.global(query.toLowerCase()), + queryFn: async () => { + if (query.length < 2) { + return { parks: [], rides: [], companies: [] }; + } + + const searchTerm = `%${query.toLowerCase()}%`; + + // Run all 3 queries in parallel + const [parksResult, ridesResult, companiesResult] = await Promise.all([ + supabase + .from('parks') + .select(`*, location:locations(*)`) + .or(`name.ilike.${searchTerm},description.ilike.${searchTerm}`) + .limit(5), + supabase + .from('rides') + .select(`*, park:parks!inner(name, slug)`) + .or(`name.ilike.${searchTerm},description.ilike.${searchTerm}`) + .limit(5), + supabase + .from('companies') + .select('id, name, slug, description, company_type, logo_url, average_rating, review_count') + .or(`name.ilike.${searchTerm},description.ilike.${searchTerm}`) + .limit(3), + ]); + + if (parksResult.error) throw parksResult.error; + if (ridesResult.error) throw ridesResult.error; + if (companiesResult.error) throw companiesResult.error; + + return { + parks: parksResult.data || [], + rides: ridesResult.data || [], + companies: companiesResult.data || [], + }; + }, + enabled: query.length >= 2, + staleTime: 2 * 60 * 1000, // 2 minutes - search results fairly stable + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/useDebouncedValue.ts b/src/hooks/useDebouncedValue.ts new file mode 100644 index 00000000..c183497e --- /dev/null +++ b/src/hooks/useDebouncedValue.ts @@ -0,0 +1,22 @@ +import { useState, useEffect } from 'react'; + +/** + * Hook to debounce a value + * @param value - The value to debounce + * @param delay - Delay in milliseconds + */ +export function useDebouncedValue(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/lib/queryInvalidation.ts b/src/lib/queryInvalidation.ts index e315e28a..e3601461 100644 --- a/src/lib/queryInvalidation.ts +++ b/src/lib/queryInvalidation.ts @@ -110,5 +110,81 @@ export function useQueryInvalidation() { invalidateRides: () => { queryClient.invalidateQueries({ queryKey: ['rides'] }); }, + + /** + * Invalidate park detail cache + * Call this after updating a park + */ + invalidateParkDetail: (slug: string) => { + queryClient.invalidateQueries({ queryKey: queryKeys.parks.detail(slug) }); + }, + + /** + * Invalidate ride detail cache + * Call this after updating a ride + */ + invalidateRideDetail: (parkSlug: string, rideSlug: string) => { + queryClient.invalidateQueries({ queryKey: queryKeys.rides.detail(parkSlug, rideSlug) }); + }, + + /** + * Invalidate entity reviews + * Call this after adding/updating/deleting reviews + */ + invalidateEntityReviews: (entityType: 'park' | 'ride', entityId: string) => { + queryClient.invalidateQueries({ queryKey: queryKeys.reviews.entity(entityType, entityId) }); + }, + + /** + * Invalidate user reviews + * Call this after a user adds/updates/deletes their reviews + */ + invalidateUserReviews: (userId: string) => { + queryClient.invalidateQueries({ queryKey: ['reviews', 'user', userId] }); + }, + + /** + * Invalidate entity photos + * Call this after uploading/deleting photos + */ + invalidateEntityPhotos: (entityType: string, entityId: string) => { + queryClient.invalidateQueries({ queryKey: queryKeys.photos.entity(entityType, entityId) }); + }, + + /** + * Invalidate photo count + * Call this after photo changes + */ + invalidatePhotoCount: (entityType: string, entityId: string) => { + queryClient.invalidateQueries({ queryKey: queryKeys.photos.count(entityType, entityId) }); + }, + + /** + * Invalidate search results + * Call this after major data changes + */ + invalidateSearchResults: () => { + queryClient.invalidateQueries({ queryKey: ['search'] }); + }, + + /** + * Invalidate similar rides + * Call this after ride updates + */ + invalidateSimilarRides: (parkId: string, category: string) => { + queryClient.invalidateQueries({ + queryKey: ['rides', 'similar', parkId, category] + }); + }, + + /** + * Invalidate featured parks + * Call this after park updates that affect featured status + */ + invalidateFeaturedParks: () => { + queryClient.invalidateQueries({ + queryKey: ['homepage', 'featured-parks'] + }); + }, }; } diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 117c2959..184ebb40 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -31,7 +31,50 @@ export const queryKeys = { closingSoonRides: () => ['homepage', 'closing-soon-rides'] as const, recentlyClosedParks: () => ['homepage', 'recently-closed-parks'] as const, recentlyClosedRides: () => ['homepage', 'recently-closed-rides'] as const, + featuredParks: { + topRated: () => ['homepage', 'featured-parks', 'top-rated'] as const, + mostRides: () => ['homepage', 'featured-parks', 'most-rides'] as const, + }, }, - // Add more query keys as needed + // Parks queries + parks: { + all: () => ['parks', 'all'] as const, + detail: (slug: string) => ['parks', 'detail', slug] as const, + rides: (parkId: string) => ['parks', 'rides', parkId] as const, + }, + + // Rides queries + rides: { + all: () => ['rides', 'all'] as const, + detail: (parkSlug: string, rideSlug: string) => ['rides', 'detail', parkSlug, rideSlug] as const, + similar: (parkId: string, category: string, currentId: string) => + ['rides', 'similar', parkId, category, currentId] as const, + }, + + // Reviews queries + reviews: { + entity: (entityType: 'park' | 'ride', entityId: string) => + ['reviews', entityType, entityId] as const, + user: (userId: string, filter: string, sortBy: string) => + ['reviews', 'user', userId, filter, sortBy] as const, + }, + + // Photos queries + photos: { + entity: (entityType: string, entityId: string) => + ['photos', entityType, entityId] as const, + count: (entityType: string, entityId: string) => + ['photos', 'count', entityType, entityId] as const, + }, + + // Search queries + search: { + global: (query: string) => ['search', 'global', query] as const, + }, + + // Lists queries + lists: { + items: (listId: string) => ['list-items', listId] as const, + }, } as const; diff --git a/src/pages/ParkDetail.tsx b/src/pages/ParkDetail.tsx index 7590a1d0..129c9245 100644 --- a/src/pages/ParkDetail.tsx +++ b/src/pages/ParkDetail.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, lazy, Suspense } from 'react'; +import { useState, lazy, Suspense, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Header } from '@/components/layout/Header'; import { getBannerUrls } from '@/lib/cloudflareImageUtils'; @@ -15,10 +15,12 @@ import { RideCard } from '@/components/rides/RideCard'; import { Park, Ride } from '@/types/database'; import { ParkLocationMap } from '@/components/maps/ParkLocationMap'; import { EntityPhotoGallery } from '@/components/upload/EntityPhotoGallery'; -import { supabase } from '@/integrations/supabase/client'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { AdminFormSkeleton } from '@/components/loading/PageSkeletons'; import { toast } from '@/hooks/use-toast'; +import { useParkDetail } from '@/hooks/parks/useParkDetail'; +import { useParkRides } from '@/hooks/parks/useParkRides'; +import { usePhotoCount } from '@/hooks/photos/usePhotoCount'; // Lazy load admin forms const RideForm = lazy(() => import('@/components/admin/RideForm').then(m => ({ default: m.RideForm }))); @@ -33,23 +35,23 @@ import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useOpenGraph } from '@/hooks/useOpenGraph'; export default function ParkDetail() { - const { - slug - } = useParams<{ - slug: string; - }>(); + const { slug } = useParams<{ slug: string }>(); const navigate = useNavigate(); const { user } = useAuth(); const { requireAuth } = useAuthModal(); - const [park, setPark] = useState(null); - const [rides, setRides] = useState([]); - const [loading, setLoading] = useState(true); const [isAddRideModalOpen, setIsAddRideModalOpen] = useState(false); const [isEditParkModalOpen, setIsEditParkModalOpen] = useState(false); - const [photoCount, setPhotoCount] = useState(0); - const [statsLoading, setStatsLoading] = useState(true); const { isModerator } = useUserRole(); + // Fetch park data with caching + const { data: park, isLoading: loading, error } = useParkDetail(slug); + + // Fetch rides with caching + const { data: rides = [] } = useParkRides(park?.id, !!park?.id); + + // Fetch photo count with caching + const { data: photoCount = 0, isLoading: statsLoading } = usePhotoCount('park', park?.id, !!park?.id); + // Update document title when park changes useDocumentTitle(park?.name || 'Park Details'); @@ -62,58 +64,6 @@ export default function ParkDetail() { type: 'website', enabled: !!park }); - - const fetchPhotoCount = useCallback(async (parkId: string) => { - try { - const { count, error } = await supabase - .from('photos') - .select('id', { count: 'exact', head: true }) - .eq('entity_type', 'park') - .eq('entity_id', parkId); - - if (error) throw error; - setPhotoCount(count || 0); - } catch (error) { - console.error('Error fetching photo count:', error); - setPhotoCount(0); - } finally { - setStatsLoading(false); - } - }, []); - - const fetchParkData = useCallback(async () => { - try { - // Fetch park details - const { - data: parkData - } = await supabase.from('parks').select(` - *, - location:locations(*), - operator:companies!parks_operator_id_fkey(*), - property_owner:companies!parks_property_owner_id_fkey(*) - `).eq('slug', slug).maybeSingle(); - if (parkData) { - setPark(parkData); - fetchPhotoCount(parkData.id); - - // Fetch park rides - const { - data: ridesData - } = await supabase.from('rides').select(`*`).eq('park_id', parkData.id).order('name'); - setRides(ridesData || []); - } - } catch (error) { - console.error('Error fetching park data:', error); - } finally { - setLoading(false); - } - }, [slug, fetchPhotoCount]); - - useEffect(() => { - if (slug) { - fetchParkData(); - } - }, [slug, fetchParkData]); // Track page view when park is loaded useEffect(() => { diff --git a/src/pages/RideDetail.tsx b/src/pages/RideDetail.tsx index 6f9489c6..9bf601d9 100644 --- a/src/pages/RideDetail.tsx +++ b/src/pages/RideDetail.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, lazy, Suspense } from 'react'; +import { useState, lazy, Suspense, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Header } from '@/components/layout/Header'; import { getBannerUrls } from '@/lib/cloudflareImageUtils'; @@ -45,8 +45,9 @@ import { FormerNames } from '@/components/rides/FormerNames'; import { RecentPhotosPreview } from '@/components/rides/RecentPhotosPreview'; import { ParkLocationMap } from '@/components/maps/ParkLocationMap'; import { Ride } from '@/types/database'; -import { supabase } from '@/integrations/supabase/client'; import { useAuth } from '@/hooks/useAuth'; +import { useRideDetail } from '@/hooks/rides/useRideDetail'; +import { usePhotoCount } from '@/hooks/photos/usePhotoCount'; // Lazy load admin forms const RideForm = lazy(() => import('@/components/admin/RideForm').then(m => ({ default: m.RideForm }))); @@ -70,12 +71,17 @@ export default function RideDetail() { const { user } = useAuth(); const { isModerator } = useUserRole(); const { requireAuth } = useAuthModal(); - const [ride, setRide] = useState(null); - const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState("overview"); const [isEditModalOpen, setIsEditModalOpen] = useState(false); - const [photoCount, setPhotoCount] = useState(0); - const [statsLoading, setStatsLoading] = useState(true); + + // Fetch ride data with caching + const { data: rideData, isLoading: loading } = useRideDetail(parkSlug, rideSlug); + + // Cast to RideWithParkId to include currentParkId + const ride = rideData as RideWithParkId | null; + + // Fetch photo count with caching + const { data: photoCount = 0, isLoading: statsLoading } = usePhotoCount('ride', ride?.id, !!ride?.id); // Update document title when ride changes useDocumentTitle(ride?.name || 'Ride Details'); @@ -90,12 +96,6 @@ export default function RideDetail() { enabled: !!ride }); - useEffect(() => { - if (parkSlug && rideSlug) { - fetchRideData(); - } - }, [parkSlug, rideSlug]); - // Track page view when ride is loaded useEffect(() => { if (ride?.id) { @@ -103,66 +103,6 @@ export default function RideDetail() { } }, [ride?.id]); - const fetchRideData = async () => { - try { - // First get park to find park_id - const { data: parkData } = await supabase - .from('parks') - .select('id') - .eq('slug', parkSlug) - .maybeSingle(); - - if (parkData) { - // Then get ride details with park_id stored separately - const { data: rideData } = await supabase - .from('rides') - .select(` - *, - park:parks!inner(id, name, slug, location:locations(*)), - manufacturer:companies!rides_manufacturer_id_fkey(*), - designer:companies!rides_designer_id_fkey(*) - `) - .eq('park_id', parkData.id) - .eq('slug', rideSlug) - .maybeSingle(); - - if (rideData) { - // Store park_id for easier access - const extendedRide: RideWithParkId = { - ...rideData, - currentParkId: parkData.id - }; - setRide(extendedRide); - fetchPhotoCount(rideData.id); - } else { - setRide(null); - } - } - } catch (error) { - console.error('Error fetching ride data:', error); - } finally { - setLoading(false); - } - }; - - const fetchPhotoCount = async (rideId: string) => { - try { - const { count, error } = await supabase - .from('photos') - .select('id', { count: 'exact', head: true }) - .eq('entity_type', 'ride') - .eq('entity_id', rideId); - - if (error) throw error; - setPhotoCount(count || 0); - } catch (error) { - console.error('Error fetching photo count:', error); - setPhotoCount(0); - } finally { - setStatsLoading(false); - } - }; - const getStatusColor = (status: string) => { switch (status) { case 'operating': return 'bg-green-500/20 text-green-400 border-green-500/30';