mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 02:51:12 -05:00
feat: Implement full Phase 3 API optimizations
This commit is contained in:
@@ -145,7 +145,7 @@ export function FeaturedParks() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
{topRatedParks.map((park) => (
|
{topRated.data?.map((park) => (
|
||||||
<FeaturedParkCard
|
<FeaturedParkCard
|
||||||
key={park.id}
|
key={park.id}
|
||||||
park={park}
|
park={park}
|
||||||
@@ -166,7 +166,7 @@ export function FeaturedParks() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
{mostRidesParks.map((park) => (
|
{mostRides.data?.map((park) => (
|
||||||
<FeaturedParkCard
|
<FeaturedParkCard
|
||||||
key={park.id}
|
key={park.id}
|
||||||
park={park}
|
park={park}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useUserReviews } from '@/hooks/reviews/useUserReviews';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -43,62 +44,11 @@ interface UserReviewsListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UserReviewsList({ userId, reviewCount }: UserReviewsListProps) {
|
export function UserReviewsList({ userId, reviewCount }: UserReviewsListProps) {
|
||||||
const [reviews, setReviews] = useState<ReviewWithEntity[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [filter, setFilter] = useState<'all' | 'parks' | 'rides'>('all');
|
const [filter, setFilter] = useState<'all' | 'parks' | 'rides'>('all');
|
||||||
const [sortBy, setSortBy] = useState<'date' | 'rating'>('date');
|
const [sortBy, setSortBy] = useState<'date' | 'rating'>('date');
|
||||||
|
|
||||||
useEffect(() => {
|
// Use cached user reviews hook
|
||||||
fetchReviews();
|
const { data: reviews = [], isLoading: loading } = useUserReviews(userId, filter, sortBy);
|
||||||
}, [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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||||
|
import { useGlobalSearch } from '@/hooks/search/useGlobalSearch';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -20,57 +22,20 @@ type SearchResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function SearchResults({ query, onClose }: SearchResultsProps) {
|
export function SearchResults({ query, onClose }: SearchResultsProps) {
|
||||||
const [results, setResults] = useState<SearchResult[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
// Debounce search query
|
||||||
if (query.length >= 2) {
|
const debouncedQuery = useDebouncedValue(query, 300);
|
||||||
searchContent();
|
|
||||||
} else {
|
// Use global search hook with caching
|
||||||
setResults([]);
|
const { data, isLoading: loading } = useGlobalSearch(debouncedQuery);
|
||||||
}
|
|
||||||
}, [query]);
|
// Flatten results
|
||||||
|
const results: SearchResult[] = [
|
||||||
const searchContent = async () => {
|
...(data?.parks || []).map(park => ({ type: 'park' as const, data: park })),
|
||||||
setLoading(true);
|
...(data?.rides || []).map(ride => ({ type: 'ride' as const, data: ride })),
|
||||||
try {
|
...(data?.companies || []).map(company => ({ type: 'company' as const, data: company })),
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResultClick = (result: SearchResult) => {
|
const handleResultClick = (result: SearchResult) => {
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -110,5 +110,81 @@ export function useQueryInvalidation() {
|
|||||||
invalidateRides: () => {
|
invalidateRides: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['rides'] });
|
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']
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,50 @@ export const queryKeys = {
|
|||||||
closingSoonRides: () => ['homepage', 'closing-soon-rides'] as const,
|
closingSoonRides: () => ['homepage', 'closing-soon-rides'] as const,
|
||||||
recentlyClosedParks: () => ['homepage', 'recently-closed-parks'] as const,
|
recentlyClosedParks: () => ['homepage', 'recently-closed-parks'] as const,
|
||||||
recentlyClosedRides: () => ['homepage', 'recently-closed-rides'] 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;
|
} as const;
|
||||||
|
|||||||
@@ -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 { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
||||||
@@ -15,10 +15,12 @@ import { RideCard } from '@/components/rides/RideCard';
|
|||||||
import { Park, Ride } from '@/types/database';
|
import { Park, Ride } from '@/types/database';
|
||||||
import { ParkLocationMap } from '@/components/maps/ParkLocationMap';
|
import { ParkLocationMap } from '@/components/maps/ParkLocationMap';
|
||||||
import { EntityPhotoGallery } from '@/components/upload/EntityPhotoGallery';
|
import { EntityPhotoGallery } from '@/components/upload/EntityPhotoGallery';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { AdminFormSkeleton } from '@/components/loading/PageSkeletons';
|
import { AdminFormSkeleton } from '@/components/loading/PageSkeletons';
|
||||||
import { toast } from '@/hooks/use-toast';
|
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
|
// Lazy load admin forms
|
||||||
const RideForm = lazy(() => import('@/components/admin/RideForm').then(m => ({ default: m.RideForm })));
|
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';
|
import { useOpenGraph } from '@/hooks/useOpenGraph';
|
||||||
|
|
||||||
export default function ParkDetail() {
|
export default function ParkDetail() {
|
||||||
const {
|
const { slug } = useParams<{ slug: string }>();
|
||||||
slug
|
|
||||||
} = useParams<{
|
|
||||||
slug: string;
|
|
||||||
}>();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { requireAuth } = useAuthModal();
|
const { requireAuth } = useAuthModal();
|
||||||
const [park, setPark] = useState<Park | null>(null);
|
|
||||||
const [rides, setRides] = useState<Ride[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [isAddRideModalOpen, setIsAddRideModalOpen] = useState(false);
|
const [isAddRideModalOpen, setIsAddRideModalOpen] = useState(false);
|
||||||
const [isEditParkModalOpen, setIsEditParkModalOpen] = useState(false);
|
const [isEditParkModalOpen, setIsEditParkModalOpen] = useState(false);
|
||||||
const [photoCount, setPhotoCount] = useState<number>(0);
|
|
||||||
const [statsLoading, setStatsLoading] = useState(true);
|
|
||||||
const { isModerator } = useUserRole();
|
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
|
// Update document title when park changes
|
||||||
useDocumentTitle(park?.name || 'Park Details');
|
useDocumentTitle(park?.name || 'Park Details');
|
||||||
|
|
||||||
@@ -62,58 +64,6 @@ export default function ParkDetail() {
|
|||||||
type: 'website',
|
type: 'website',
|
||||||
enabled: !!park
|
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
|
// Track page view when park is loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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 { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
||||||
@@ -45,8 +45,9 @@ import { FormerNames } from '@/components/rides/FormerNames';
|
|||||||
import { RecentPhotosPreview } from '@/components/rides/RecentPhotosPreview';
|
import { RecentPhotosPreview } from '@/components/rides/RecentPhotosPreview';
|
||||||
import { ParkLocationMap } from '@/components/maps/ParkLocationMap';
|
import { ParkLocationMap } from '@/components/maps/ParkLocationMap';
|
||||||
import { Ride } from '@/types/database';
|
import { Ride } from '@/types/database';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { useRideDetail } from '@/hooks/rides/useRideDetail';
|
||||||
|
import { usePhotoCount } from '@/hooks/photos/usePhotoCount';
|
||||||
|
|
||||||
// Lazy load admin forms
|
// Lazy load admin forms
|
||||||
const RideForm = lazy(() => import('@/components/admin/RideForm').then(m => ({ default: m.RideForm })));
|
const RideForm = lazy(() => import('@/components/admin/RideForm').then(m => ({ default: m.RideForm })));
|
||||||
@@ -70,12 +71,17 @@ export default function RideDetail() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { isModerator } = useUserRole();
|
const { isModerator } = useUserRole();
|
||||||
const { requireAuth } = useAuthModal();
|
const { requireAuth } = useAuthModal();
|
||||||
const [ride, setRide] = useState<RideWithParkId | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [activeTab, setActiveTab] = useState("overview");
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
const [photoCount, setPhotoCount] = useState<number>(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
|
// Update document title when ride changes
|
||||||
useDocumentTitle(ride?.name || 'Ride Details');
|
useDocumentTitle(ride?.name || 'Ride Details');
|
||||||
@@ -90,12 +96,6 @@ export default function RideDetail() {
|
|||||||
enabled: !!ride
|
enabled: !!ride
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (parkSlug && rideSlug) {
|
|
||||||
fetchRideData();
|
|
||||||
}
|
|
||||||
}, [parkSlug, rideSlug]);
|
|
||||||
|
|
||||||
// Track page view when ride is loaded
|
// Track page view when ride is loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ride?.id) {
|
if (ride?.id) {
|
||||||
@@ -103,66 +103,6 @@ export default function RideDetail() {
|
|||||||
}
|
}
|
||||||
}, [ride?.id]);
|
}, [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) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'operating': return 'bg-green-500/20 text-green-400 border-green-500/30';
|
case 'operating': return 'bg-green-500/20 text-green-400 border-green-500/30';
|
||||||
|
|||||||
Reference in New Issue
Block a user