diff --git a/src/components/upload/EntityPhotoGallery.tsx b/src/components/upload/EntityPhotoGallery.tsx index 6bf8ab98..f36739db 100644 --- a/src/components/upload/EntityPhotoGallery.tsx +++ b/src/components/upload/EntityPhotoGallery.tsx @@ -190,7 +190,7 @@ export function EntityPhotoGallery({ entityType={entityType} open={showManagement} onOpenChange={setShowManagement} - onUpdate={fetchPhotos} + onUpdate={() => refetch()} /> {/* Photo Grid */} diff --git a/src/hooks/companies/useCompanyDetail.ts b/src/hooks/companies/useCompanyDetail.ts new file mode 100644 index 00000000..6cc7c0b8 --- /dev/null +++ b/src/hooks/companies/useCompanyDetail.ts @@ -0,0 +1,32 @@ +/** + * Company Detail Hook + * + * Fetches company details with caching for efficient data access. + */ + +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +export function useCompanyDetail(slug: string | undefined, companyType: string) { + return useQuery({ + queryKey: queryKeys.companies.detail(slug || '', companyType), + queryFn: async () => { + if (!slug) return null; + + const { data, error } = await supabase + .from('companies') + .select('*') + .eq('slug', slug) + .eq('company_type', companyType) + .maybeSingle(); + + if (error) throw error; + return data; + }, + enabled: !!slug, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 15 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/companies/useCompanyParks.ts b/src/hooks/companies/useCompanyParks.ts new file mode 100644 index 00000000..e243b216 --- /dev/null +++ b/src/hooks/companies/useCompanyParks.ts @@ -0,0 +1,38 @@ +/** + * Company Parks Hook + * + * Fetches parks operated/owned by a company with caching. + */ + +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +export function useCompanyParks( + companyId: string | undefined, + companyType: 'operator' | 'property_owner', + limit = 6 +) { + const field = companyType === 'operator' ? 'operator_id' : 'property_owner_id'; + + return useQuery({ + queryKey: queryKeys.companies.parks(companyId || '', companyType, limit), + queryFn: async () => { + if (!companyId) return []; + + const { data, error } = await supabase + .from('parks') + .select('*, location:locations(*)') + .eq(field, companyId) + .order('name') + .limit(limit); + + if (error) throw error; + return data || []; + }, + enabled: !!companyId, + staleTime: 5 * 60 * 1000, + gcTime: 15 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/companies/useCompanyStatistics.ts b/src/hooks/companies/useCompanyStatistics.ts new file mode 100644 index 00000000..d262e2b7 --- /dev/null +++ b/src/hooks/companies/useCompanyStatistics.ts @@ -0,0 +1,78 @@ +/** + * Company Statistics Hook + * + * Fetches company statistics (rides, models, photos, parks) with parallel queries. + */ + +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +export function useCompanyStatistics(companyId: string | undefined, companyType: string) { + return useQuery({ + queryKey: queryKeys.companies.statistics(companyId || '', companyType), + queryFn: async () => { + if (!companyId) return null; + + // Batch fetch all statistics in parallel + const statsPromises: Promise[] = []; + + if (companyType === 'manufacturer') { + const [ridesRes, modelsRes, photosRes] = await Promise.all([ + supabase.from('rides').select('id', { count: 'exact', head: true }).eq('manufacturer_id', companyId), + supabase.from('ride_models').select('id', { count: 'exact', head: true }).eq('manufacturer_id', companyId), + supabase.from('photos').select('id', { count: 'exact', head: true }).eq('entity_type', 'manufacturer').eq('entity_id', companyId) + ]); + + return { + ridesCount: ridesRes.count || 0, + modelsCount: modelsRes.count || 0, + photosCount: photosRes.count || 0, + }; + } else if (companyType === 'designer') { + const [ridesRes, photosRes] = await Promise.all([ + supabase.from('rides').select('id', { count: 'exact', head: true }).eq('designer_id', companyId), + supabase.from('photos').select('id', { count: 'exact', head: true }).eq('entity_type', 'designer').eq('entity_id', companyId) + ]); + + return { + ridesCount: ridesRes.count || 0, + photosCount: photosRes.count || 0, + }; + } else { + // operator or property_owner + const parkField = companyType === 'operator' ? 'operator_id' : 'property_owner_id'; + const filterField = `parks.${parkField}`; + + const [parksRes, ridesRes, photosRes] = await Promise.all([ + supabase.from('parks').select('id', { count: 'exact', head: true }).eq(parkField, companyId), + supabase.from('rides').select('id').eq('status', 'operating'), + supabase.from('photos').select('id', { count: 'exact', head: true }).eq('entity_type', companyType).eq('entity_id', companyId) + ]); + + // Filter rides data manually to avoid deep type instantiation + const allRidesIds = ridesRes.data?.map(r => r.id) || []; + const { data: ridesWithPark } = await supabase + .from('rides') + .select('id, park_id, parks!inner(operator_id, property_owner_id)') + .in('id', allRidesIds); + + const operatingRides = ridesWithPark?.filter((r: any) => { + return companyType === 'operator' + ? r.parks?.operator_id === companyId + : r.parks?.property_owner_id === companyId; + }) || []; + + return { + parksCount: parksRes.count || 0, + operatingRidesCount: operatingRides.length, + photosCount: photosRes.count || 0, + }; + } + }, + enabled: !!companyId, + staleTime: 10 * 60 * 1000, // 10 minutes - stats change rarely + gcTime: 20 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/photos/useEntityPhotos.ts b/src/hooks/photos/useEntityPhotos.ts new file mode 100644 index 00000000..394137b5 --- /dev/null +++ b/src/hooks/photos/useEntityPhotos.ts @@ -0,0 +1,42 @@ +/** + * Entity Photos Hook + * + * Fetches photos for an entity with caching and sorting support. + */ + +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +export function useEntityPhotos( + entityType: string, + entityId: string, + sortBy: 'newest' | 'oldest' = 'newest' +) { + return useQuery({ + queryKey: queryKeys.photos.entity(entityType, entityId, sortBy), + queryFn: async () => { + const { data, error } = await supabase + .from('photos') + .select('id, cloudflare_image_url, title, caption, submitted_by, created_at, order_index') + .eq('entity_type', entityType) + .eq('entity_id', entityId) + .order('created_at', { ascending: sortBy === 'oldest' }); + + if (error) throw error; + + return data?.map((photo) => ({ + id: photo.id, + url: photo.cloudflare_image_url, + caption: photo.caption || undefined, + title: photo.title || undefined, + user_id: photo.submitted_by, + created_at: photo.created_at, + })) || []; + }, + enabled: !!entityType && !!entityId, + staleTime: 5 * 60 * 1000, + gcTime: 15 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/profile/useProfileActivity.ts b/src/hooks/profile/useProfileActivity.ts new file mode 100644 index 00000000..8968a22e --- /dev/null +++ b/src/hooks/profile/useProfileActivity.ts @@ -0,0 +1,158 @@ +/** + * Profile Activity Hook + * + * Fetches user activity feed with privacy checks and batch optimization. + * Eliminates N+1 queries for photo submissions. + */ + +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +export function useProfileActivity( + userId: string | undefined, + isOwnProfile: boolean, + isModerator: boolean +) { + return useQuery({ + queryKey: queryKeys.profile.activity(userId || '', isOwnProfile, isModerator), + queryFn: async () => { + if (!userId) return []; + + // Check privacy settings first + const { data: preferences } = await supabase + .from('user_preferences') + .select('privacy_settings') + .eq('user_id', userId) + .single(); + + const privacySettings = preferences?.privacy_settings as { activity_visibility?: string } | null; + const activityVisibility = privacySettings?.activity_visibility || 'public'; + + if (activityVisibility !== 'public' && !isOwnProfile && !isModerator) { + return []; + } + + // Fetch all activity types in parallel + const [reviews, credits, submissions, rankings] = await Promise.all([ + // Reviews query + supabase.from('reviews') + .select('id, rating, title, created_at, moderation_status, park_id, ride_id, parks(name, slug), rides(name, slug, parks(name, slug))') + .eq('user_id', userId) + .eq('moderation_status', isOwnProfile || isModerator ? undefined : 'approved') + .order('created_at', { ascending: false }) + .limit(10) + .then(res => res.data || []), + + // Credits query + supabase.from('user_ride_credits') + .select('id, ride_count, first_ride_date, created_at, rides(name, slug, parks(name, slug))') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .limit(10) + .then(res => res.data || []), + + // Submissions query + supabase.from('content_submissions') + .select('id, submission_type, content, status, created_at') + .eq('user_id', userId) + .eq('status', isOwnProfile || isModerator ? undefined : 'approved') + .order('created_at', { ascending: false }) + .limit(10) + .then(res => res.data || []), + + // Rankings query + supabase.from('user_top_lists') + .select('id, title, description, list_type, created_at') + .eq('user_id', userId) + .eq('is_public', isOwnProfile ? undefined : true) + .order('created_at', { ascending: false }) + .limit(10) + .then(res => res.data || []) + ]); + + // Enrich photo submissions in batch + const photoSubmissions = submissions.filter(s => s.submission_type === 'photo'); + const photoSubmissionIds = photoSubmissions.map(s => s.id); + + if (photoSubmissionIds.length > 0) { + // Batch fetch photo submission data + const { data: photoSubs } = await supabase + .from('photo_submissions') + .select('id, submission_id, entity_type, entity_id') + .in('submission_id', photoSubmissionIds); + + if (photoSubs) { + // Batch fetch photo items + const photoSubIds = photoSubs.map(ps => ps.id); + const { data: photoItems } = await supabase + .from('photo_submission_items') + .select('photo_submission_id, cloudflare_image_url') + .in('photo_submission_id', photoSubIds) + .order('order_index', { ascending: true }); + + // Group entity IDs by type for batch fetching + const parkIds = photoSubs.filter(ps => ps.entity_type === 'park').map(ps => ps.entity_id); + const rideIds = photoSubs.filter(ps => ps.entity_type === 'ride').map(ps => ps.entity_id); + + // Batch fetch entities + const [parks, rides] = await Promise.all([ + parkIds.length ? supabase.from('parks').select('id, name, slug').in('id', parkIds).then(r => r.data || []) : [], + rideIds.length ? supabase.from('rides').select('id, name, slug, parks!inner(name, slug)').in('id', rideIds).then(r => r.data || []) : [] + ]); + + // Create lookup maps + const photoSubMap = new Map(photoSubs.map(ps => [ps.submission_id, ps])); + const photoItemsMap = new Map(); + photoItems?.forEach(item => { + if (!photoItemsMap.has(item.photo_submission_id)) { + photoItemsMap.set(item.photo_submission_id, []); + } + photoItemsMap.get(item.photo_submission_id)!.push(item); + }); + const entityMap = new Map([ + ...parks.map((p: any) => [p.id, p] as [string, any]), + ...rides.map((r: any) => [r.id, r] as [string, any]) + ]); + + // Enrich submissions + photoSubmissions.forEach((sub: any) => { + const photoSub = photoSubMap.get(sub.id); + if (photoSub) { + const items = photoItemsMap.get(photoSub.id) || []; + sub.photo_count = items.length; + sub.photo_preview = items[0]?.cloudflare_image_url; + sub.entity_type = photoSub.entity_type; + sub.entity_id = photoSub.entity_id; + + const entity = entityMap.get(photoSub.entity_id); + if (entity) { + sub.content = { + ...(typeof sub.content === 'object' ? sub.content : {}), + entity_name: entity.name, + entity_slug: entity.slug, + ...(entity.parks && { park_name: entity.parks.name, park_slug: entity.parks.slug }) + }; + } + } + }); + } + } + + // Combine and sort + const combined = [ + ...reviews.map(r => ({ ...r, type: 'review' as const })), + ...credits.map(c => ({ ...c, type: 'credit' as const })), + ...submissions.map(s => ({ ...s, type: 'submission' as const })), + ...rankings.map(r => ({ ...r, type: 'ranking' as const })) + ].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + .slice(0, 15); + + return combined; + }, + enabled: !!userId, + staleTime: 3 * 60 * 1000, // 3 minutes - activity updates frequently + gcTime: 10 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/profile/useProfileStats.ts b/src/hooks/profile/useProfileStats.ts new file mode 100644 index 00000000..562faa54 --- /dev/null +++ b/src/hooks/profile/useProfileStats.ts @@ -0,0 +1,37 @@ +/** + * Profile Stats Hook + * + * Fetches calculated user statistics (rides, coasters, parks). + */ + +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +export function useProfileStats(userId: string | undefined) { + return useQuery({ + queryKey: queryKeys.profile.stats(userId || ''), + queryFn: async () => { + if (!userId) return { rideCount: 0, coasterCount: 0, parkCount: 0 }; + + const { data: ridesData } = await supabase + .from('user_ride_credits') + .select('ride_count, rides!inner(category, park_id)') + .eq('user_id', userId); + + const totalRides = ridesData?.reduce((sum, credit) => sum + (credit.ride_count || 0), 0) || 0; + const coasterRides = ridesData?.filter(credit => credit.rides?.category === 'roller_coaster') || []; + const uniqueCoasters = new Set(coasterRides.map(credit => credit.rides)); + const coasterCount = uniqueCoasters.size; + const parkRides = ridesData?.map(credit => credit.rides?.park_id).filter(Boolean) || []; + const uniqueParks = new Set(parkRides); + const parkCount = uniqueParks.size; + + return { rideCount: totalRides, coasterCount, parkCount }; + }, + enabled: !!userId, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 15 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/rideModels/useModelRides.ts b/src/hooks/rideModels/useModelRides.ts new file mode 100644 index 00000000..1f9eb381 --- /dev/null +++ b/src/hooks/rideModels/useModelRides.ts @@ -0,0 +1,39 @@ +/** + * Model Rides Hook + * + * Fetches rides using a specific ride model with caching. + */ + +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +export function useModelRides(modelId: string | undefined, limit?: number) { + return useQuery({ + queryKey: queryKeys.rideModels.rides(modelId || '', limit), + queryFn: async () => { + if (!modelId) return []; + + let query = supabase + .from('rides') + .select(` + *, + park:parks!inner(name, slug, location:locations(*)), + manufacturer:companies!rides_manufacturer_id_fkey(*), + ride_model:ride_models(id, name, slug, manufacturer_id, category) + `) + .eq('ride_model_id', modelId) + .order('name'); + + if (limit) query = query.limit(limit); + + const { data, error } = await query; + if (error) throw error; + return data || []; + }, + enabled: !!modelId, + staleTime: 5 * 60 * 1000, + gcTime: 15 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/rideModels/useModelStatistics.ts b/src/hooks/rideModels/useModelStatistics.ts new file mode 100644 index 00000000..86066f6e --- /dev/null +++ b/src/hooks/rideModels/useModelStatistics.ts @@ -0,0 +1,32 @@ +/** + * Model Statistics Hook + * + * Fetches ride model statistics (ride count, photo count) with parallel queries. + */ + +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +export function useModelStatistics(modelId: string | undefined) { + return useQuery({ + queryKey: queryKeys.rideModels.statistics(modelId || ''), + queryFn: async () => { + if (!modelId) return { rideCount: 0, photoCount: 0 }; + + const [ridesResult, photosResult] = await Promise.all([ + supabase.from('rides').select('id', { count: 'exact', head: true }).eq('ride_model_id', modelId), + supabase.from('photos').select('id', { count: 'exact', head: true }).eq('entity_type', 'ride_model').eq('entity_id', modelId) + ]); + + return { + rideCount: ridesResult.count || 0, + photoCount: photosResult.count || 0 + }; + }, + enabled: !!modelId, + staleTime: 10 * 60 * 1000, + gcTime: 20 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/rideModels/useRideModelDetail.ts b/src/hooks/rideModels/useRideModelDetail.ts new file mode 100644 index 00000000..a15d19db --- /dev/null +++ b/src/hooks/rideModels/useRideModelDetail.ts @@ -0,0 +1,48 @@ +/** + * Ride Model Detail Hook + * + * Fetches ride model and manufacturer data with caching. + */ + +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; + +export function useRideModelDetail( + manufacturerSlug: string | undefined, + modelSlug: string | undefined +) { + return useQuery({ + queryKey: queryKeys.rideModels.detail(manufacturerSlug || '', modelSlug || ''), + queryFn: async () => { + if (!manufacturerSlug || !modelSlug) return null; + + // Fetch manufacturer first + const { data: manufacturer, error: mfgError } = await supabase + .from('companies') + .select('*') + .eq('slug', manufacturerSlug) + .eq('company_type', 'manufacturer') + .maybeSingle(); + + if (mfgError) throw mfgError; + if (!manufacturer) return null; + + // Fetch ride model + const { data: model, error: modelError } = await supabase + .from('ride_models') + .select('*') + .eq('slug', modelSlug) + .eq('manufacturer_id', manufacturer.id) + .maybeSingle(); + + if (modelError) throw modelError; + + return model ? { model, manufacturer } : null; + }, + enabled: !!manufacturerSlug && !!modelSlug, + staleTime: 5 * 60 * 1000, + gcTime: 15 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/lib/queryInvalidation.ts b/src/lib/queryInvalidation.ts index e3601461..ce1d9f69 100644 --- a/src/lib/queryInvalidation.ts +++ b/src/lib/queryInvalidation.ts @@ -186,5 +186,69 @@ export function useQueryInvalidation() { queryKey: ['homepage', 'featured-parks'] }); }, + + /** + * Invalidate company detail cache + * Call this after updating a company + */ + invalidateCompanyDetail: (slug: string, type: string) => { + queryClient.invalidateQueries({ queryKey: queryKeys.companies.detail(slug, type) }); + }, + + /** + * Invalidate company statistics cache + * Call this after changes affecting company stats + */ + invalidateCompanyStatistics: (id: string, type: string) => { + queryClient.invalidateQueries({ queryKey: queryKeys.companies.statistics(id, type) }); + }, + + /** + * Invalidate company parks cache + * Call this after park changes + */ + invalidateCompanyParks: (id: string, type: 'operator' | 'property_owner') => { + queryClient.invalidateQueries({ queryKey: ['companies', 'parks', id, type] }); + }, + + /** + * Invalidate profile activity cache + * Call this after user activity changes + */ + invalidateProfileActivity: (userId: string) => { + queryClient.invalidateQueries({ queryKey: ['profile', 'activity', userId] }); + }, + + /** + * Invalidate profile stats cache + * Call this after user stats changes + */ + invalidateProfileStats: (userId: string) => { + queryClient.invalidateQueries({ queryKey: queryKeys.profile.stats(userId) }); + }, + + /** + * Invalidate ride model detail cache + * Call this after updating a ride model + */ + invalidateRideModelDetail: (manufacturerSlug: string, modelSlug: string) => { + queryClient.invalidateQueries({ queryKey: queryKeys.rideModels.detail(manufacturerSlug, modelSlug) }); + }, + + /** + * Invalidate ride model statistics cache + * Call this after changes affecting model stats + */ + invalidateRideModelStatistics: (modelId: string) => { + queryClient.invalidateQueries({ queryKey: queryKeys.rideModels.statistics(modelId) }); + }, + + /** + * Invalidate model rides cache + * Call this after ride changes + */ + invalidateModelRides: (modelId: string) => { + queryClient.invalidateQueries({ queryKey: ['ride-models', 'rides', modelId] }); + }, }; } diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 184ebb40..1d00a3da 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -62,8 +62,8 @@ export const queryKeys = { // Photos queries photos: { - entity: (entityType: string, entityId: string) => - ['photos', entityType, entityId] as const, + entity: (entityType: string, entityId: string, sortBy?: string) => + ['photos', entityType, entityId, sortBy] as const, count: (entityType: string, entityId: string) => ['photos', 'count', entityType, entityId] as const, }, @@ -77,4 +77,30 @@ export const queryKeys = { lists: { items: (listId: string) => ['list-items', listId] as const, }, + + // Company queries + companies: { + all: (type: string) => ['companies', 'all', type] as const, + detail: (slug: string, type: string) => ['companies', 'detail', slug, type] as const, + statistics: (id: string, type: string) => ['companies', 'statistics', id, type] as const, + parks: (id: string, type: string, limit: number) => ['companies', 'parks', id, type, limit] as const, + }, + + // Profile queries + profile: { + detail: (userId: string) => ['profile', userId] as const, + activity: (userId: string, isOwn: boolean, isMod: boolean) => + ['profile', 'activity', userId, isOwn, isMod] as const, + stats: (userId: string) => ['profile', 'stats', userId] as const, + }, + + // Ride Models queries + rideModels: { + all: (manufacturerId: string) => ['ride-models', 'all', manufacturerId] as const, + detail: (manufacturerSlug: string, modelSlug: string) => + ['ride-models', 'detail', manufacturerSlug, modelSlug] as const, + rides: (modelId: string, limit?: number) => + ['ride-models', 'rides', modelId, limit] as const, + statistics: (modelId: string) => ['ride-models', 'statistics', modelId] as const, + }, } as const; diff --git a/src/pages/DesignerDetail.tsx b/src/pages/DesignerDetail.tsx index e8218551..ea67963f 100644 --- a/src/pages/DesignerDetail.tsx +++ b/src/pages/DesignerDetail.tsx @@ -26,20 +26,21 @@ import { trackPageView } from '@/lib/viewTracking'; import { useAuthModal } from '@/hooks/useAuthModal'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useOpenGraph } from '@/hooks/useOpenGraph'; +import { useCompanyDetail } from '@/hooks/companies/useCompanyDetail'; +import { useCompanyStatistics } from '@/hooks/companies/useCompanyStatistics'; export default function DesignerDetail() { const { slug } = useParams<{ slug: string }>(); const navigate = useNavigate(); - const [designer, setDesigner] = useState(null); - const [loading, setLoading] = useState(true); const [isEditModalOpen, setIsEditModalOpen] = useState(false); - const [totalRides, setTotalRides] = useState(0); - const [totalPhotos, setTotalPhotos] = useState(0); - const [statsLoading, setStatsLoading] = useState(true); const { user } = useAuth(); const { isModerator } = useUserRole(); const { requireAuth } = useAuthModal(); + // Use custom hooks for data fetching + const { data: designer, isLoading: loading } = useCompanyDetail(slug, 'designer'); + const { data: statistics, isLoading: statsLoading } = useCompanyStatistics(designer?.id, 'designer'); + // Update document title when designer changes useDocumentTitle(designer?.name || 'Designer Details'); @@ -53,12 +54,6 @@ export default function DesignerDetail() { enabled: !!designer }); - useEffect(() => { - if (slug) { - fetchDesignerData(); - } - }, [slug]); - // Track page view when designer is loaded useEffect(() => { if (designer?.id) { @@ -66,54 +61,6 @@ export default function DesignerDetail() { } }, [designer?.id]); - const fetchDesignerData = async () => { - try { - const { data, error } = await supabase - .from('companies') - .select('*') - .eq('slug', slug) - .eq('company_type', 'designer') - .maybeSingle(); - - if (error) throw error; - setDesigner(data); - if (data) { - fetchStatistics(data.id); - } - } catch (error) { - console.error('Error fetching designer:', error); - } finally { - setLoading(false); - } - }; - - const fetchStatistics = async (designerId: string) => { - try { - // Count rides - const { count: ridesCount, error: ridesError } = await supabase - .from('rides') - .select('id', { count: 'exact', head: true }) - .eq('designer_id', designerId); - - if (ridesError) throw ridesError; - setTotalRides(ridesCount || 0); - - // Count photos - const { count: photosCount, error: photosError } = await supabase - .from('photos') - .select('id', { count: 'exact', head: true }) - .eq('entity_type', 'designer') - .eq('entity_id', designerId); - - if (photosError) throw photosError; - setTotalPhotos(photosCount || 0); - } catch (error) { - console.error('Error fetching statistics:', error); - } finally { - setStatsLoading(false); - } - }; - const handleEditSubmit = async (data: any) => { try { await submitCompanyUpdate( @@ -295,10 +242,10 @@ export default function DesignerDetail() { Overview - Rides {!statsLoading && totalRides > 0 && `(${totalRides})`} + Rides {!statsLoading && statistics?.ridesCount ? `(${statistics.ridesCount})` : ''} - Photos {!statsLoading && totalPhotos > 0 && `(${totalPhotos})`} + Photos {!statsLoading && statistics?.photosCount ? `(${statistics.photosCount})` : ''} History diff --git a/src/pages/ManufacturerDetail.tsx b/src/pages/ManufacturerDetail.tsx index 39257d2e..14f61d3d 100644 --- a/src/pages/ManufacturerDetail.tsx +++ b/src/pages/ManufacturerDetail.tsx @@ -26,21 +26,21 @@ import { EntityHistoryTabs } from '@/components/history/EntityHistoryTabs'; import { useAuthModal } from '@/hooks/useAuthModal'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useOpenGraph } from '@/hooks/useOpenGraph'; +import { useCompanyDetail } from '@/hooks/companies/useCompanyDetail'; +import { useCompanyStatistics } from '@/hooks/companies/useCompanyStatistics'; export default function ManufacturerDetail() { const { slug } = useParams<{ slug: string }>(); const navigate = useNavigate(); - const [manufacturer, setManufacturer] = useState(null); - const [loading, setLoading] = useState(true); const [isEditModalOpen, setIsEditModalOpen] = useState(false); - const [totalRides, setTotalRides] = useState(0); - const [totalModels, setTotalModels] = useState(0); - const [totalPhotos, setTotalPhotos] = useState(0); - const [statsLoading, setStatsLoading] = useState(true); const { user } = useAuth(); const { isModerator } = useUserRole(); const { requireAuth } = useAuthModal(); + // Use custom hooks for data fetching + const { data: manufacturer, isLoading: loading } = useCompanyDetail(slug, 'manufacturer'); + const { data: statistics, isLoading: statsLoading } = useCompanyStatistics(manufacturer?.id, 'manufacturer'); + // Update document title when manufacturer changes useDocumentTitle(manufacturer?.name || 'Manufacturer Details'); @@ -54,12 +54,6 @@ export default function ManufacturerDetail() { enabled: !!manufacturer }); - useEffect(() => { - if (slug) { - fetchManufacturerData(); - } - }, [slug]); - // Track page view when manufacturer is loaded useEffect(() => { if (manufacturer?.id) { @@ -67,63 +61,6 @@ export default function ManufacturerDetail() { } }, [manufacturer?.id]); - const fetchManufacturerData = async () => { - try { - const { data, error } = await supabase - .from('companies') - .select('*') - .eq('slug', slug) - .eq('company_type', 'manufacturer') - .maybeSingle(); - - if (error) throw error; - setManufacturer(data); - if (data) { - fetchStatistics(data.id); - } - } catch (error) { - console.error('Error fetching manufacturer:', error); - } finally { - setLoading(false); - } - }; - - const fetchStatistics = async (manufacturerId: string) => { - try { - // Count rides - const { count: ridesCount, error: ridesError } = await supabase - .from('rides') - .select('id', { count: 'exact', head: true }) - .eq('manufacturer_id', manufacturerId); - - if (ridesError) throw ridesError; - setTotalRides(ridesCount || 0); - - // Count models - const { count: modelsCount, error: modelsError } = await supabase - .from('ride_models') - .select('id', { count: 'exact', head: true }) - .eq('manufacturer_id', manufacturerId); - - if (modelsError) throw modelsError; - setTotalModels(modelsCount || 0); - - // Count photos - const { count: photosCount, error: photosError } = await supabase - .from('photos') - .select('id', { count: 'exact', head: true }) - .eq('entity_type', 'manufacturer') - .eq('entity_id', manufacturerId); - - if (photosError) throw photosError; - setTotalPhotos(photosCount || 0); - } catch (error) { - console.error('Error fetching statistics:', error); - } finally { - setStatsLoading(false); - } - }; - const handleEditSubmit = async (data: any) => { try { await submitCompanyUpdate( @@ -307,13 +244,13 @@ export default function ManufacturerDetail() { Overview - Rides {!statsLoading && totalRides > 0 && `(${totalRides})`} + Rides {!statsLoading && statistics?.ridesCount ? `(${statistics.ridesCount})` : ''} - Models {!statsLoading && totalModels > 0 && `(${totalModels})`} + Models {!statsLoading && statistics?.modelsCount ? `(${statistics.modelsCount})` : ''} - Photos {!statsLoading && totalPhotos > 0 && `(${totalPhotos})`} + Photos {!statsLoading && statistics?.photosCount ? `(${statistics.photosCount})` : ''} History diff --git a/src/pages/OperatorDetail.tsx b/src/pages/OperatorDetail.tsx index fb78cbc1..666266a5 100644 --- a/src/pages/OperatorDetail.tsx +++ b/src/pages/OperatorDetail.tsx @@ -27,23 +27,23 @@ import { EntityHistoryTabs } from '@/components/history/EntityHistoryTabs'; import { useAuthModal } from '@/hooks/useAuthModal'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useOpenGraph } from '@/hooks/useOpenGraph'; +import { useCompanyDetail } from '@/hooks/companies/useCompanyDetail'; +import { useCompanyStatistics } from '@/hooks/companies/useCompanyStatistics'; +import { useCompanyParks } from '@/hooks/companies/useCompanyParks'; export default function OperatorDetail() { const { slug } = useParams<{ slug: string }>(); const navigate = useNavigate(); - const [operator, setOperator] = useState(null); - const [parks, setParks] = useState([]); - const [loading, setLoading] = useState(true); - const [parksLoading, setParksLoading] = useState(true); const [isEditModalOpen, setIsEditModalOpen] = useState(false); - const [totalParks, setTotalParks] = useState(0); - const [operatingRides, setOperatingRides] = useState(0); - const [statsLoading, setStatsLoading] = useState(true); - const [totalPhotos, setTotalPhotos] = useState(0); const { user } = useAuth(); const { isModerator } = useUserRole(); const { requireAuth } = useAuthModal(); + // Use custom hooks for data fetching + const { data: operator, isLoading: loading } = useCompanyDetail(slug, 'operator'); + const { data: statistics, isLoading: statsLoading } = useCompanyStatistics(operator?.id, 'operator'); + const { data: parks = [], isLoading: parksLoading } = useCompanyParks(operator?.id, 'operator', 6); + // Update document title when operator changes useDocumentTitle(operator?.name || 'Operator Details'); @@ -57,12 +57,6 @@ export default function OperatorDetail() { enabled: !!operator }); - useEffect(() => { - if (slug) { - fetchOperatorData(); - } - }, [slug]); - // Track page view when operator is loaded useEffect(() => { if (operator?.id) { @@ -70,95 +64,6 @@ export default function OperatorDetail() { } }, [operator?.id]); - const fetchOperatorData = async () => { - try { - const { data, error } = await supabase - .from('companies') - .select('*') - .eq('slug', slug) - .eq('company_type', 'operator') - .maybeSingle(); - - if (error) throw error; - setOperator(data); - - // Fetch parks operated by this operator - if (data) { - fetchParks(data.id); - fetchStatistics(data.id); - fetchPhotoCount(data.id); - } - } catch (error) { - console.error('Error fetching operator:', error); - } finally { - setLoading(false); - } - }; - - const fetchParks = async (operatorId: string) => { - try { - const { data, error } = await supabase - .from('parks') - .select(` - *, - location:locations(*) - `) - .eq('operator_id', operatorId) - .order('name') - .limit(6); - - if (error) throw error; - setParks(data || []); - } catch (error) { - console.error('Error fetching parks:', error); - } finally { - setParksLoading(false); - } - }; - - const fetchStatistics = async (operatorId: string) => { - try { - // Get total parks count - const { count: parksCount, error: parksError } = await supabase - .from('parks') - .select('id', { count: 'exact', head: true }) - .eq('operator_id', operatorId); - - if (parksError) throw parksError; - setTotalParks(parksCount || 0); - - // Get operating rides count across all parks - const { data: ridesData, error: ridesError } = await supabase - .from('rides') - .select('id, parks!inner(operator_id)') - .eq('parks.operator_id', operatorId) - .eq('status', 'operating'); - - if (ridesError) throw ridesError; - setOperatingRides(ridesData?.length || 0); - } catch (error) { - console.error('Error fetching statistics:', error); - } finally { - setStatsLoading(false); - } - }; - - const fetchPhotoCount = async (operatorId: string) => { - try { - const { count, error } = await supabase - .from('photos') - .select('id', { count: 'exact', head: true }) - .eq('entity_type', 'operator') - .eq('entity_id', operatorId); - - if (error) throw error; - setTotalPhotos(count || 0); - } catch (error) { - console.error('Error fetching photo count:', error); - setTotalPhotos(0); - } - }; - const handleEditSubmit = async (data: any) => { try { await submitCompanyUpdate( @@ -309,29 +214,29 @@ export default function OperatorDetail() { {/* Company Info */}
- {!statsLoading && totalParks > 0 && ( + {!statsLoading && statistics?.parksCount ? ( -
{totalParks}
+
{statistics.parksCount}
- {totalParks === 1 ? 'Park Operated' : 'Parks Operated'} + {statistics.parksCount === 1 ? 'Park Operated' : 'Parks Operated'}
- )} + ) : null} - {!statsLoading && operatingRides > 0 && ( + {!statsLoading && statistics?.operatingRidesCount ? ( -
{operatingRides}
+
{statistics.operatingRidesCount}
- Operating {operatingRides === 1 ? 'Ride' : 'Rides'} + Operating {statistics.operatingRidesCount === 1 ? 'Ride' : 'Rides'}
- )} + ) : null} {operator.founded_year && ( @@ -365,10 +270,10 @@ export default function OperatorDetail() { Overview - Parks {!statsLoading && totalParks > 0 && `(${totalParks})`} + Parks {!statsLoading && statistics?.parksCount ? `(${statistics.parksCount})` : ''} - Photos {!statsLoading && totalPhotos > 0 && `(${totalPhotos})`} + Photos {!statsLoading && statistics?.photosCount ? `(${statistics.photosCount})` : ''} History diff --git a/src/pages/PropertyOwnerDetail.tsx b/src/pages/PropertyOwnerDetail.tsx index e5994716..d6401e1c 100644 --- a/src/pages/PropertyOwnerDetail.tsx +++ b/src/pages/PropertyOwnerDetail.tsx @@ -27,23 +27,23 @@ import { EntityHistoryTabs } from '@/components/history/EntityHistoryTabs'; import { useAuthModal } from '@/hooks/useAuthModal'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useOpenGraph } from '@/hooks/useOpenGraph'; +import { useCompanyDetail } from '@/hooks/companies/useCompanyDetail'; +import { useCompanyStatistics } from '@/hooks/companies/useCompanyStatistics'; +import { useCompanyParks } from '@/hooks/companies/useCompanyParks'; export default function PropertyOwnerDetail() { const { slug } = useParams<{ slug: string }>(); const navigate = useNavigate(); - const [owner, setOwner] = useState(null); - const [parks, setParks] = useState([]); - const [loading, setLoading] = useState(true); - const [parksLoading, setParksLoading] = useState(true); const [isEditModalOpen, setIsEditModalOpen] = useState(false); - const [totalParks, setTotalParks] = useState(0); - const [operatingRides, setOperatingRides] = useState(0); - const [statsLoading, setStatsLoading] = useState(true); - const [totalPhotos, setTotalPhotos] = useState(0); const { user } = useAuth(); const { isModerator } = useUserRole(); const { requireAuth } = useAuthModal(); + // Use custom hooks for data fetching + const { data: owner, isLoading: loading } = useCompanyDetail(slug, 'property_owner'); + const { data: statistics, isLoading: statsLoading } = useCompanyStatistics(owner?.id, 'property_owner'); + const { data: parks = [], isLoading: parksLoading } = useCompanyParks(owner?.id, 'property_owner', 6); + // Update document title when owner changes useDocumentTitle(owner?.name || 'Property Owner Details'); @@ -57,12 +57,6 @@ export default function PropertyOwnerDetail() { enabled: !!owner }); - useEffect(() => { - if (slug) { - fetchOwnerData(); - } - }, [slug]); - // Track page view when property owner is loaded useEffect(() => { if (owner?.id) { @@ -70,95 +64,6 @@ export default function PropertyOwnerDetail() { } }, [owner?.id]); - const fetchOwnerData = async () => { - try { - const { data, error } = await supabase - .from('companies') - .select('*') - .eq('slug', slug) - .eq('company_type', 'property_owner') - .maybeSingle(); - - if (error) throw error; - setOwner(data); - - // Fetch parks owned by this property owner - if (data) { - fetchParks(data.id); - fetchStatistics(data.id); - fetchPhotoCount(data.id); - } - } catch (error) { - console.error('Error fetching property owner:', error); - } finally { - setLoading(false); - } - }; - - const fetchParks = async (ownerId: string) => { - try { - const { data, error } = await supabase - .from('parks') - .select(` - *, - location:locations(*) - `) - .eq('property_owner_id', ownerId) - .order('name') - .limit(6); - - if (error) throw error; - setParks(data || []); - } catch (error) { - console.error('Error fetching parks:', error); - } finally { - setParksLoading(false); - } - }; - - const fetchStatistics = async (ownerId: string) => { - try { - // Get total parks count - const { count: parksCount, error: parksError } = await supabase - .from('parks') - .select('id', { count: 'exact', head: true }) - .eq('property_owner_id', ownerId); - - if (parksError) throw parksError; - setTotalParks(parksCount || 0); - - // Get operating rides count across all owned parks - const { data: ridesData, error: ridesError } = await supabase - .from('rides') - .select('id, parks!inner(property_owner_id)') - .eq('parks.property_owner_id', ownerId) - .eq('status', 'operating'); - - if (ridesError) throw ridesError; - setOperatingRides(ridesData?.length || 0); - } catch (error) { - console.error('Error fetching statistics:', error); - } finally { - setStatsLoading(false); - } - }; - - const fetchPhotoCount = async (ownerId: string) => { - try { - const { count, error } = await supabase - .from('photos') - .select('id', { count: 'exact', head: true }) - .eq('entity_type', 'property_owner') - .eq('entity_id', ownerId); - - if (error) throw error; - setTotalPhotos(count || 0); - } catch (error) { - console.error('Error fetching photo count:', error); - setTotalPhotos(0); - } - }; - const handleEditSubmit = async (data: any) => { try { await submitCompanyUpdate( @@ -309,29 +214,29 @@ export default function PropertyOwnerDetail() { {/* Company Info */}
- {!statsLoading && totalParks > 0 && ( + {!statsLoading && statistics?.parksCount ? ( -
{totalParks}
+
{statistics.parksCount}
- {totalParks === 1 ? 'Park Owned' : 'Parks Owned'} + {statistics.parksCount === 1 ? 'Park Owned' : 'Parks Owned'}
- )} + ) : null} - {!statsLoading && operatingRides > 0 && ( + {!statsLoading && statistics?.operatingRidesCount ? ( -
{operatingRides}
+
{statistics.operatingRidesCount}
- Operating {operatingRides === 1 ? 'Ride' : 'Rides'} + Operating {statistics.operatingRidesCount === 1 ? 'Ride' : 'Rides'}
- )} + ) : null} {owner.founded_year && ( @@ -365,10 +270,10 @@ export default function PropertyOwnerDetail() { Overview - Parks {!statsLoading && totalParks > 0 && `(${totalParks})`} + Parks {!statsLoading && statistics?.parksCount ? `(${statistics.parksCount})` : ''} - Photos {!statsLoading && totalPhotos > 0 && `(${totalPhotos})`} + Photos {!statsLoading && statistics?.photosCount ? `(${statistics.photosCount})` : ''} History diff --git a/src/pages/RideModelDetail.tsx b/src/pages/RideModelDetail.tsx index fb685389..af4fef26 100644 --- a/src/pages/RideModelDetail.tsx +++ b/src/pages/RideModelDetail.tsx @@ -24,18 +24,24 @@ import { VersionIndicator } from '@/components/versioning/VersionIndicator'; import { EntityVersionHistory } from '@/components/versioning/EntityVersionHistory'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useOpenGraph } from '@/hooks/useOpenGraph'; +import { useRideModelDetail } from '@/hooks/rideModels/useRideModelDetail'; +import { useModelRides } from '@/hooks/rideModels/useModelRides'; +import { useModelStatistics } from '@/hooks/rideModels/useModelStatistics'; export default function RideModelDetail() { const { manufacturerSlug, modelSlug } = useParams<{ manufacturerSlug: string; modelSlug: string }>(); const navigate = useNavigate(); const { user } = useAuth(); const { requireAuth } = useAuthModal(); - const [model, setModel] = useState(null); - const [manufacturer, setManufacturer] = useState(null); - const [rides, setRides] = useState([]); - const [loading, setLoading] = useState(true); const [isEditModalOpen, setIsEditModalOpen] = useState(false); + // Use custom hooks for data fetching + const { data: modelData, isLoading: loading } = useRideModelDetail(manufacturerSlug, modelSlug); + const model = modelData?.model; + const manufacturer = modelData?.manufacturer; + const { data: rides = [] } = useModelRides(model?.id); + const { data: statistics = { rideCount: 0, photoCount: 0 } } = useModelStatistics(model?.id); + // Update document title when model changes useDocumentTitle(model?.name || 'Ride Model Details'); @@ -48,78 +54,10 @@ export default function RideModelDetail() { type: 'website', enabled: !!model }); - const [statistics, setStatistics] = useState({ rideCount: 0, photoCount: 0 }); // Fetch technical specifications from relational table const { data: technicalSpecs } = useTechnicalSpecifications('ride_model', model?.id); - const fetchData = useCallback(async () => { - try { - // Fetch manufacturer - const { data: manufacturerData, error: manufacturerError } = await supabase - .from('companies') - .select('*') - .eq('slug', manufacturerSlug) - .eq('company_type', 'manufacturer') - .maybeSingle(); - - if (manufacturerError) throw manufacturerError; - setManufacturer(manufacturerData); - - if (manufacturerData) { - // Fetch ride model - const { data: modelData, error: modelError } = await supabase - .from('ride_models') - .select('*') - .eq('slug', modelSlug) - .eq('manufacturer_id', manufacturerData.id) - .maybeSingle(); - - if (modelError) throw modelError; - setModel(modelData as RideModel); - - if (modelData) { - // Fetch rides using this model with proper joins - const { data: ridesData, error: ridesError } = await supabase - .from('rides') - .select(` - *, - park:parks!inner(name, slug, location:locations(*)), - manufacturer:companies!rides_manufacturer_id_fkey(*), - ride_model:ride_models(id, name, slug, manufacturer_id, category) - `) - .eq('ride_model_id', modelData.id) - .order('name'); - - if (ridesError) throw ridesError; - setRides(ridesData as Ride[] || []); - - // Fetch statistics - const { count: photoCount } = await supabase - .from('photos') - .select('*', { count: 'exact', head: true }) - .eq('entity_type', 'ride_model') - .eq('entity_id', modelData.id); - - setStatistics({ - rideCount: ridesData?.length || 0, - photoCount: photoCount || 0 - }); - } - } - } catch (error) { - console.error('Error fetching data:', error); - } finally { - setLoading(false); - } - }, [manufacturerSlug, modelSlug]); - - useEffect(() => { - if (manufacturerSlug && modelSlug) { - fetchData(); - } - }, [manufacturerSlug, modelSlug, fetchData]); - const handleEditSubmit = async (data: any) => { try { if (!user || !model) return; @@ -138,7 +76,6 @@ export default function RideModelDetail() { }); setIsEditModalOpen(false); - fetchData(); } catch (error) { const errorMsg = getErrorMessage(error); toast({