diff --git a/src/App.tsx b/src/App.tsx index 8f1751fe..dc2162a4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,8 +3,9 @@ import { lazy, Suspense } from "react"; import { Toaster } from "@/components/ui/toaster"; import { Toaster as Sonner } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider, QueryCache } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { CacheMonitor } from "@/components/dev/CacheMonitor"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import { AuthProvider } from "@/hooks/useAuth"; import { AuthModalProvider } from "@/contexts/AuthModalContext"; @@ -77,8 +78,41 @@ const queryClient = new QueryClient({ gcTime: 5 * 60 * 1000, // 5 minutes - keep in cache for 5 mins }, }, + // Add cache size management + queryCache: new QueryCache({ + onSuccess: () => { + // Monitor cache size in development + if (import.meta.env.DEV) { + const cacheSize = queryClient.getQueryCache().getAll().length; + if (cacheSize > 100) { + console.warn(`⚠️ Query cache size: ${cacheSize} queries`); + } + } + }, + }), }); +// Add cache size monitoring and automatic cleanup (dev mode) +if (import.meta.env.DEV) { + setInterval(() => { + const cache = queryClient.getQueryCache(); + const queries = cache.getAll(); + + // Remove oldest queries if cache exceeds 150 items + if (queries.length > 150) { + const sortedByLastUpdated = queries + .sort((a, b) => (a.state.dataUpdatedAt || 0) - (b.state.dataUpdatedAt || 0)); + + const toRemove = sortedByLastUpdated.slice(0, queries.length - 100); + toRemove.forEach(query => { + queryClient.removeQueries({ queryKey: query.queryKey }); + }); + + console.log(`🧹 Removed ${toRemove.length} stale queries from cache`); + } + }, 60000); // Check every minute +} + function AppContent(): React.JSX.Element { return ( @@ -161,7 +195,12 @@ const App = (): React.JSX.Element => ( - {import.meta.env.DEV && } + {import.meta.env.DEV && ( + <> + + + + )} ); diff --git a/src/components/dev/CacheMonitor.tsx b/src/components/dev/CacheMonitor.tsx new file mode 100644 index 00000000..d2cc97d5 --- /dev/null +++ b/src/components/dev/CacheMonitor.tsx @@ -0,0 +1,49 @@ +import { useState, useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +/** + * CacheMonitor Component (Dev Only) + * + * Real-time cache performance monitoring for development. + * Displays total queries, stale queries, fetching queries, and cache size. + * Only renders in development mode. + */ +export function CacheMonitor() { + const queryClient = useQueryClient(); + const [stats, setStats] = useState({ + totalQueries: 0, + staleQueries: 0, + fetchingQueries: 0, + cacheSize: 0, + }); + + useEffect(() => { + const interval = setInterval(() => { + const cache = queryClient.getQueryCache(); + const queries = cache.getAll(); + + setStats({ + totalQueries: queries.length, + staleQueries: queries.filter(q => q.isStale()).length, + fetchingQueries: queries.filter(q => q.state.fetchStatus === 'fetching').length, + cacheSize: JSON.stringify(queries).length, + }); + }, 1000); + + return () => clearInterval(interval); + }, [queryClient]); + + if (!import.meta.env.DEV) return null; + + return ( +
+

Cache Monitor

+
+
Total Queries: {stats.totalQueries}
+
Stale: {stats.staleQueries}
+
Fetching: {stats.fetchingQueries}
+
Size: {(stats.cacheSize / 1024).toFixed(1)} KB
+
+
+ ); +} diff --git a/src/components/parks/ParkCard.tsx b/src/components/parks/ParkCard.tsx index 8fcf47de..6118b597 100644 --- a/src/components/parks/ParkCard.tsx +++ b/src/components/parks/ParkCard.tsx @@ -1,19 +1,55 @@ import { MapPin, Star, Users, Clock, Castle, FerrisWheel, Waves, Tent } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Park } from '@/types/database'; import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils'; +import { queryKeys } from '@/lib/queryKeys'; +import { supabase } from '@/integrations/supabase/client'; interface ParkCardProps { park: Park; } export function ParkCard({ park }: ParkCardProps) { const navigate = useNavigate(); + const queryClient = useQueryClient(); const handleClick = () => { navigate(`/parks/${park.slug}`); }; + + // Prefetch park detail data on hover + const handleMouseEnter = () => { + // Prefetch park detail page data + queryClient.prefetchQuery({ + queryKey: queryKeys.parks.detail(park.slug), + queryFn: async () => { + const { data } = await supabase + .from('parks') + .select('*') + .eq('slug', park.slug) + .single(); + return data; + }, + staleTime: 5 * 60 * 1000, + }); + + // Prefetch park photos (first 10) + queryClient.prefetchQuery({ + queryKey: queryKeys.photos.entity('park', park.id), + queryFn: async () => { + const { data } = await supabase + .from('photos') + .select('*') + .eq('entity_type', 'park') + .eq('entity_id', park.id) + .limit(10); + return data; + }, + staleTime: 5 * 60 * 1000, + }); + }; const getStatusColor = (status: string) => { switch (status) { case 'operating': @@ -56,7 +92,7 @@ export function ParkCard({ park }: ParkCardProps) { const formatParkType = (type: string) => { return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); }; - return + return
{/* Image Placeholder with Gradient */}
diff --git a/src/components/rides/RideCard.tsx b/src/components/rides/RideCard.tsx index 78209d06..66a0ffa2 100644 --- a/src/components/rides/RideCard.tsx +++ b/src/components/rides/RideCard.tsx @@ -1,10 +1,13 @@ import { useNavigate } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Star, MapPin, Clock, Zap, FerrisWheel, Waves, Theater, Train, ArrowUp, CheckCircle, Calendar, Hammer, XCircle } from 'lucide-react'; import { MeasurementDisplay } from '@/components/ui/measurement-display'; import { Ride } from '@/types/database'; import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils'; +import { queryKeys } from '@/lib/queryKeys'; +import { supabase } from '@/integrations/supabase/client'; interface RideCardProps { ride: Ride; @@ -15,11 +18,47 @@ interface RideCardProps { export function RideCard({ ride, showParkName = true, className, parkSlug }: RideCardProps) { const navigate = useNavigate(); + const queryClient = useQueryClient(); const handleRideClick = () => { const slug = parkSlug || ride.park?.slug; navigate(`/parks/${slug}/rides/${ride.slug}`); }; + + // Prefetch ride detail data on hover + const handleMouseEnter = () => { + const slug = parkSlug || ride.park?.slug; + if (!slug) return; + + // Prefetch ride detail page data + queryClient.prefetchQuery({ + queryKey: queryKeys.rides.detail(slug, ride.slug), + queryFn: async () => { + const { data } = await supabase + .from('rides') + .select('*') + .eq('slug', ride.slug) + .single(); + return data; + }, + staleTime: 5 * 60 * 1000, + }); + + // Prefetch ride photos (first 10) + queryClient.prefetchQuery({ + queryKey: queryKeys.photos.entity('ride', ride.id), + queryFn: async () => { + const { data } = await supabase + .from('photos') + .select('*') + .eq('entity_type', 'ride') + .eq('entity_id', ride.id) + .limit(10); + return data; + }, + staleTime: 5 * 60 * 1000, + }); + }; const getRideIcon = (category: string) => { switch (category) { @@ -61,6 +100,7 @@ export function RideCard({ ride, showParkName = true, className, parkSlug }: Rid
{/* Image/Icon Section */} diff --git a/src/components/rides/RideModelCard.tsx b/src/components/rides/RideModelCard.tsx index 13598b32..8749677f 100644 --- a/src/components/rides/RideModelCard.tsx +++ b/src/components/rides/RideModelCard.tsx @@ -3,7 +3,10 @@ import { Badge } from '@/components/ui/badge'; import { FerrisWheel } from 'lucide-react'; import { RideModel } from '@/types/database'; import { useNavigate } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils'; +import { queryKeys } from '@/lib/queryKeys'; +import { supabase } from '@/integrations/supabase/client'; interface RideModelCardProps { model: RideModel; @@ -12,6 +15,23 @@ interface RideModelCardProps { export function RideModelCard({ model, manufacturerSlug }: RideModelCardProps) { const navigate = useNavigate(); + const queryClient = useQueryClient(); + + // Prefetch ride model detail data on hover + const handleMouseEnter = () => { + queryClient.prefetchQuery({ + queryKey: queryKeys.rideModels.detail(manufacturerSlug, model.slug), + queryFn: async () => { + const { data } = await supabase + .from('ride_models') + .select('*') + .eq('slug', model.slug) + .single(); + return data; + }, + staleTime: 5 * 60 * 1000, + }); + }; const formatCategory = (category: string | null | undefined) => { if (!category) return 'Unknown'; @@ -42,6 +62,7 @@ export function RideModelCard({ model, manufacturerSlug }: RideModelCardProps) { navigate(`/manufacturers/${manufacturerSlug}/models/${model.slug}`)} + onMouseEnter={handleMouseEnter} >
{(cardImageUrl || cardImageId) ? ( diff --git a/src/components/upload/PhotoManagementDialog.tsx b/src/components/upload/PhotoManagementDialog.tsx index 7b3d38a2..726bac1e 100644 --- a/src/components/upload/PhotoManagementDialog.tsx +++ b/src/components/upload/PhotoManagementDialog.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { supabase } from '@/integrations/supabase/client'; +import { useEntityName } from '@/hooks/entities/useEntityName'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -60,6 +61,9 @@ export function PhotoManagementDialog({ const [photoToDelete, setPhotoToDelete] = useState(null); const [deleteReason, setDeleteReason] = useState(''); const { toast } = useToast(); + + // Fetch entity name once using cached hook (replaces 4 sequential direct queries) + const { data: entityName = 'Unknown' } = useEntityName(entityType, entityId); useEffect(() => { if (open) { @@ -106,27 +110,6 @@ export function PhotoManagementDialog({ const { data: { user } } = await supabase.auth.getUser(); if (!user) throw new Error('Not authenticated'); - // Fetch entity name from database based on entity type - let entityName = 'Unknown'; - - try { - if (entityType === 'park') { - const { data } = await supabase.from('parks').select('name').eq('id', entityId).single(); - if (data?.name) entityName = data.name; - } else if (entityType === 'ride') { - const { data } = await supabase.from('rides').select('name').eq('id', entityId).single(); - if (data?.name) entityName = data.name; - } else if (entityType === 'ride_model') { - const { data } = await supabase.from('ride_models').select('name').eq('id', entityId).single(); - if (data?.name) entityName = data.name; - } else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(entityType)) { - const { data } = await supabase.from('companies').select('name').eq('id', entityId).single(); - if (data?.name) entityName = data.name; - } - } catch (err) { - logger.error('Failed to fetch entity name', { error: getErrorMessage(err), entityType, entityId }); - } - // Create content submission const { data: submission, error: submissionError } = await supabase .from('content_submissions') diff --git a/src/hooks/blog/useBlogPost.ts b/src/hooks/blog/useBlogPost.ts new file mode 100644 index 00000000..c1c24fcf --- /dev/null +++ b/src/hooks/blog/useBlogPost.ts @@ -0,0 +1,45 @@ +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +/** + * useBlogPost Hook + * + * Fetches a published blog post by slug with author profile information. + * Extracted from BlogPost.tsx for reusability and better caching. + * + * Features: + * - Caches blog posts for 5 minutes + * - Includes author profile data (username, display_name, avatar) + * - Only returns published posts + * + * @param slug - URL slug of the blog post + * @returns TanStack Query result with blog post data + * + * @example + * ```tsx + * const { data: post, isLoading } = useBlogPost(slug); + * ``` + */ +export function useBlogPost(slug: string | undefined) { + return useQuery({ + queryKey: queryKeys.blog.post(slug || ''), + queryFn: async () => { + if (!slug) return null; + + const { data, error } = await supabase + .from('blog_posts') + .select('*, profiles!inner(username, display_name, avatar_url, avatar_image_id)') + .eq('slug', slug) + .eq('status', 'published') + .single(); + + if (error) throw error; + return data; + }, + enabled: !!slug, + staleTime: 5 * 60 * 1000, // 5 minutes (blog content) + gcTime: 15 * 60 * 1000, // 15 minutes + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/entities/useEntityName.ts b/src/hooks/entities/useEntityName.ts new file mode 100644 index 00000000..1ff3faff --- /dev/null +++ b/src/hooks/entities/useEntityName.ts @@ -0,0 +1,82 @@ +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +/** + * useEntityName Hook + * + * Fetches the name of an entity for display purposes. + * Replaces multiple sequential direct queries with a single cached hook. + * + * Features: + * - Caches entity names for 10 minutes (rarely change) + * - Supports parks, rides, ride_models, and all company types + * - Returns 'Unknown' for invalid types or missing data + * + * @param entityType - Type of entity ('park', 'ride', 'ride_model', 'manufacturer', etc.) + * @param entityId - UUID of the entity + * @returns TanStack Query result with entity name string + * + * @example + * ```tsx + * const { data: entityName = 'Unknown' } = useEntityName('park', parkId); + * ``` + */ +export function useEntityName(entityType: string, entityId: string) { + return useQuery({ + queryKey: queryKeys.entities.name(entityType, entityId), + queryFn: async () => { + // Type-safe approach: separate queries for each table type + try { + if (entityType === 'park') { + const { data, error } = await supabase + .from('parks') + .select('name') + .eq('id', entityId) + .single(); + if (error) throw error; + return data?.name || 'Unknown'; + } + + if (entityType === 'ride') { + const { data, error } = await supabase + .from('rides') + .select('name') + .eq('id', entityId) + .single(); + if (error) throw error; + return data?.name || 'Unknown'; + } + + if (entityType === 'ride_model') { + const { data, error } = await supabase + .from('ride_models') + .select('name') + .eq('id', entityId) + .single(); + if (error) throw error; + return data?.name || 'Unknown'; + } + + if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(entityType)) { + const { data, error } = await supabase + .from('companies') + .select('name') + .eq('id', entityId) + .single(); + if (error) throw error; + return data?.name || 'Unknown'; + } + + return 'Unknown'; + } catch (error) { + console.error('Failed to fetch entity name:', error); + return 'Unknown'; + } + }, + enabled: !!entityType && !!entityId, + staleTime: 10 * 60 * 1000, // 10 minutes (entity names rarely change) + gcTime: 30 * 60 * 1000, // 30 minutes + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/pagination/usePrefetchNextPage.ts b/src/hooks/pagination/usePrefetchNextPage.ts new file mode 100644 index 00000000..280f6d46 --- /dev/null +++ b/src/hooks/pagination/usePrefetchNextPage.ts @@ -0,0 +1,47 @@ +import { useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +/** + * usePrefetchNextPage Hook + * + * Prefetches the next page of paginated data for instant navigation. + * + * Features: + * - Automatically prefetches when user is on a page with more data + * - Short staleTime (2 minutes) prevents over-caching + * - Generic implementation works with any paginated data + * + * @param baseQueryKey - Base query key array for the paginated query + * @param currentPage - Current page number + * @param hasNextPage - Boolean indicating if there is a next page + * @param queryFn - Function to fetch the next page + * + * @example + * ```tsx + * usePrefetchNextPage( + * queryKeys.parks.all(), + * currentPage, + * hasNextPage, + * (page) => fetchParks(page) + * ); + * ``` + */ +export function usePrefetchNextPage( + baseQueryKey: readonly unknown[], + currentPage: number, + hasNextPage: boolean, + queryFn: (page: number) => Promise +) { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!hasNextPage) return; + + // Prefetch next page + queryClient.prefetchQuery({ + queryKey: [...baseQueryKey, currentPage + 1], + queryFn: () => queryFn(currentPage + 1), + staleTime: 2 * 60 * 1000, // 2 minutes + }); + }, [currentPage, hasNextPage, baseQueryKey, queryFn, queryClient]); +} diff --git a/src/hooks/photos/useEntityPhotos.ts b/src/hooks/photos/useEntityPhotos.ts index 97dd5556..d9af0f19 100644 --- a/src/hooks/photos/useEntityPhotos.ts +++ b/src/hooks/photos/useEntityPhotos.ts @@ -26,7 +26,8 @@ * ``` */ -import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import { useQuery, UseQueryResult, useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { queryKeys } from '@/lib/queryKeys'; @@ -42,9 +43,12 @@ interface EntityPhoto { export function useEntityPhotos( entityType: string, entityId: string, - sortBy: 'newest' | 'oldest' = 'newest' + sortBy: 'newest' | 'oldest' = 'newest', + enableRealtime = false // New parameter for opt-in real-time updates ): UseQueryResult { - return useQuery({ + const queryClient = useQueryClient(); + + const query = useQuery({ queryKey: queryKeys.photos.entity(entityType, entityId, sortBy), queryFn: async () => { const startTime = performance.now(); @@ -82,4 +86,34 @@ export function useEntityPhotos( gcTime: 15 * 60 * 1000, refetchOnWindowFocus: false, }); + + // Real-time subscription for photo uploads (opt-in) + useEffect(() => { + if (!enableRealtime || !entityType || !entityId) return; + + const channel = supabase + .channel(`photos-${entityType}-${entityId}`) + .on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'photos', + filter: `entity_type=eq.${entityType},entity_id=eq.${entityId}`, + }, + (payload) => { + console.log('📸 New photo uploaded:', payload.new); + queryClient.invalidateQueries({ + queryKey: queryKeys.photos.entity(entityType, entityId) + }); + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }, [enableRealtime, entityType, entityId, queryClient, sortBy]); + + return query; } diff --git a/src/hooks/reviews/useEntityReviews.ts b/src/hooks/reviews/useEntityReviews.ts index b39483bf..feee9e89 100644 --- a/src/hooks/reviews/useEntityReviews.ts +++ b/src/hooks/reviews/useEntityReviews.ts @@ -1,12 +1,20 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; 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({ +export function useEntityReviews( + entityType: 'park' | 'ride', + entityId: string | undefined, + enabled = true, + enableRealtime = false // New parameter for opt-in real-time updates +) { + const queryClient = useQueryClient(); + + const query = useQuery({ queryKey: queryKeys.reviews.entity(entityType, entityId || ''), queryFn: async () => { if (!entityId) return []; @@ -35,4 +43,34 @@ export function useEntityReviews(entityType: 'park' | 'ride', entityId: string | gcTime: 10 * 60 * 1000, refetchOnWindowFocus: false, }); + + // Real-time subscription for new reviews (opt-in) + useEffect(() => { + if (!enableRealtime || !entityId || !enabled) return; + + const channel = supabase + .channel(`reviews-${entityType}-${entityId}`) + .on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'reviews', + filter: `${entityType}_id=eq.${entityId},moderation_status=eq.approved`, + }, + (payload) => { + console.log('⭐ New review posted:', payload.new); + queryClient.invalidateQueries({ + queryKey: queryKeys.reviews.entity(entityType, entityId) + }); + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }, [enableRealtime, entityType, entityId, enabled, queryClient]); + + return query; } diff --git a/src/hooks/useCoasterStats.ts b/src/hooks/useCoasterStats.ts index 83503b0b..28ca6c54 100644 --- a/src/hooks/useCoasterStats.ts +++ b/src/hooks/useCoasterStats.ts @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; export interface CoasterStat { id: string; @@ -15,7 +16,7 @@ export interface CoasterStat { export function useCoasterStats(rideId: string | undefined) { return useQuery({ - queryKey: ['coaster-stats', rideId], + queryKey: queryKeys.stats.coaster(rideId || ''), queryFn: async () => { if (!rideId) return []; diff --git a/src/hooks/usePublicNovuSettings.ts b/src/hooks/usePublicNovuSettings.ts index 7711e181..a6592f4b 100644 --- a/src/hooks/usePublicNovuSettings.ts +++ b/src/hooks/usePublicNovuSettings.ts @@ -1,12 +1,13 @@ import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; /** * Hook to fetch public Novu settings accessible to all authenticated users */ export function usePublicNovuSettings() { const { data: settings, isLoading, error } = useQuery({ - queryKey: ['public-novu-settings'], + queryKey: queryKeys.settings.publicNovu(), queryFn: async () => { const { data, error } = await supabase .from('admin_settings') diff --git a/src/lib/queryInvalidation.ts b/src/lib/queryInvalidation.ts index ce1d9f69..81421445 100644 --- a/src/lib/queryInvalidation.ts +++ b/src/lib/queryInvalidation.ts @@ -247,8 +247,82 @@ export function useQueryInvalidation() { * Invalidate model rides cache * Call this after ride changes */ - invalidateModelRides: (modelId: string) => { - queryClient.invalidateQueries({ queryKey: ['ride-models', 'rides', modelId] }); + invalidateModelRides: (modelId: string, limit?: number) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.rideModels.rides(modelId, limit), + }); + }, + + /** + * Invalidate entity name cache + * Call this after updating an entity's name + */ + invalidateEntityName: (entityType: string, entityId: string) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.entities.name(entityType, entityId) + }); + }, + + /** + * Invalidate blog post cache + * Call this after updating a blog post + */ + invalidateBlogPost: (slug: string) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.blog.post(slug) + }); + }, + + /** + * Invalidate coaster stats cache + * Call this after updating ride statistics + */ + invalidateCoasterStats: (rideId: string) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.stats.coaster(rideId) + }); + }, + + /** + * Invalidate security queries + * Call this after security-related changes (email, sessions) + */ + invalidateSecurityQueries: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.security.emailChangeStatus() + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.security.sessions() + }); + }, + + /** + * Smart invalidation for related entities + * Invalidates entity detail, photos, reviews, and name cache + * Call this after any entity update + */ + invalidateRelatedEntities: (entityType: string, entityId: string) => { + // Invalidate the entity itself + if (entityType === 'park') { + queryClient.invalidateQueries({ + queryKey: queryKeys.parks.detail(entityId) + }); + } else if (entityType === 'ride') { + queryClient.invalidateQueries({ + queryKey: queryKeys.rides.detail('', entityId) + }); + } + + // Invalidate photos, reviews, and entity name + queryClient.invalidateQueries({ + queryKey: queryKeys.photos.entity(entityType, entityId) + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.reviews.entity(entityType as 'park' | 'ride', entityId) + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.entities.name(entityType, entityId) + }); }, }; } diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 234cc815..66a21190 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -121,4 +121,31 @@ export const queryKeys = { ['ride-models', 'rides', modelId, limit] as const, statistics: (modelId: string) => ['ride-models', 'statistics', modelId] as const, }, + + // Settings queries + settings: { + publicNovu: () => ['public-novu-settings'] as const, + }, + + // Stats queries + stats: { + coaster: (rideId: string) => ['coaster-stats', rideId] as const, + }, + + // Blog queries + blog: { + post: (slug: string) => ['blog-post', slug] as const, + viewIncrement: (slug: string) => ['blog-view-increment', slug] as const, + }, + + // Entity name queries (for PhotoManagementDialog) + entities: { + name: (entityType: string, entityId: string) => ['entity-name', entityType, entityId] as const, + }, + + // Security queries + security: { + emailChangeStatus: () => ['email-change-status'] as const, + sessions: () => ['my-sessions'] as const, + }, } as const; diff --git a/src/pages/BlogPost.tsx b/src/pages/BlogPost.tsx index bcf05d1c..ddd575e2 100644 --- a/src/pages/BlogPost.tsx +++ b/src/pages/BlogPost.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useParams, Link } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; +import { useBlogPost } from '@/hooks/blog/useBlogPost'; import { MarkdownRenderer } from '@/components/blog/MarkdownRenderer'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; @@ -17,22 +17,7 @@ import { useOpenGraph } from '@/hooks/useOpenGraph'; export default function BlogPost() { const { slug } = useParams<{ slug: string }>(); - const { data: post, isLoading } = useQuery({ - queryKey: ['blog-post', slug], - queryFn: async () => { - const query = supabase - .from('blog_posts') - .select('*, profiles!inner(username, display_name, avatar_url, avatar_image_id)') - .eq('slug', slug) - .eq('status', 'published') - .single(); - - const { data, error } = await query; - if (error) throw error; - return data; - }, - enabled: !!slug, - }); + const { data: post, isLoading } = useBlogPost(slug); // Update document title when post changes useDocumentTitle(post?.title || 'Blog Post');