mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 14:11:13 -05:00
feat: Implement full Phase 3 API optimizations
This commit is contained in:
63
src/hooks/homepage/useFeaturedParks.ts
Normal file
63
src/hooks/homepage/useFeaturedParks.ts
Normal file
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
59
src/hooks/lists/useListItems.ts
Normal file
59
src/hooks/lists/useListItems.ts
Normal file
@@ -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<string, any>();
|
||||
(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,
|
||||
});
|
||||
}
|
||||
34
src/hooks/parks/useParkDetail.ts
Normal file
34
src/hooks/parks/useParkDetail.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
28
src/hooks/parks/useParkRides.ts
Normal file
28
src/hooks/parks/useParkRides.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
28
src/hooks/photos/usePhotoCount.ts
Normal file
28
src/hooks/photos/usePhotoCount.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
38
src/hooks/reviews/useEntityReviews.ts
Normal file
38
src/hooks/reviews/useEntityReviews.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
61
src/hooks/reviews/useUserReviews.ts
Normal file
61
src/hooks/reviews/useUserReviews.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
51
src/hooks/rides/useRideDetail.ts
Normal file
51
src/hooks/rides/useRideDetail.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
50
src/hooks/rides/useSimilarRides.ts
Normal file
50
src/hooks/rides/useSimilarRides.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
53
src/hooks/search/useGlobalSearch.ts
Normal file
53
src/hooks/search/useGlobalSearch.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
22
src/hooks/useDebouncedValue.ts
Normal file
22
src/hooks/useDebouncedValue.ts
Normal file
@@ -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<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
Reference in New Issue
Block a user