mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 14:11:13 -05:00
feat: Implement Phase 4 cleanup and polish
This commit is contained in:
32
src/hooks/companies/useCompanyDetail.ts
Normal file
32
src/hooks/companies/useCompanyDetail.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
38
src/hooks/companies/useCompanyParks.ts
Normal file
38
src/hooks/companies/useCompanyParks.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
78
src/hooks/companies/useCompanyStatistics.ts
Normal file
78
src/hooks/companies/useCompanyStatistics.ts
Normal file
@@ -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<any>[] = [];
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
42
src/hooks/photos/useEntityPhotos.ts
Normal file
42
src/hooks/photos/useEntityPhotos.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
158
src/hooks/profile/useProfileActivity.ts
Normal file
158
src/hooks/profile/useProfileActivity.ts
Normal file
@@ -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<string, any[]>();
|
||||
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<string, any>([
|
||||
...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,
|
||||
});
|
||||
}
|
||||
37
src/hooks/profile/useProfileStats.ts
Normal file
37
src/hooks/profile/useProfileStats.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
39
src/hooks/rideModels/useModelRides.ts
Normal file
39
src/hooks/rideModels/useModelRides.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
32
src/hooks/rideModels/useModelStatistics.ts
Normal file
32
src/hooks/rideModels/useModelStatistics.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
48
src/hooks/rideModels/useRideModelDetail.ts
Normal file
48
src/hooks/rideModels/useRideModelDetail.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user