mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 11:11:13 -05:00
Reverted to commit 0091584677
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user