Compare commits

...

3 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
8623291c62 Refactor: Optimize recent changes query 2025-10-30 22:29:56 +00:00
gpt-engineer-app[bot]
662c2fbed4 Refactor homepage content fetching 2025-10-30 22:26:02 +00:00
gpt-engineer-app[bot]
d7ef581220 Refactor: Optimize user role fetching 2025-10-30 22:16:47 +00:00
14 changed files with 1053 additions and 624 deletions

View File

@@ -1,349 +1,63 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ParkCard } from '@/components/parks/ParkCard';
import { RideCard } from '@/components/rides/RideCard';
import { RecentChangeCard } from './RecentChangeCard';
import { Badge } from '@/components/ui/badge';
import { Park, Ride } from '@/types/database';
import { supabase } from '@/integrations/supabase/client';
import { getErrorMessage } from '@/lib/errorHandler';
import { logger } from '@/lib/logger';
import { Clock } from 'lucide-react';
interface RecentChange {
entityType: 'park' | 'ride' | 'company';
entityId: string;
entityName: string;
entitySlug: string;
parkSlug?: string;
imageUrl: string | null;
changeType: string;
changedAt: string;
changedByUsername?: string | null;
changedByAvatar?: string | null;
changeReason: string | null;
}
import { useHomepageTrendingParks, useHomepageTrendingRides } from '@/hooks/homepage/useHomepageTrending';
import { useHomepageRecentParks, useHomepageRecentRides } from '@/hooks/homepage/useHomepageRecent';
import { useHomepageRecentChanges } from '@/hooks/homepage/useHomepageRecentChanges';
import { useHomepageRecentlyOpenedParks, useHomepageRecentlyOpenedRides } from '@/hooks/homepage/useHomepageOpened';
import { useHomepageHighestRatedParks, useHomepageHighestRatedRides } from '@/hooks/homepage/useHomepageRated';
import { useHomepageOpeningSoonParks, useHomepageOpeningSoonRides } from '@/hooks/homepage/useHomepageOpeningSoon';
import { useHomepageClosingSoonParks, useHomepageClosingSoonRides } from '@/hooks/homepage/useHomepageClosing';
import { useHomepageRecentlyClosedParks, useHomepageRecentlyClosedRides } from '@/hooks/homepage/useHomepageClosed';
export function ContentTabs() {
const [trendingParks, setTrendingParks] = useState<Park[]>([]);
const [trendingRides, setTrendingRides] = useState<Ride[]>([]);
const [recentParks, setRecentParks] = useState<Park[]>([]);
const [recentRides, setRecentRides] = useState<Ride[]>([]);
const [recentChanges, setRecentChanges] = useState<RecentChange[]>([]);
const [recentlyOpened, setRecentlyOpened] = useState<Array<(Park | Ride) & { entityType: 'park' | 'ride' }>>([]);
const [highestRatedParks, setHighestRatedParks] = useState<Park[]>([]);
const [highestRatedRides, setHighestRatedRides] = useState<Ride[]>([]);
const [openingSoon, setOpeningSoon] = useState<Array<(Park | Ride) & { entityType: 'park' | 'ride' }>>([]);
const [closingSoon, setClosingSoon] = useState<Array<(Park | Ride) & { entityType: 'park' | 'ride' }>>([]);
const [recentlyClosed, setRecentlyClosed] = useState<Array<(Park | Ride) & { entityType: 'park' | 'ride' }>>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('trending-parks');
useEffect(() => {
fetchContent();
}, []);
// Lazy load data - only fetch when tab is active
const trendingParks = useHomepageTrendingParks(activeTab === 'trending-parks');
const trendingRides = useHomepageTrendingRides(activeTab === 'trending-rides');
const recentParks = useHomepageRecentParks(activeTab === 'recent-parks');
const recentRides = useHomepageRecentRides(activeTab === 'recent-rides');
const recentChanges = useHomepageRecentChanges(activeTab === 'recent-changes');
const recentlyOpenedParks = useHomepageRecentlyOpenedParks(activeTab === 'recently-opened');
const recentlyOpenedRides = useHomepageRecentlyOpenedRides(activeTab === 'recently-opened');
const highestRatedParks = useHomepageHighestRatedParks(activeTab === 'highest-rated-parks');
const highestRatedRides = useHomepageHighestRatedRides(activeTab === 'highest-rated-rides');
const openingSoonParks = useHomepageOpeningSoonParks(activeTab === 'opening-soon');
const openingSoonRides = useHomepageOpeningSoonRides(activeTab === 'opening-soon');
const closingSoonParks = useHomepageClosingSoonParks(activeTab === 'closing-soon');
const closingSoonRides = useHomepageClosingSoonRides(activeTab === 'closing-soon');
const recentlyClosedParks = useHomepageRecentlyClosedParks(activeTab === 'recently-closed');
const recentlyClosedRides = useHomepageRecentlyClosedRides(activeTab === 'recently-closed');
const fetchContent = async () => {
try {
// Trending Parks (by 30-day view count)
const { data: trending } = await supabase
.from('parks')
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
.order('view_count_30d', { ascending: false })
.limit(12);
// Combine parks and rides for mixed tabs
const recentlyOpened = [
...(recentlyOpenedParks.data || []).map(p => ({ ...p, entityType: 'park' as const })),
...(recentlyOpenedRides.data || []).map(r => ({ ...r, entityType: 'ride' as const }))
].sort((a, b) => new Date(b.opening_date || 0).getTime() - new Date(a.opening_date || 0).getTime()).slice(0, 24);
// Recently Added Parks
const { data: recent } = await supabase
.from('parks')
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
.order('created_at', { ascending: false })
.limit(12);
const openingSoon = [
...(openingSoonParks.data || []).map(p => ({ ...p, entityType: 'park' as const })),
...(openingSoonRides.data || []).map(r => ({ ...r, entityType: 'ride' as const }))
].sort((a, b) => new Date(a.opening_date || 0).getTime() - new Date(b.opening_date || 0).getTime()).slice(0, 24);
// Trending Rides (by 30-day view count)
const { data: trendingRidesData } = await supabase
.from('rides')
.select(`*, park:parks!inner(name, slug, location:locations(*))`)
.order('view_count_30d', { ascending: false })
.limit(12);
const closingSoon = [
...(closingSoonParks.data || []).map(p => ({ ...p, entityType: 'park' as const })),
...(closingSoonRides.data || []).map(r => ({ ...r, entityType: 'ride' as const }))
].sort((a, b) => new Date(a.closing_date || 0).getTime() - new Date(b.closing_date || 0).getTime()).slice(0, 24);
// Recently Added Rides
const { data: recentRidesData } = await supabase
.from('rides')
.select(`*, park:parks!inner(name, slug, location:locations(*))`)
.order('created_at', { ascending: false })
.limit(12);
const recentlyClosed = [
...(recentlyClosedParks.data || []).map(p => ({ ...p, entityType: 'park' as const })),
...(recentlyClosedRides.data || []).map(r => ({ ...r, entityType: 'ride' as const }))
].sort((a, b) => new Date(b.closing_date || 0).getTime() - new Date(a.closing_date || 0).getTime()).slice(0, 24);
// Fetch recent park versions
const { data: parkVersions } = await supabase
.from('park_versions')
.select(`
version_id,
park_id,
name,
slug,
change_type,
created_at,
created_by,
change_reason,
card_image_url,
profiles:profiles!park_versions_created_by_fkey(username, avatar_url)
`)
.eq('is_current', true)
.order('created_at', { ascending: false })
.limit(12);
const isLoadingInitial = activeTab === 'trending-parks' && trendingParks.isLoading;
// Fetch recent ride versions with park slug for proper routing
const { data: rideVersions } = await supabase
.from('ride_versions')
.select(`
version_id,
ride_id,
name,
slug,
change_type,
created_at,
created_by,
change_reason,
card_image_url,
park_id,
profiles:profiles!ride_versions_created_by_fkey(username, avatar_url)
`)
.eq('is_current', true)
.order('created_at', { ascending: false })
.limit(12);
// Fetch park slugs for rides that have a park_id
const rideParksMap = new Map<string, string>();
if (rideVersions) {
const parkIds = [...new Set(rideVersions.map(v => v.park_id).filter(Boolean))];
if (parkIds.length > 0) {
const { data: parks } = await supabase
.from('parks')
.select('id, slug')
.in('id', parkIds);
parks?.forEach(park => {
rideParksMap.set(park.id, park.slug);
});
}
}
// Fetch recent company versions
const { data: companyVersions } = await supabase
.from('company_versions')
.select(`
version_id,
company_id,
name,
slug,
change_type,
created_at,
created_by,
change_reason,
card_image_url,
profiles:profiles!company_versions_created_by_fkey(username, avatar_url)
`)
.eq('is_current', true)
.order('created_at', { ascending: false })
.limit(12);
// Combine all changes into a unified structure
const allChanges: RecentChange[] = [
...(parkVersions || []).map(v => ({
entityType: 'park' as const,
entityId: v.park_id,
entityName: v.name,
entitySlug: v.slug,
imageUrl: v.card_image_url,
changeType: v.change_type,
changedAt: v.created_at,
changedByUsername: v.profiles?.username,
changedByAvatar: v.profiles?.avatar_url,
changeReason: v.change_reason,
})),
...(rideVersions || []).map(v => ({
entityType: 'ride' as const,
entityId: v.ride_id,
entityName: v.name,
entitySlug: v.slug,
parkSlug: v.park_id ? rideParksMap.get(v.park_id) : undefined,
imageUrl: v.card_image_url,
changeType: v.change_type,
changedAt: v.created_at,
changedByUsername: v.profiles?.username,
changedByAvatar: v.profiles?.avatar_url,
changeReason: v.change_reason,
})),
...(companyVersions || []).map(v => ({
entityType: 'company' as const,
entityId: v.company_id,
entityName: v.name,
entitySlug: v.slug,
imageUrl: v.card_image_url,
changeType: v.change_type,
changedAt: v.created_at,
changedByUsername: v.profiles?.username,
changedByAvatar: v.profiles?.avatar_url,
changeReason: v.change_reason,
}))
]
.sort((a, b) => new Date(b.changedAt).getTime() - new Date(a.changedAt).getTime())
.slice(0, 24);
// Fetch recently opened parks and rides
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const dateThreshold = oneYearAgo.toISOString().split('T')[0];
const { data: openedParks } = await supabase
.from('parks')
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
.not('opening_date', 'is', null)
.gte('opening_date', dateThreshold)
.order('opening_date', { ascending: false })
.limit(20);
const { data: openedRides } = await supabase
.from('rides')
.select(`*, park:parks!inner(name, slug, location:locations(*))`)
.not('opening_date', 'is', null)
.gte('opening_date', dateThreshold)
.order('opening_date', { ascending: false })
.limit(20);
// Combine and sort by opening date
const combinedOpened = [
...(openedParks || []).map(p => ({ ...p, entityType: 'park' as const })),
...(openedRides || []).map(r => ({ ...r, entityType: 'ride' as const }))
]
.sort((a, b) => new Date(b.opening_date).getTime() - new Date(a.opening_date).getTime())
.slice(0, 24);
// Highest Rated Parks
const { data: topRatedParks } = await supabase
.from('parks')
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
.gt('review_count', 0)
.order('average_rating', { ascending: false })
.order('review_count', { ascending: false })
.limit(12);
// Highest Rated Rides
const { data: topRatedRides } = await supabase
.from('rides')
.select(`*, park:parks!inner(name, slug, location:locations(*))`)
.gt('review_count', 0)
.order('average_rating', { ascending: false })
.order('review_count', { ascending: false })
.limit(12);
// Opening Soon (next 6 months)
const sixMonthsFromNow = new Date();
sixMonthsFromNow.setMonth(sixMonthsFromNow.getMonth() + 6);
const futureThreshold = sixMonthsFromNow.toISOString().split('T')[0];
const { data: parksSoon } = await supabase
.from('parks')
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
.not('opening_date', 'is', null)
.gt('opening_date', new Date().toISOString().split('T')[0])
.lte('opening_date', futureThreshold)
.in('status', ['under_construction', 'planned'])
.order('opening_date', { ascending: true })
.limit(20);
const { data: ridesSoon } = await supabase
.from('rides')
.select(`*, park:parks!inner(name, slug, location:locations(*))`)
.not('opening_date', 'is', null)
.gt('opening_date', new Date().toISOString().split('T')[0])
.lte('opening_date', futureThreshold)
.in('status', ['under_construction', 'planned'])
.order('opening_date', { ascending: true })
.limit(20);
const combinedOpening = [
...(parksSoon || []).map(p => ({ ...p, entityType: 'park' as const })),
...(ridesSoon || []).map(r => ({ ...r, entityType: 'ride' as const }))
]
.sort((a, b) => new Date(a.opening_date).getTime() - new Date(b.opening_date).getTime())
.slice(0, 24);
// Closing Soon (next 6 months)
const { data: parksClosing } = await supabase
.from('parks')
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
.not('closing_date', 'is', null)
.gt('closing_date', new Date().toISOString().split('T')[0])
.lte('closing_date', futureThreshold)
.eq('status', 'operating')
.order('closing_date', { ascending: true })
.limit(20);
const { data: ridesClosing } = await supabase
.from('rides')
.select(`*, park:parks!inner(name, slug, location:locations(*))`)
.not('closing_date', 'is', null)
.gt('closing_date', new Date().toISOString().split('T')[0])
.lte('closing_date', futureThreshold)
.eq('status', 'operating')
.order('closing_date', { ascending: true })
.limit(20);
const combinedClosing = [
...(parksClosing || []).map(p => ({ ...p, entityType: 'park' as const })),
...(ridesClosing || []).map(r => ({ ...r, entityType: 'ride' as const }))
]
.sort((a, b) => new Date(a.closing_date).getTime() - new Date(b.closing_date).getTime())
.slice(0, 24);
// Recently Closed (closed in last year)
const { data: parksClosed } = await supabase
.from('parks')
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
.not('closing_date', 'is', null)
.lt('closing_date', new Date().toISOString().split('T')[0])
.gte('closing_date', dateThreshold)
.in('status', ['closed', 'permanently_closed'])
.order('closing_date', { ascending: false })
.limit(20);
const { data: ridesClosed } = await supabase
.from('rides')
.select(`*, park:parks!inner(name, slug, location:locations(*))`)
.not('closing_date', 'is', null)
.lt('closing_date', new Date().toISOString().split('T')[0])
.gte('closing_date', dateThreshold)
.in('status', ['closed', 'removed', 'permanently_closed'])
.order('closing_date', { ascending: false })
.limit(20);
const combinedClosed = [
...(parksClosed || []).map(p => ({ ...p, entityType: 'park' as const })),
...(ridesClosed || []).map(r => ({ ...r, entityType: 'ride' as const }))
]
.sort((a, b) => new Date(b.closing_date).getTime() - new Date(a.closing_date).getTime())
.slice(0, 24);
setTrendingParks(trending || []);
setRecentParks(recent || []);
setTrendingRides(trendingRidesData || []);
setRecentRides(recentRidesData || []);
setRecentChanges(allChanges);
setRecentlyOpened(combinedOpened);
setHighestRatedParks(topRatedParks || []);
setHighestRatedRides(topRatedRides || []);
setOpeningSoon(combinedOpening);
setClosingSoon(combinedClosing);
setRecentlyClosed(combinedClosed);
} catch (error: unknown) {
logger.error('Failed to fetch content', { error: getErrorMessage(error) });
} finally {
setLoading(false);
}
};
if (loading) {
if (isLoadingInitial) {
return (
<section className="py-12">
<div className="container mx-auto px-4">
@@ -374,7 +88,7 @@ export function ContentTabs() {
return (
<section className="py-8">
<div className="container mx-auto px-4">
<Tabs defaultValue="trending-parks" className="w-full">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<div className="text-center mb-8">
<TabsList className="flex flex-wrap justify-center gap-2 p-3 bg-muted/30 rounded-lg max-w-5xl mx-auto">
<TabsTrigger value="trending-parks" className="px-4 py-2.5 text-sm font-medium rounded-full data-[state=active]:bg-primary data-[state=active]:text-primary-foreground hover:bg-muted/50 transition-colors text-center md:w-[calc(25%-0.375rem)]">
@@ -419,11 +133,19 @@ export function ContentTabs() {
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Most viewed parks in the last 30 days</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{trendingParks.isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{trendingParks.map((park) => (
{trendingParks.data?.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
)}
</TabsContent>
<TabsContent value="trending-rides" className="mt-8">
@@ -432,11 +154,19 @@ export function ContentTabs() {
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Most viewed rides in the last 30 days</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{trendingRides.isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{trendingRides.map((ride) => (
{trendingRides.data?.map((ride) => (
<RideCard key={ride.id} ride={ride} />
))}
</div>
)}
</TabsContent>
<TabsContent value="recent-parks" className="mt-8">
@@ -445,11 +175,19 @@ export function ContentTabs() {
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Latest parks added to our database</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{recentParks.isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{recentParks.map((park) => (
{recentParks.data?.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
)}
</TabsContent>
<TabsContent value="recent-rides" className="mt-8">
@@ -458,11 +196,19 @@ export function ContentTabs() {
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Latest attractions added to our database</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{recentRides.isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{recentRides.map((ride) => (
{recentRides.data?.map((ride) => (
<RideCard key={ride.id} ride={ride} />
))}
</div>
)}
</TabsContent>
<TabsContent value="recent-changes" className="mt-8">
@@ -471,21 +217,27 @@ export function ContentTabs() {
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Latest updates across all entities</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{recentChanges.length > 0 ? (
{recentChanges.isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
{recentChanges.map((change) => (
{[...Array(12)].map((_, i) => (
<div key={i} className="h-64 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : recentChanges.data && recentChanges.data.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
{recentChanges.data.map((change) => (
<RecentChangeCard
key={`${change.entityType}-${change.entityId}-${change.changedAt}`}
entityType={change.entityType}
entityId={change.entityId}
entityName={change.entityName}
entitySlug={change.entitySlug}
key={`${change.type}-${change.id}-${change.changedAt}`}
entityType={change.type}
entityId={change.id}
entityName={change.name}
entitySlug={change.slug}
parkSlug={change.parkSlug}
imageUrl={change.imageUrl}
changeType={change.changeType}
changedAt={change.changedAt}
changedByUsername={change.changedByUsername}
changedByAvatar={change.changedByAvatar}
changedByUsername={change.changedBy?.username}
changedByAvatar={change.changedBy?.avatarUrl}
changeReason={change.changeReason}
/>
))}
@@ -513,6 +265,13 @@ export function ContentTabs() {
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Parks and rides that opened in the last year</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{recentlyOpenedParks.isLoading || recentlyOpenedRides.isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{recentlyOpened.map((entity: any) => (
entity.entityType === 'park' ? (
@@ -532,6 +291,7 @@ export function ContentTabs() {
)
))}
</div>
)}
</TabsContent>
<TabsContent value="highest-rated-parks" className="mt-8">
@@ -540,9 +300,15 @@ export function ContentTabs() {
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Top-rated theme parks based on visitor reviews</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{highestRatedParks.length > 0 ? (
{highestRatedParks.isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : highestRatedParks.data && highestRatedParks.data.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{highestRatedParks.map((park) => (
{highestRatedParks.data.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
@@ -559,9 +325,15 @@ export function ContentTabs() {
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Top-rated attractions based on rider reviews</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{highestRatedRides.length > 0 ? (
{highestRatedRides.isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : highestRatedRides.data && highestRatedRides.data.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{highestRatedRides.map((ride) => (
{highestRatedRides.data.map((ride) => (
<RideCard key={ride.id} ride={ride} />
))}
</div>
@@ -578,7 +350,13 @@ export function ContentTabs() {
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Parks and rides opening in the next 6 months</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{openingSoon.length > 0 ? (
{openingSoonParks.isLoading || openingSoonRides.isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : openingSoon.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{openingSoon.map((entity: any) => (
entity.entityType === 'park' ? (
@@ -611,7 +389,13 @@ export function ContentTabs() {
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Last chance: Parks and rides closing in the next 6 months</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{closingSoon.length > 0 ? (
{closingSoonParks.isLoading || closingSoonRides.isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : closingSoon.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{closingSoon.map((entity: any) => (
entity.entityType === 'park' ? (
@@ -644,7 +428,13 @@ export function ContentTabs() {
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Parks and rides that closed in the last year</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{recentlyClosed.length > 0 ? (
{recentlyClosedParks.isLoading || recentlyClosedRides.isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : recentlyClosed.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{recentlyClosed.map((entity: any) => (
entity.entityType === 'park' ? (

View File

@@ -0,0 +1,55 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
export function useHomepageRecentlyClosedParks(enabled = true) {
return useQuery({
queryKey: queryKeys.homepage.recentlyClosedParks(),
queryFn: async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const today = new Date();
const { data, error } = await supabase
.from('parks')
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
.gte('closed_date', oneYearAgo.toISOString())
.lte('closed_date', today.toISOString())
.order('closed_date', { ascending: false })
.limit(12);
if (error) throw error;
return data || [];
},
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}
export function useHomepageRecentlyClosedRides(enabled = true) {
return useQuery({
queryKey: queryKeys.homepage.recentlyClosedRides(),
queryFn: async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const today = new Date();
const { data, error } = await supabase
.from('rides')
.select(`*, park:parks(*), location:locations(*)`)
.gte('closed_date', oneYearAgo.toISOString())
.lte('closed_date', today.toISOString())
.order('closed_date', { ascending: false })
.limit(12);
if (error) throw error;
return data || [];
},
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View File

@@ -0,0 +1,55 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
export function useHomepageClosingSoonParks(enabled = true) {
return useQuery({
queryKey: queryKeys.homepage.closingSoonParks(),
queryFn: async () => {
const today = new Date();
const sixMonthsFromNow = new Date();
sixMonthsFromNow.setMonth(sixMonthsFromNow.getMonth() + 6);
const { data, error } = await supabase
.from('parks')
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
.gte('closed_date', today.toISOString())
.lte('closed_date', sixMonthsFromNow.toISOString())
.order('closed_date', { ascending: true })
.limit(12);
if (error) throw error;
return data || [];
},
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}
export function useHomepageClosingSoonRides(enabled = true) {
return useQuery({
queryKey: queryKeys.homepage.closingSoonRides(),
queryFn: async () => {
const today = new Date();
const sixMonthsFromNow = new Date();
sixMonthsFromNow.setMonth(sixMonthsFromNow.getMonth() + 6);
const { data, error } = await supabase
.from('rides')
.select(`*, park:parks(*), location:locations(*)`)
.gte('closed_date', today.toISOString())
.lte('closed_date', sixMonthsFromNow.toISOString())
.order('closed_date', { ascending: true })
.limit(12);
if (error) throw error;
return data || [];
},
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View File

@@ -0,0 +1,51 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
export function useHomepageRecentlyOpenedParks(enabled = true) {
return useQuery({
queryKey: queryKeys.homepage.recentlyOpenedParks(),
queryFn: async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const { data, error } = await supabase
.from('parks')
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
.gte('opened_date', oneYearAgo.toISOString())
.order('opened_date', { ascending: false })
.limit(12);
if (error) throw error;
return data || [];
},
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}
export function useHomepageRecentlyOpenedRides(enabled = true) {
return useQuery({
queryKey: queryKeys.homepage.recentlyOpenedRides(),
queryFn: async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const { data, error } = await supabase
.from('rides')
.select(`*, park:parks(*), location:locations(*)`)
.gte('opened_date', oneYearAgo.toISOString())
.order('opened_date', { ascending: false })
.limit(12);
if (error) throw error;
return data || [];
},
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View File

@@ -0,0 +1,55 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
export function useHomepageOpeningSoonParks(enabled = true) {
return useQuery({
queryKey: queryKeys.homepage.openingSoonParks(),
queryFn: async () => {
const today = new Date();
const sixMonthsFromNow = new Date();
sixMonthsFromNow.setMonth(sixMonthsFromNow.getMonth() + 6);
const { data, error } = await supabase
.from('parks')
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
.gte('opened_date', today.toISOString())
.lte('opened_date', sixMonthsFromNow.toISOString())
.order('opened_date', { ascending: true })
.limit(12);
if (error) throw error;
return data || [];
},
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}
export function useHomepageOpeningSoonRides(enabled = true) {
return useQuery({
queryKey: queryKeys.homepage.openingSoonRides(),
queryFn: async () => {
const today = new Date();
const sixMonthsFromNow = new Date();
sixMonthsFromNow.setMonth(sixMonthsFromNow.getMonth() + 6);
const { data, error } = await supabase
.from('rides')
.select(`*, park:parks(*), location:locations(*)`)
.gte('opened_date', today.toISOString())
.lte('opened_date', sixMonthsFromNow.toISOString())
.order('opened_date', { ascending: true })
.limit(12);
if (error) throw error;
return data || [];
},
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View File

@@ -0,0 +1,45 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
export function useHomepageHighestRatedParks(enabled = true) {
return useQuery({
queryKey: queryKeys.homepage.highestRatedParks(),
queryFn: async () => {
const { data, error } = await supabase
.from('parks')
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
.not('average_rating', 'is', null)
.order('average_rating', { ascending: false })
.limit(12);
if (error) throw error;
return data || [];
},
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}
export function useHomepageHighestRatedRides(enabled = true) {
return useQuery({
queryKey: queryKeys.homepage.highestRatedRides(),
queryFn: async () => {
const { data, error } = await supabase
.from('rides')
.select(`*, park:parks(*), location:locations(*)`)
.not('average_rating', 'is', null)
.order('average_rating', { ascending: false })
.limit(12);
if (error) throw error;
return data || [];
},
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View File

@@ -0,0 +1,43 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
export function useHomepageRecentParks(enabled = true) {
return useQuery({
queryKey: queryKeys.homepage.recentParks(),
queryFn: async () => {
const { data, error } = await supabase
.from('parks')
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
.order('created_at', { ascending: false })
.limit(12);
if (error) throw error;
return data || [];
},
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}
export function useHomepageRecentRides(enabled = true) {
return useQuery({
queryKey: queryKeys.homepage.recentRides(),
queryFn: async () => {
const { data, error } = await supabase
.from('rides')
.select(`*, park:parks(*), location:locations(*)`)
.order('created_at', { ascending: false })
.limit(12);
if (error) throw error;
return data || [];
},
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View File

@@ -0,0 +1,52 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
interface RecentChange {
id: string;
name: string;
type: 'park' | 'ride' | 'company';
slug: string;
parkSlug?: string;
imageUrl?: string;
changeType: string;
changedAt: string;
changedBy?: {
username: string;
avatarUrl?: string;
};
changeReason?: string;
}
export function useHomepageRecentChanges(enabled = true) {
return useQuery({
queryKey: queryKeys.homepage.recentChanges(),
queryFn: async () => {
// Use the new database function to get all changes in a single query
const { data, error } = await supabase.rpc('get_recent_changes', { limit_count: 24 });
if (error) throw error;
// Transform the database response to match our interface
return (data || []).map((item: any) => ({
id: item.entity_id,
name: item.entity_name,
type: item.entity_type as 'park' | 'ride' | 'company',
slug: item.entity_slug,
parkSlug: item.park_slug || undefined,
imageUrl: item.image_url || undefined,
changeType: item.change_type,
changedAt: item.changed_at,
changedBy: item.changed_by_username ? {
username: item.changed_by_username,
avatarUrl: item.changed_by_avatar || undefined
} : undefined,
changeReason: item.change_reason || undefined
})) as RecentChange[];
},
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View File

@@ -0,0 +1,43 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
export function useHomepageTrendingParks(enabled = true) {
return useQuery({
queryKey: queryKeys.homepage.trendingParks(),
queryFn: async () => {
const { data, error } = await supabase
.from('parks')
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
.order('view_count_30d', { ascending: false })
.limit(12);
if (error) throw error;
return data || [];
},
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}
export function useHomepageTrendingRides(enabled = true) {
return useQuery({
queryKey: queryKeys.homepage.trendingRides(),
queryFn: async () => {
const { data, error } = await supabase
.from('rides')
.select(`*, park:parks(*), location:locations(*)`)
.order('view_count_30d', { ascending: false })
.limit(12);
if (error) throw error;
return data || [];
},
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { queryKeys } from '@/lib/queryKeys';
export type UserRole = 'admin' | 'moderator' | 'user' | 'superuser';
@@ -15,53 +16,54 @@ export interface UserPermissions {
export function useUserRole() {
const { user } = useAuth();
const [roles, setRoles] = useState<UserRole[]>([]);
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!user) {
setRoles([]);
setLoading(false);
return;
}
// Fetch user roles with TanStack Query for automatic caching
const rolesQuery = useQuery({
queryKey: queryKeys.userRoles(user?.id),
queryFn: async () => {
if (!user) return [];
const fetchRoles = async () => {
try {
// Fetch user roles
const { data: rolesData, error: rolesError } = await supabase
const { data, error } = await supabase
.from('user_roles')
.select('role')
.eq('user_id', user.id);
if (rolesError) {
console.error('Error fetching user roles:', rolesError);
setRoles([]);
} else {
setRoles(rolesData?.map(r => r.role as UserRole) || []);
if (error) {
console.error('Error fetching user roles:', error);
return [];
}
// Fetch user permissions using the new function
const { data: permissionsData, error: permissionsError } = await supabase
return data?.map(r => r.role as UserRole) || [];
},
enabled: !!user,
staleTime: 5 * 60 * 1000, // 5 minutes - roles don't change often
gcTime: 10 * 60 * 1000, // 10 minutes garbage collection
});
// Fetch user permissions with TanStack Query for automatic caching
const permissionsQuery = useQuery({
queryKey: queryKeys.userPermissions(user?.id),
queryFn: async () => {
if (!user) return null;
const { data, error } = await supabase
.rpc('get_user_management_permissions', { _user_id: user.id });
if (permissionsError) {
console.error('Error fetching user permissions:', permissionsError);
setPermissions(null);
} else {
setPermissions(permissionsData as unknown as UserPermissions);
if (error) {
console.error('Error fetching user permissions:', error);
return null;
}
} catch (error: unknown) {
console.error('Error fetching user roles:', error);
setRoles([]);
setPermissions(null);
} finally {
setLoading(false);
}
};
fetchRoles();
}, [user]);
return data as unknown as UserPermissions;
},
enabled: !!user,
staleTime: 5 * 60 * 1000, // 5 minutes - permissions don't change often
gcTime: 10 * 60 * 1000, // 10 minutes garbage collection
});
const roles = rolesQuery.data || [];
const permissions = permissionsQuery.data || null;
const loading = rolesQuery.isLoading || permissionsQuery.isLoading;
const hasRole = (role: UserRole) => roles.includes(role);
const isModerator = () => hasRole('admin') || hasRole('moderator') || hasRole('superuser');

View File

@@ -4619,6 +4619,22 @@ export type Database = {
user_agent: string
}[]
}
get_recent_changes: {
Args: { limit_count?: number }
Returns: {
change_reason: string
change_type: string
changed_at: string
changed_by_avatar: string
changed_by_username: string
entity_id: string
entity_name: string
entity_slug: string
entity_type: string
image_url: string
park_slug: string
}[]
}
get_submission_item_dependencies: {
Args: { item_id: string }
Returns: {

View File

@@ -0,0 +1,98 @@
/**
* Query invalidation helpers for TanStack Query
*
* Use these helpers to invalidate cached queries when data changes.
* This ensures UI stays in sync with backend state.
*/
import { useQueryClient } from '@tanstack/react-query';
import { queryKeys } from './queryKeys';
/**
* Hook providing query invalidation helpers
*/
export function useQueryInvalidation() {
const queryClient = useQueryClient();
return {
/**
* Invalidate user roles cache
* Call this after assigning/revoking roles
*/
invalidateUserRoles: (userId?: string) => {
if (userId) {
queryClient.invalidateQueries({ queryKey: queryKeys.userRoles(userId) });
} else {
queryClient.invalidateQueries({ queryKey: ['user-roles'] });
}
},
/**
* Invalidate user permissions cache
* Call this after role changes that affect permissions
*/
invalidateUserPermissions: (userId?: string) => {
if (userId) {
queryClient.invalidateQueries({ queryKey: queryKeys.userPermissions(userId) });
} else {
queryClient.invalidateQueries({ queryKey: ['user-permissions'] });
}
},
/**
* Invalidate both roles and permissions for a user
* Use this as a convenience method after role updates
*/
invalidateUserAuth: (userId?: string) => {
if (userId) {
queryClient.invalidateQueries({ queryKey: queryKeys.userRoles(userId) });
queryClient.invalidateQueries({ queryKey: queryKeys.userPermissions(userId) });
} else {
queryClient.invalidateQueries({ queryKey: ['user-roles'] });
queryClient.invalidateQueries({ queryKey: ['user-permissions'] });
}
},
/**
* Invalidate moderation queue
* Call this after moderation actions
*/
invalidateModerationQueue: () => {
queryClient.invalidateQueries({ queryKey: ['moderation-queue'] });
},
/**
* Invalidate moderation stats
* Call this after queue changes
*/
invalidateModerationStats: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.moderationStats() });
},
/**
* Invalidate homepage data
* Call this after creating/updating parks or rides
*/
invalidateHomepageData: (entityType?: 'parks' | 'rides' | 'all') => {
if (!entityType || entityType === 'all') {
queryClient.invalidateQueries({ queryKey: ['homepage'] });
} else if (entityType === 'parks') {
queryClient.invalidateQueries({
queryKey: ['homepage'],
predicate: (query) => {
const key = query.queryKey[1] as string;
return typeof key === 'string' && key.includes('parks');
}
});
} else if (entityType === 'rides') {
queryClient.invalidateQueries({
queryKey: ['homepage'],
predicate: (query) => {
const key = query.queryKey[1] as string;
return typeof key === 'string' && key.includes('rides');
}
});
}
},
};
}

37
src/lib/queryKeys.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Centralized query key definitions for TanStack Query
*
* This ensures consistent query keys across the application
* and makes cache invalidation easier to manage.
*/
export const queryKeys = {
// User-related queries
userRoles: (userId?: string) => ['user-roles', userId] as const,
userPermissions: (userId?: string) => ['user-permissions', userId] as const,
// Moderation queue queries
moderationQueue: (config: Record<string, any>) => ['moderation-queue', config] as const,
moderationStats: () => ['moderation-stats'] as const,
// Homepage queries
homepage: {
trendingParks: () => ['homepage', 'trending-parks'] as const,
trendingRides: () => ['homepage', 'trending-rides'] as const,
recentParks: () => ['homepage', 'recent-parks'] as const,
recentRides: () => ['homepage', 'recent-rides'] as const,
recentChanges: () => ['homepage', 'recent-changes'] as const,
recentlyOpenedParks: () => ['homepage', 'recently-opened-parks'] as const,
recentlyOpenedRides: () => ['homepage', 'recently-opened-rides'] as const,
highestRatedParks: () => ['homepage', 'highest-rated-parks'] as const,
highestRatedRides: () => ['homepage', 'highest-rated-rides'] as const,
openingSoonParks: () => ['homepage', 'opening-soon-parks'] as const,
openingSoonRides: () => ['homepage', 'opening-soon-rides'] as const,
closingSoonParks: () => ['homepage', 'closing-soon-parks'] as const,
closingSoonRides: () => ['homepage', 'closing-soon-rides'] as const,
recentlyClosedParks: () => ['homepage', 'recently-closed-parks'] as const,
recentlyClosedRides: () => ['homepage', 'recently-closed-rides'] as const,
},
// Add more query keys as needed
} as const;

View File

@@ -0,0 +1,87 @@
-- Create a function to get recent changes across all entities in a single query
CREATE OR REPLACE FUNCTION get_recent_changes(limit_count INT DEFAULT 24)
RETURNS TABLE (
entity_type TEXT,
entity_id UUID,
entity_name TEXT,
entity_slug TEXT,
park_slug TEXT,
image_url TEXT,
change_type TEXT,
changed_at TIMESTAMPTZ,
changed_by_username TEXT,
changed_by_avatar TEXT,
change_reason TEXT
)
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RETURN QUERY
SELECT * FROM (
-- Park versions
SELECT
'park'::TEXT as entity_type,
pv.park_id as entity_id,
pv.name as entity_name,
pv.slug as entity_slug,
NULL::TEXT as park_slug,
pv.card_image_url as image_url,
pv.change_type::TEXT as change_type,
pv.created_at as changed_at,
p.username as changed_by_username,
p.avatar_url as changed_by_avatar,
pv.change_reason as change_reason
FROM park_versions pv
LEFT JOIN profiles p ON p.user_id = pv.created_by
WHERE pv.is_current = true
UNION ALL
-- Ride versions with park slug
SELECT
'ride'::TEXT as entity_type,
rv.ride_id as entity_id,
rv.name as entity_name,
rv.slug as entity_slug,
parks.slug as park_slug,
rv.card_image_url as image_url,
rv.change_type::TEXT as change_type,
rv.created_at as changed_at,
p.username as changed_by_username,
p.avatar_url as changed_by_avatar,
rv.change_reason as change_reason
FROM ride_versions rv
LEFT JOIN profiles p ON p.user_id = rv.created_by
LEFT JOIN parks ON parks.id = rv.park_id
WHERE rv.is_current = true
UNION ALL
-- Company versions
SELECT
'company'::TEXT as entity_type,
cv.company_id as entity_id,
cv.name as entity_name,
cv.slug as entity_slug,
NULL::TEXT as park_slug,
cv.card_image_url as image_url,
cv.change_type::TEXT as change_type,
cv.created_at as changed_at,
p.username as changed_by_username,
p.avatar_url as changed_by_avatar,
cv.change_reason as change_reason
FROM company_versions cv
LEFT JOIN profiles p ON p.user_id = cv.created_by
WHERE cv.is_current = true
) combined
ORDER BY changed_at DESC
LIMIT limit_count;
END;
$$;
-- Grant execute permission to authenticated users
GRANT EXECUTE ON FUNCTION get_recent_changes(INT) TO authenticated;
GRANT EXECUTE ON FUNCTION get_recent_changes(INT) TO anon;