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