Reverted to commit 0091584677

This commit is contained in:
gpt-engineer-app[bot]
2025-11-01 15:22:30 +00:00
parent 26e5753807
commit 133141d474
125 changed files with 2316 additions and 9102 deletions

View File

@@ -14,7 +14,7 @@ import { ReportsQueue } from '@/components/moderation/ReportsQueue';
import { RecentActivity } from '@/components/moderation/RecentActivity';
import { useModerationStats } from '@/hooks/useModerationStats';
import { useAdminSettings } from '@/hooks/useAdminSettings';
import { useVersionAudit } from '@/hooks/admin/useVersionAudit';
import { supabase } from '@/integrations/supabase/client';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Skeleton } from '@/components/ui/skeleton';
import { QueueSkeleton } from '@/components/moderation/QueueSkeleton';
@@ -24,13 +24,11 @@ export default function AdminDashboard() {
useDocumentTitle('Dashboard - Admin');
const { user, loading: authLoading } = useAuth();
const { isModerator, loading: roleLoading } = useUserRole();
const { needsEnrollment, needsVerification, loading: mfaLoading } = useRequireMFA();
const { needsEnrollment, loading: mfaLoading } = useRequireMFA();
const navigate = useNavigate();
const [isRefreshing, setIsRefreshing] = useState(false);
const [activeTab, setActiveTab] = useState('moderation');
const { data: versionAudit } = useVersionAudit();
const suspiciousVersionsCount = versionAudit?.totalCount || 0;
const [suspiciousVersionsCount, setSuspiciousVersionsCount] = useState<number>(0);
const moderationQueueRef = useRef<ModerationQueueRef>(null);
const reportsQueueRef = useRef<any>(null);
@@ -50,9 +48,32 @@ export default function AdminDashboard() {
pollingInterval: pollInterval,
});
// Check for suspicious versions (bypassed submission flow)
const checkSuspiciousVersions = useCallback(async () => {
if (!user || !isModerator()) return;
// Query all version tables for suspicious entries (no changed_by)
const queries = [
supabase.from('park_versions').select('*', { count: 'exact', head: true }).is('created_by', null),
supabase.from('ride_versions').select('*', { count: 'exact', head: true }).is('created_by', null),
supabase.from('company_versions').select('*', { count: 'exact', head: true }).is('created_by', null),
supabase.from('ride_model_versions').select('*', { count: 'exact', head: true }).is('created_by', null),
];
const results = await Promise.all(queries);
const totalCount = results.reduce((sum, result) => sum + (result.count || 0), 0);
setSuspiciousVersionsCount(totalCount);
}, [user, isModerator]);
useEffect(() => {
checkSuspiciousVersions();
}, [checkSuspiciousVersions]);
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
await refreshStats();
await checkSuspiciousVersions();
// Refresh active tab's content
switch (activeTab) {
@@ -68,7 +89,7 @@ export default function AdminDashboard() {
}
setTimeout(() => setIsRefreshing(false), 500);
}, [refreshStats, activeTab]);
}, [refreshStats, checkSuspiciousVersions, activeTab]);
const handleStatCardClick = (cardType: 'submissions' | 'reports' | 'flagged') => {
switch (cardType) {
@@ -138,8 +159,8 @@ export default function AdminDashboard() {
return null;
}
// MFA enforcement - CRITICAL: Block if EITHER not enrolled OR needs verification
if (needsEnrollment || needsVerification) {
// MFA enforcement
if (needsEnrollment) {
return (
<AdminLayout>
<MFARequiredAlert />

View File

@@ -8,9 +8,8 @@ import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Separator } from '@/components/ui/separator';
import { Zap, Mail, Lock, User, AlertCircle, Eye, EyeOff, Shield } from 'lucide-react';
import { Zap, Mail, Lock, User, AlertCircle, Eye, EyeOff } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/errorHandler';
@@ -37,9 +36,6 @@ export default function Auth() {
const [signInCaptchaToken, setSignInCaptchaToken] = useState<string | null>(null);
const [signInCaptchaKey, setSignInCaptchaKey] = useState(0);
const [mfaFactorId, setMfaFactorId] = useState<string | null>(null);
const [mfaPendingEmail, setMfaPendingEmail] = useState<string | null>(null);
const [mfaChallengeId, setMfaChallengeId] = useState<string | null>(null);
const [mfaPendingUserId, setMfaPendingUserId] = useState<string | null>(null);
const emailParam = searchParams.get('email');
const messageParam = searchParams.get('message');
@@ -95,65 +91,89 @@ export default function Auth() {
setSignInCaptchaToken(null);
try {
// Call server-side auth check with MFA detection
const { data: authResult, error: authError } = await supabase.functions.invoke(
'auth-with-mfa-check',
{
body: {
email: formData.email,
password: formData.password,
captchaToken: tokenToUse,
},
const {
data,
error
} = await supabase.auth.signInWithPassword({
email: formData.email,
password: formData.password,
options: {
captchaToken: tokenToUse
}
);
});
if (error) throw error;
if (authError || authResult.error) {
throw new Error(authResult?.error || authError?.message || 'Authentication failed');
}
// Check if user is banned
if (authResult.banned) {
const reason = authResult.banReason
? `Reason: ${authResult.banReason}`
// CRITICAL: Check ban status immediately after successful authentication
const { data: profile } = await supabase
.from('profiles')
.select('banned, ban_reason')
.eq('user_id', data.user.id)
.single();
if (profile?.banned) {
// Sign out immediately
await supabase.auth.signOut();
const reason = profile.ban_reason
? `Reason: ${profile.ban_reason}`
: 'Contact support for assistance.';
toast({
variant: "destructive",
title: "Account Suspended",
description: `Your account has been suspended. ${reason}`,
duration: 10000,
duration: 10000
});
setLoading(false);
return;
return; // Stop authentication flow
}
// Check if MFA is required
if (authResult.mfaRequired) {
// NO SESSION EXISTS YET - show MFA challenge
console.log('[Auth] MFA required - no session created yet');
setMfaFactorId(authResult.factorId);
setMfaChallengeId(authResult.challengeId);
setMfaPendingUserId(authResult.userId);
setLoading(false);
return; // User has NO session - MFA modal will show
// Check if MFA is required (user exists but no session)
if (data.user && !data.session) {
const totpFactor = data.user.factors?.find(f => f.factor_type === 'totp' && f.status === 'verified');
if (totpFactor) {
setMfaFactorId(totpFactor.id);
setLoading(false);
return;
}
}
// No MFA required - user has session
console.log('[Auth] No MFA required - user authenticated');
// Set the session in Supabase client
if (authResult.session) {
await supabase.auth.setSession(authResult.session);
// Track auth method for audit logging
setAuthMethod('password');
// Check if MFA step-up is required
const { handlePostAuthFlow } = await import('@/lib/authService');
const postAuthResult = await handlePostAuthFlow(data.session, 'password');
if (postAuthResult.success && postAuthResult.data.shouldRedirect) {
// Get the TOTP factor ID
const { data: factors } = await supabase.auth.mfa.listFactors();
const totpFactor = factors?.totp?.find(f => f.status === 'verified');
if (totpFactor) {
setMfaFactorId(totpFactor.id);
setLoading(false);
return; // Stay on page, show MFA modal
}
}
toast({
title: "Welcome back!",
description: "You've been signed in successfully."
});
// Navigate after brief delay
setTimeout(() => {
navigate('/');
// Verify session was stored
setTimeout(async () => {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
toast({
variant: "destructive",
title: "Session Error",
description: "Login succeeded but session was not stored. Please check your browser settings and enable cookies/storage."
});
} else {
toast({
title: "Welcome back!",
description: "You've been signed in successfully."
});
}
}, 500);
} catch (error) {
@@ -165,11 +185,11 @@ export default function Auth() {
// Enhanced error messages
const errorMsg = getErrorMessage(error);
let errorMessage = errorMsg;
if (errorMsg.includes('Invalid login credentials') || errorMsg.includes('Invalid credentials')) {
if (errorMsg.includes('Invalid login credentials')) {
errorMessage = 'Invalid email or password. Please try again.';
} else if (errorMsg.includes('Email not confirmed')) {
errorMessage = 'Please confirm your email address before signing in.';
} else if (errorMsg.includes('Too many requests')) {
} else if (error.message.includes('Too many requests')) {
errorMessage = 'Too many login attempts. Please wait a few minutes and try again.';
}
@@ -184,40 +204,33 @@ export default function Auth() {
};
const handleMfaSuccess = async () => {
console.log('[Auth] MFA verification succeeded - no further action needed');
// Verify AAL upgrade was successful
const { data: { session } } = await supabase.auth.getSession();
const verification = await verifyMfaUpgrade(session);
// MFA verification is handled by MFAChallenge component
// which calls verify-mfa-and-login edge function
// The session is automatically set by the edge function
if (!verification.success) {
toast({
variant: "destructive",
title: "MFA Verification Failed",
description: verification.error || "Failed to upgrade session. Please try again."
});
// Force sign out on verification failure
await supabase.auth.signOut();
setMfaFactorId(null);
return;
}
// Clear state
setMfaFactorId(null);
setMfaChallengeId(null);
setMfaPendingUserId(null);
toast({
title: "Authentication complete",
description: "You've been signed in successfully.",
title: "Welcome back!",
description: "You've been signed in successfully."
});
setTimeout(() => {
navigate('/');
}, 500);
};
const handleMfaCancel = async () => {
console.log('[Auth] User cancelled MFA verification');
// Clear state - no credentials stored
const handleMfaCancel = () => {
setMfaFactorId(null);
setMfaChallengeId(null);
setMfaPendingUserId(null);
setSignInCaptchaKey(prev => prev + 1);
toast({
title: "Authentication cancelled",
description: "Please sign in again when you're ready to complete two-factor authentication.",
});
};
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault();
@@ -268,7 +281,6 @@ export default function Auth() {
email: formData.email,
password: formData.password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
captchaToken: tokenToUse,
data: {
username: formData.username,
@@ -415,35 +427,11 @@ export default function Auth() {
)}
{mfaFactorId ? (
<Dialog open={!!mfaFactorId} onOpenChange={(isOpen) => {
if (!isOpen) {
handleMfaCancel();
}
}}>
<DialogContent
className="sm:max-w-md"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<div className="flex items-center gap-2 justify-center mb-2">
<Shield className="h-6 w-6 text-primary" />
<DialogTitle>Two-Factor Authentication Required</DialogTitle>
</div>
<DialogDescription className="text-center">
Your account security settings require MFA verification to continue.
</DialogDescription>
</DialogHeader>
<MFAChallenge
factorId={mfaFactorId}
challengeId={mfaChallengeId}
userId={mfaPendingUserId}
onSuccess={handleMfaSuccess}
onCancel={handleMfaCancel}
/>
</DialogContent>
</Dialog>
<MFAChallenge
factorId={mfaFactorId}
onSuccess={handleMfaSuccess}
onCancel={handleMfaCancel}
/>
) : (
<>
<form onSubmit={handleSignIn} className="space-y-4">

View File

@@ -1,148 +0,0 @@
/**
* Auth0 Callback Page
*
* Handles Auth0 authentication callback and syncs user to Supabase
*/
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth0 } from '@auth0/auth0-react';
import { supabase } from '@/integrations/supabase/client';
import { Header } from '@/components/layout/Header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Loader2, CheckCircle, XCircle, Shield } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
type SyncStatus = 'processing' | 'success' | 'error';
export default function Auth0Callback() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { isAuthenticated, isLoading, user, getAccessTokenSilently } = useAuth0();
const [syncStatus, setSyncStatus] = useState<SyncStatus>('processing');
const [errorMessage, setErrorMessage] = useState<string>('');
const { toast } = useToast();
useEffect(() => {
const syncUserToSupabase = async () => {
if (isLoading) return;
if (!isAuthenticated || !user) {
setSyncStatus('error');
setErrorMessage('Authentication failed. Please try again.');
return;
}
try {
console.log('[Auth0Callback] Syncing user to Supabase:', user.sub);
// Get Auth0 access token
const accessToken = await getAccessTokenSilently();
// Call sync edge function
const { data, error } = await supabase.functions.invoke('auth0-sync-user', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: {
email: user.email,
name: user.name,
picture: user.picture,
email_verified: user.email_verified,
},
});
if (error || !data?.success) {
throw new Error(data?.error || error?.message || 'Sync failed');
}
console.log('[Auth0Callback] User synced successfully:', data.profile);
setSyncStatus('success');
toast({
title: 'Welcome back!',
description: 'You\'ve been signed in successfully.',
});
// Redirect after brief delay
setTimeout(() => {
const redirectTo = searchParams.get('redirect') || '/';
navigate(redirectTo);
}, 1500);
} catch (error) {
console.error('[Auth0Callback] Sync error:', error);
setSyncStatus('error');
setErrorMessage(error instanceof Error ? error.message : 'Failed to sync user data');
}
};
syncUserToSupabase();
}, [isAuthenticated, isLoading, user, getAccessTokenSilently, navigate, searchParams, toast]);
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-16">
<div className="max-w-md mx-auto">
<Card>
<CardHeader>
<div className="flex items-center justify-center mb-4">
{syncStatus === 'processing' && (
<Loader2 className="h-12 w-12 text-primary animate-spin" />
)}
{syncStatus === 'success' && (
<CheckCircle className="h-12 w-12 text-green-500" />
)}
{syncStatus === 'error' && (
<XCircle className="h-12 w-12 text-destructive" />
)}
</div>
<CardTitle className="text-center">
{syncStatus === 'processing' && 'Completing Sign In...'}
{syncStatus === 'success' && 'Sign In Successful!'}
{syncStatus === 'error' && 'Sign In Error'}
</CardTitle>
<CardDescription className="text-center">
{syncStatus === 'processing' && 'Please wait while we set up your account'}
{syncStatus === 'success' && 'Redirecting you to ThrillWiki...'}
{syncStatus === 'error' && 'Something went wrong during authentication'}
</CardDescription>
</CardHeader>
{syncStatus === 'error' && (
<CardContent>
<Alert variant="destructive">
<AlertDescription>
{errorMessage || 'An unexpected error occurred. Please try signing in again.'}
</AlertDescription>
</Alert>
<div className="mt-4 space-y-2">
<Button
onClick={() => navigate('/auth')}
className="w-full"
>
Return to Sign In
</Button>
</div>
</CardContent>
)}
{syncStatus === 'processing' && (
<CardContent>
<div className="space-y-2 text-sm text-muted-foreground text-center">
<p>Syncing your profile...</p>
<p>This should only take a moment</p>
</div>
</CardContent>
)}
</Card>
</div>
</main>
</div>
);
}

View File

@@ -114,15 +114,11 @@ export default function AuthCallback() {
const result = await handlePostAuthFlow(session, authMethod);
if (result.success && result.data?.shouldRedirect) {
// CRITICAL SECURITY FIX: Get factor BEFORE destroying session
// Get factor ID and show modal instead of redirecting
const { data: factors } = await supabase.auth.mfa.listFactors();
const totpFactor = factors?.totp?.find(f => f.status === 'verified');
if (totpFactor) {
// OAuth flow: We can't store the OAuth token, so we keep the AAL1 session
// This is unavoidable for OAuth flows - but RLS blocks sensitive operations
console.log('[AuthCallback] OAuth MFA required - keeping AAL1 session (OAuth limitation)');
setMfaFactorId(totpFactor.id);
setStatus('mfa_required');
return;

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { useBlogPost } from '@/hooks/blog/useBlogPost';
import { MarkdownRenderer } from '@/components/blog/MarkdownRenderer';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
@@ -13,13 +13,26 @@ import { Header } from '@/components/layout/Header';
import { Footer } from '@/components/layout/Footer';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
import { useQueryInvalidation } from '@/lib/queryInvalidation';
export default function BlogPost() {
const { slug } = useParams<{ slug: string }>();
const { invalidateBlogPost } = useQueryInvalidation();
const { data: post, isLoading } = useBlogPost(slug);
const { data: post, isLoading } = useQuery({
queryKey: ['blog-post', slug],
queryFn: async () => {
const query = supabase
.from('blog_posts')
.select('*, profiles!inner(username, display_name, avatar_url, avatar_image_id)')
.eq('slug', slug)
.eq('status', 'published')
.single();
const { data, error } = await query;
if (error) throw error;
return data;
},
enabled: !!slug,
});
// Update document title when post changes
useDocumentTitle(post?.title || 'Blog Post');
@@ -36,12 +49,9 @@ export default function BlogPost() {
useEffect(() => {
if (slug) {
supabase.rpc('increment_blog_view_count', { post_slug: slug }).then(() => {
// Invalidate blog post cache to update view count
invalidateBlogPost(slug);
});
supabase.rpc('increment_blog_view_count', { post_slug: slug });
}
}, [slug, invalidateBlogPost]);
}, [slug]);
if (isLoading) {
return (

View File

@@ -26,21 +26,20 @@ 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<Company | null>(null);
const [loading, setLoading] = useState(true);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [totalRides, setTotalRides] = useState<number>(0);
const [totalPhotos, setTotalPhotos] = useState<number>(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');
@@ -54,6 +53,12 @@ export default function DesignerDetail() {
enabled: !!designer
});
useEffect(() => {
if (slug) {
fetchDesignerData();
}
}, [slug]);
// Track page view when designer is loaded
useEffect(() => {
if (designer?.id) {
@@ -61,6 +66,54 @@ 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(
@@ -242,10 +295,10 @@ export default function DesignerDetail() {
<TabsList className="grid w-full grid-cols-2 md:grid-cols-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="rides">
Rides {!statsLoading && statistics?.ridesCount ? `(${statistics.ridesCount})` : ''}
Rides {!statsLoading && totalRides > 0 && `(${totalRides})`}
</TabsTrigger>
<TabsTrigger value="photos">
Photos {!statsLoading && statistics?.photosCount ? `(${statistics.photosCount})` : ''}
Photos {!statsLoading && totalPhotos > 0 && `(${totalPhotos})`}
</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
</TabsList>

View File

@@ -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<Company | null>(null);
const [loading, setLoading] = useState(true);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [totalRides, setTotalRides] = useState<number>(0);
const [totalModels, setTotalModels] = useState<number>(0);
const [totalPhotos, setTotalPhotos] = useState<number>(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,6 +54,12 @@ export default function ManufacturerDetail() {
enabled: !!manufacturer
});
useEffect(() => {
if (slug) {
fetchManufacturerData();
}
}, [slug]);
// Track page view when manufacturer is loaded
useEffect(() => {
if (manufacturer?.id) {
@@ -61,6 +67,63 @@ 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(
@@ -244,13 +307,13 @@ export default function ManufacturerDetail() {
<TabsList className="grid w-full grid-cols-2 md:grid-cols-5">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="rides">
Rides {!statsLoading && statistics?.ridesCount ? `(${statistics.ridesCount})` : ''}
Rides {!statsLoading && totalRides > 0 && `(${totalRides})`}
</TabsTrigger>
<TabsTrigger value="models">
Models {!statsLoading && statistics?.modelsCount ? `(${statistics.modelsCount})` : ''}
Models {!statsLoading && totalModels > 0 && `(${totalModels})`}
</TabsTrigger>
<TabsTrigger value="photos">
Photos {!statsLoading && statistics?.photosCount ? `(${statistics.photosCount})` : ''}
Photos {!statsLoading && totalPhotos > 0 && `(${totalPhotos})`}
</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
</TabsList>

View File

@@ -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<Company | null>(null);
const [parks, setParks] = useState<Park[]>([]);
const [loading, setLoading] = useState(true);
const [parksLoading, setParksLoading] = useState(true);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [totalParks, setTotalParks] = useState<number>(0);
const [operatingRides, setOperatingRides] = useState<number>(0);
const [statsLoading, setStatsLoading] = useState(true);
const [totalPhotos, setTotalPhotos] = useState<number>(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,6 +57,12 @@ export default function OperatorDetail() {
enabled: !!operator
});
useEffect(() => {
if (slug) {
fetchOperatorData();
}
}, [slug]);
// Track page view when operator is loaded
useEffect(() => {
if (operator?.id) {
@@ -64,6 +70,95 @@ 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(
@@ -214,29 +309,29 @@ export default function OperatorDetail() {
{/* Company Info */}
<div className="flex flex-wrap justify-center gap-4 mb-8">
{!statsLoading && statistics?.parksCount ? (
{!statsLoading && totalParks > 0 && (
<Card>
<CardContent className="p-4 text-center">
<FerrisWheel className="w-6 h-6 text-primary mx-auto mb-2" />
<div className="text-2xl font-bold">{statistics.parksCount}</div>
<div className="text-2xl font-bold">{totalParks}</div>
<div className="text-sm text-muted-foreground">
{statistics.parksCount === 1 ? 'Park Operated' : 'Parks Operated'}
{totalParks === 1 ? 'Park Operated' : 'Parks Operated'}
</div>
</CardContent>
</Card>
) : null}
)}
{!statsLoading && statistics?.operatingRidesCount ? (
{!statsLoading && operatingRides > 0 && (
<Card>
<CardContent className="p-4 text-center">
<Gauge className="w-6 h-6 text-accent mx-auto mb-2" />
<div className="text-2xl font-bold">{statistics.operatingRidesCount}</div>
<div className="text-2xl font-bold">{operatingRides}</div>
<div className="text-sm text-muted-foreground">
Operating {statistics.operatingRidesCount === 1 ? 'Ride' : 'Rides'}
Operating {operatingRides === 1 ? 'Ride' : 'Rides'}
</div>
</CardContent>
</Card>
) : null}
)}
{operator.founded_year && (
<Card>
@@ -270,10 +365,10 @@ export default function OperatorDetail() {
<TabsList className="grid w-full grid-cols-2 md:grid-cols-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="parks">
Parks {!statsLoading && statistics?.parksCount ? `(${statistics.parksCount})` : ''}
Parks {!statsLoading && totalParks > 0 && `(${totalParks})`}
</TabsTrigger>
<TabsTrigger value="photos">
Photos {!statsLoading && statistics?.photosCount ? `(${statistics.photosCount})` : ''}
Photos {!statsLoading && totalPhotos > 0 && `(${totalPhotos})`}
</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
</TabsList>

View File

@@ -32,9 +32,6 @@ import { PersonalLocationDisplay } from '@/components/profile/PersonalLocationDi
import { useUserRole } from '@/hooks/useUserRole';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
import { useProfileActivity } from '@/hooks/profile/useProfileActivity';
import { useProfileStats } from '@/hooks/profile/useProfileStats';
import { useQueryInvalidation } from '@/lib/queryInvalidation';
// Activity type definitions
interface SubmissionActivity {
@@ -150,9 +147,13 @@ export default function Profile() {
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const [avatarUrl, setAvatarUrl] = useState<string>('');
const [avatarImageId, setAvatarImageId] = useState<string>('');
// Query invalidation for cache updates
const { invalidateProfileActivity, invalidateProfileStats } = useQueryInvalidation();
const [calculatedStats, setCalculatedStats] = useState({
rideCount: 0,
coasterCount: 0,
parkCount: 0
});
const [recentActivity, setRecentActivity] = useState<ActivityEntry[]>([]);
const [activityLoading, setActivityLoading] = useState(false);
// User role checking
const { isModerator, loading: rolesLoading } = useUserRole();
@@ -171,18 +172,6 @@ export default function Profile() {
// Username validation
const usernameValidation = useUsernameValidation(editForm.username, profile?.username);
// Optimized activity and stats hooks
const isOwnProfile = currentUser && profile && currentUser.id === profile.user_id;
const { data: calculatedStats = { rideCount: 0, coasterCount: 0, parkCount: 0 } } = useProfileStats(profile?.user_id);
const { data: recentActivity = [], isLoading: activityLoading } = useProfileActivity(
profile?.user_id,
isOwnProfile || false,
isModerator()
);
// Cast activity to local types for type safety
const typedActivity = recentActivity as ActivityEntry[];
useEffect(() => {
getCurrentUser();
if (username) {
@@ -192,6 +181,214 @@ export default function Profile() {
}
}, [username]);
const fetchCalculatedStats = async (userId: string) => {
try {
// Fetch ride credits stats
const { data: ridesData, error: ridesError } = await supabase
.from('user_ride_credits')
.select(`
ride_count,
rides!inner(category, park_id)
`)
.eq('user_id', userId);
if (ridesError) throw ridesError;
// Calculate total rides count (sum of all ride_count values)
const totalRides = ridesData?.reduce((sum, credit) => sum + (credit.ride_count || 0), 0) || 0;
// Calculate coasters count (distinct rides where category is roller_coaster)
const coasterRides = ridesData?.filter(credit =>
credit.rides?.category === 'roller_coaster'
) || [];
const uniqueCoasters = new Set(coasterRides.map(credit => credit.rides));
const coasterCount = uniqueCoasters.size;
// Calculate parks count (distinct parks where user has ridden at least one ride)
const parkRides = ridesData?.map(credit => credit.rides?.park_id).filter(Boolean) || [];
const uniqueParks = new Set(parkRides);
const parkCount = uniqueParks.size;
setCalculatedStats({
rideCount: totalRides,
coasterCount: coasterCount,
parkCount: parkCount
});
} catch (error) {
console.error('Error fetching calculated stats:', error);
toast({
variant: 'destructive',
description: getErrorMessage(error),
});
// Set defaults on error
setCalculatedStats({
rideCount: 0,
coasterCount: 0,
parkCount: 0
});
}
};
const fetchRecentActivity = async (userId: string) => {
setActivityLoading(true);
try {
const isOwnProfile = currentUser && currentUser.id === userId;
// Wait for role loading to complete
if (rolesLoading) {
setActivityLoading(false);
return;
}
// Check user privacy settings for activity visibility
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 activity is not public and viewer is not owner or moderator, show empty
if (activityVisibility !== 'public' && !isOwnProfile && !isModerator()) {
setRecentActivity([]);
setActivityLoading(false);
return;
}
// Fetch last 10 reviews
let reviewsQuery = 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)
.order('created_at', { ascending: false })
.limit(10);
// Regular users viewing others: show only approved reviews
if (!isOwnProfile && !isModerator()) {
reviewsQuery = reviewsQuery.eq('moderation_status', 'approved');
}
const { data: reviews, error: reviewsError } = await reviewsQuery;
if (reviewsError) throw reviewsError;
// Fetch last 10 ride credits
const { data: credits, error: creditsError } = await 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);
if (creditsError) throw creditsError;
// Fetch last 10 submissions with enriched data
let submissionsQuery = supabase
.from('content_submissions')
.select('id, submission_type, content, status, created_at')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(10);
// Regular users viewing others: show only approved submissions
// Moderators/Admins/Superusers see all submissions (they bypass the submission process)
if (!isOwnProfile && !isModerator()) {
submissionsQuery = submissionsQuery.eq('status', 'approved');
}
const { data: submissions, error: submissionsError } = await submissionsQuery;
if (submissionsError) throw submissionsError;
// Enrich submissions with entity data and photos
const enrichedSubmissions = await Promise.all((submissions || []).map(async (sub) => {
const enriched: any = { ...sub };
// For photo submissions, get photo count and preview
if (sub.submission_type === 'photo') {
const { data: photoSubs } = await supabase
.from('photo_submissions')
.select('id, entity_type, entity_id')
.eq('submission_id', sub.id)
.maybeSingle();
if (photoSubs) {
const { data: photoItems, count } = await supabase
.from('photo_submission_items')
.select('cloudflare_image_url', { count: 'exact' })
.eq('photo_submission_id', photoSubs.id)
.order('order_index', { ascending: true })
.limit(1);
enriched.photo_count = count || 0;
enriched.photo_preview = photoItems?.[0]?.cloudflare_image_url;
enriched.entity_type = photoSubs.entity_type;
enriched.entity_id = photoSubs.entity_id;
// Get entity name/slug for linking
if (photoSubs.entity_type === 'park') {
const { data: park } = await supabase
.from('parks')
.select('name, slug')
.eq('id', photoSubs.entity_id)
.single();
enriched.content = { ...enriched.content, entity_name: park?.name, entity_slug: park?.slug };
} else if (photoSubs.entity_type === 'ride') {
const { data: ride } = await supabase
.from('rides')
.select('name, slug, parks!inner(name, slug)')
.eq('id', photoSubs.entity_id)
.single();
enriched.content = {
...enriched.content,
entity_name: ride?.name,
entity_slug: ride?.slug,
park_name: ride?.parks?.name,
park_slug: ride?.parks?.slug
};
}
}
}
return enriched;
}));
// Fetch last 10 rankings (public top lists)
let rankingsQuery = supabase
.from('user_top_lists')
.select('id, title, description, list_type, created_at')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(10);
if (!isOwnProfile) {
rankingsQuery = rankingsQuery.eq('is_public', true);
}
const { data: rankings, error: rankingsError } = await rankingsQuery;
if (rankingsError) throw rankingsError;
// Combine and sort by date
const combined = [
...(reviews?.map(r => ({ ...r, type: 'review' as const })) || []),
...(credits?.map(c => ({ ...c, type: 'credit' as const })) || []),
...(enrichedSubmissions?.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) as ActivityEntry[];
setRecentActivity(combined);
} catch (error) {
console.error('Error fetching recent activity:', error);
toast({
variant: 'destructive',
description: getErrorMessage(error),
});
setRecentActivity([]);
} finally {
setActivityLoading(false);
}
};
const getCurrentUser = async () => {
const {
data: {
@@ -237,6 +434,10 @@ export default function Profile() {
});
setAvatarUrl(data.avatar_url || '');
setAvatarImageId(data.avatar_image_id || '');
// Fetch calculated stats and recent activity for this user
await fetchCalculatedStats(data.user_id);
await fetchRecentActivity(data.user_id);
}
} catch (error) {
console.error('Error fetching profile:', error);
@@ -274,6 +475,10 @@ export default function Profile() {
});
setAvatarUrl(data.avatar_url || '');
setAvatarImageId(data.avatar_image_id || '');
// Fetch calculated stats and recent activity for the current user
await fetchCalculatedStats(user.id);
await fetchRecentActivity(user.id);
}
} catch (error) {
console.error('Error fetching profile:', error);
@@ -329,13 +534,6 @@ export default function Profile() {
error
} = await supabase.from('profiles').update(updateData).eq('user_id', currentUser.id);
if (error) throw error;
// Invalidate profile caches across the app
if (currentUser.id) {
invalidateProfileActivity(currentUser.id);
invalidateProfileStats(currentUser.id);
}
setProfile(prev => prev ? {
...prev,
...updateData
@@ -385,11 +583,6 @@ export default function Profile() {
}).eq('user_id', currentUser.id);
if (error) throw error;
// Invalidate profile activity cache (avatar shows in activity)
if (currentUser.id) {
invalidateProfileActivity(currentUser.id);
}
// Update local profile state
setProfile(prev => prev ? {
...prev,
@@ -416,7 +609,7 @@ export default function Profile() {
});
}
};
const isOwnProfile = currentUser && profile && currentUser.id === profile.user_id;
if (loading) {
return <div className="min-h-screen bg-background">
<Header />
@@ -639,7 +832,7 @@ export default function Profile() {
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
) : typedActivity.length === 0 ? (
) : recentActivity.length === 0 ? (
<div className="text-center py-12">
<Trophy className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2">No recent activity yet</h3>
@@ -649,7 +842,7 @@ export default function Profile() {
</div>
) : (
<div className="space-y-4">
{typedActivity.map(activity => (
{recentActivity.map(activity => (
<div key={`${activity.type}-${activity.id}`} className="flex gap-4 p-4 rounded-lg border bg-card hover:bg-accent/5 transition-colors">
<div className="flex-shrink-0 mt-1">
{activity.type === 'review' ? (

View File

@@ -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<Company | null>(null);
const [parks, setParks] = useState<Park[]>([]);
const [loading, setLoading] = useState(true);
const [parksLoading, setParksLoading] = useState(true);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [totalParks, setTotalParks] = useState<number>(0);
const [operatingRides, setOperatingRides] = useState<number>(0);
const [statsLoading, setStatsLoading] = useState(true);
const [totalPhotos, setTotalPhotos] = useState<number>(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,6 +57,12 @@ export default function PropertyOwnerDetail() {
enabled: !!owner
});
useEffect(() => {
if (slug) {
fetchOwnerData();
}
}, [slug]);
// Track page view when property owner is loaded
useEffect(() => {
if (owner?.id) {
@@ -64,6 +70,95 @@ 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(
@@ -214,29 +309,29 @@ export default function PropertyOwnerDetail() {
{/* Company Info */}
<div className="flex flex-wrap justify-center gap-4 mb-8">
{!statsLoading && statistics?.parksCount ? (
{!statsLoading && totalParks > 0 && (
<Card>
<CardContent className="p-4 text-center">
<Building2 className="w-6 h-6 text-primary mx-auto mb-2" />
<div className="text-2xl font-bold">{statistics.parksCount}</div>
<div className="text-2xl font-bold">{totalParks}</div>
<div className="text-sm text-muted-foreground">
{statistics.parksCount === 1 ? 'Park Owned' : 'Parks Owned'}
{totalParks === 1 ? 'Park Owned' : 'Parks Owned'}
</div>
</CardContent>
</Card>
) : null}
)}
{!statsLoading && statistics?.operatingRidesCount ? (
{!statsLoading && operatingRides > 0 && (
<Card>
<CardContent className="p-4 text-center">
<Gauge className="w-6 h-6 text-accent mx-auto mb-2" />
<div className="text-2xl font-bold">{statistics.operatingRidesCount}</div>
<div className="text-2xl font-bold">{operatingRides}</div>
<div className="text-sm text-muted-foreground">
Operating {statistics.operatingRidesCount === 1 ? 'Ride' : 'Rides'}
Operating {operatingRides === 1 ? 'Ride' : 'Rides'}
</div>
</CardContent>
</Card>
) : null}
)}
{owner.founded_year && (
<Card>
@@ -270,10 +365,10 @@ export default function PropertyOwnerDetail() {
<TabsList className="grid w-full grid-cols-2 md:grid-cols-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="parks">
Parks {!statsLoading && statistics?.parksCount ? `(${statistics.parksCount})` : ''}
Parks {!statsLoading && totalParks > 0 && `(${totalParks})`}
</TabsTrigger>
<TabsTrigger value="photos">
Photos {!statsLoading && statistics?.photosCount ? `(${statistics.photosCount})` : ''}
Photos {!statsLoading && totalPhotos > 0 && `(${totalPhotos})`}
</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
</TabsList>

View File

@@ -24,24 +24,18 @@ 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<RideModel | null>(null);
const [manufacturer, setManufacturer] = useState<Company | null>(null);
const [rides, setRides] = useState<Ride[]>([]);
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');
@@ -54,10 +48,78 @@ 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;
@@ -76,6 +138,7 @@ export default function RideModelDetail() {
});
setIsEditModalOpen(false);
fetchData();
} catch (error) {
const errorMsg = getErrorMessage(error);
toast({

View File

@@ -80,7 +80,6 @@ import { logger } from '@/lib/logger';
import { contactCategories } from '@/lib/contactValidation';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import { AdminLayout } from '@/components/layout/AdminLayout';
import { queryKeys } from '@/lib/queryKeys';
interface ContactSubmission {
id: string;
@@ -160,7 +159,7 @@ export default function AdminContact() {
// Fetch contact submissions
const { data: submissions, isLoading } = useQuery({
queryKey: queryKeys.admin.contactSubmissions(statusFilter, categoryFilter, searchQuery, showArchived),
queryKey: ['admin-contact-submissions', statusFilter, categoryFilter, searchQuery, showArchived],
queryFn: async () => {
let query = supabase
.from('contact_submissions')
@@ -283,10 +282,7 @@ export default function AdminContact() {
.order('created_at', { ascending: true })
.then(({ data }) => setEmailThreads((data as EmailThread[]) || []));
}
queryClient.invalidateQueries({
queryKey: ['admin-contact-submissions'],
exact: false
});
queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
},
onError: (error: Error) => {
handleError(error, { action: 'Send Email Reply' });
@@ -324,10 +320,7 @@ export default function AdminContact() {
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['admin-contact-submissions'],
exact: false
});
queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
handleSuccess('Status Updated', 'Contact submission status has been updated');
setSelectedSubmission(null);
setAdminNotes('');
@@ -352,10 +345,7 @@ export default function AdminContact() {
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['admin-contact-submissions'],
exact: false
});
queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
handleSuccess('Archived', 'Contact submission has been archived');
setSelectedSubmission(null);
},
@@ -378,10 +368,7 @@ export default function AdminContact() {
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['admin-contact-submissions'],
exact: false
});
queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
handleSuccess('Restored', 'Contact submission has been restored from archive');
setSelectedSubmission(null);
},
@@ -401,10 +388,7 @@ export default function AdminContact() {
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['admin-contact-submissions'],
exact: false
});
queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
handleSuccess('Deleted', 'Contact submission has been permanently deleted');
setSelectedSubmission(null);
},
@@ -444,10 +428,7 @@ export default function AdminContact() {
};
const handleRefreshSubmissions = () => {
queryClient.invalidateQueries({
queryKey: ['admin-contact-submissions'],
exact: false
});
queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
};
const handleCopyTicket = (ticketNumber: string) => {