import { useState, useEffect } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { formatDistanceToNow } from 'date-fns'; import { User as SupabaseUser } from '@supabase/supabase-js'; import { Header } from '@/components/layout/Header'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; import { useAuth } from '@/hooks/useAuth'; import { useProfile } from '@/hooks/useProfile'; import { UserReviewsList } from '@/components/profile/UserReviewsList'; import { UserListManager } from '@/components/lists/UserListManager'; import { RideCreditsManager } from '@/components/profile/RideCreditsManager'; import { useUsernameValidation } from '@/hooks/useUsernameValidation'; import { User, MapPin, Calendar, Star, Trophy, Settings, Camera, Edit3, Save, X, ArrowLeft, Check, AlertCircle, Loader2, UserX, FileText, Image } from 'lucide-react'; import { Profile as ProfileType } from '@/types/database'; import { supabase } from '@/lib/supabaseClient'; import { useToast } from '@/hooks/use-toast'; import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler'; import { PhotoUpload } from '@/components/upload/PhotoUpload'; import { profileEditSchema } from '@/lib/validation'; import { LocationDisplay } from '@/components/profile/LocationDisplay'; import { UserBlockButton } from '@/components/profile/UserBlockButton'; import { PersonalLocationDisplay } from '@/components/profile/PersonalLocationDisplay'; import { useUserRole } from '@/hooks/useUserRole'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useOpenGraph } from '@/hooks/useOpenGraph'; // Activity type definitions interface SubmissionActivity { id: string; type: 'submission'; submission_type: 'park' | 'ride' | 'photo' | 'company' | 'ride_model'; status: string; created_at: string; content?: { action?: 'edit' | 'create'; name?: string; slug?: string; entity_slug?: string; entity_name?: string; park_slug?: string; park_name?: string; company_type?: string; manufacturer_slug?: string; description?: string; }; photo_preview?: string; photo_count?: number; entity_type?: 'park' | 'ride' | 'company'; entity_id?: string; } interface RankingActivity { id: string; type: 'ranking'; title: string; description?: string; list_type: string; created_at: string; } interface ReviewActivity { id: string; type: 'review'; rating: number; title?: string; content?: string; created_at: string; moderation_status?: string; park_id?: string; ride_id?: string; parks?: { name: string; slug: string; } | null; rides?: { name: string; slug: string; parks?: { name: string; slug: string; } | null; } | null; } interface CreditActivity { id: string; type: 'credit'; ride_count: number; first_ride_date?: string; created_at: string; rides?: { name: string; slug: string; parks?: { name: string; slug: string; } | null; } | null; } type ActivityEntry = SubmissionActivity | RankingActivity | ReviewActivity | CreditActivity; // Type guards const isSubmissionActivity = (act: ActivityEntry): act is SubmissionActivity => act.type === 'submission'; const isRankingActivity = (act: ActivityEntry): act is RankingActivity => act.type === 'ranking'; const isReviewActivity = (act: ActivityEntry): act is ReviewActivity => act.type === 'review'; const isCreditActivity = (act: ActivityEntry): act is CreditActivity => act.type === 'credit'; export default function Profile() { const { username } = useParams<{ username?: string; }>(); const navigate = useNavigate(); const { toast } = useToast(); const { user: authUser } = useAuth(); const { refreshProfile } = useProfile(authUser?.id); const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); const [editing, setEditing] = useState(false); const [currentUser, setCurrentUser] = useState(null); const [editForm, setEditForm] = useState({ username: '', display_name: '', bio: '' }); const [showUsernameDialog, setShowUsernameDialog] = useState(false); const [formErrors, setFormErrors] = useState>({}); const [avatarUrl, setAvatarUrl] = useState(''); const [avatarImageId, setAvatarImageId] = useState(''); const [calculatedStats, setCalculatedStats] = useState({ rideCount: 0, coasterCount: 0, parkCount: 0 }); const [recentActivity, setRecentActivity] = useState([]); const [activityLoading, setActivityLoading] = useState(false); // User role checking const { isModerator, loading: rolesLoading } = useUserRole(); // Update document title when profile changes useDocumentTitle(profile?.username ? `${profile.username}'s Profile` : 'Profile'); useOpenGraph({ title: profile ? `${profile.display_name || profile.username} - ThrillWiki` : 'User Profile', description: profile?.bio || (profile ? `${profile.display_name || profile.username}'s profile on ThrillWiki` : undefined), imageUrl: profile?.avatar_url, imageId: profile?.avatar_image_id, type: 'profile', enabled: !!profile }); // Username validation const usernameValidation = useUsernameValidation(editForm.username, profile?.username); useEffect(() => { getCurrentUser(); if (username) { fetchProfile(username); } else { fetchCurrentUserProfile(); } }, [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) { logger.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, status, created_at, submission_metadata(name) `) .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 }; // Get name from submission_metadata const metadata = sub.submission_metadata as any; enriched.name = Array.isArray(metadata) && metadata.length > 0 ? metadata[0]?.name : undefined; // 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) { logger.error('Error fetching recent activity', { error }); toast({ variant: 'destructive', description: getErrorMessage(error), }); setRecentActivity([]); } finally { setActivityLoading(false); } }; const getCurrentUser = async () => { const { data: { user } } = await supabase.auth.getUser(); setCurrentUser(user); }; const fetchProfile = async (profileUsername: string) => { try { // Use filtered_profiles view for privacy-respecting queries // This view enforces field-level privacy based on user settings const { data, error } = await supabase .from('filtered_profiles') .select(`*`) .eq('username', profileUsername) .maybeSingle(); if (error) throw error; if (data) { // Fetch location separately if location_id is visible let locationData: any = null; if (data.location_id) { const { data: location } = await supabase .from('locations') .select('*') .eq('id', data.location_id) .single(); locationData = location; } const profileWithLocation = { ...data, location: locationData }; setProfile(profileWithLocation as unknown as ProfileType); setEditForm({ username: data.username || '', display_name: data.display_name || '', bio: data.bio || '' }); 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) { logger.error('Error fetching profile', { error }); toast({ variant: "destructive", title: "Error loading profile", description: getErrorMessage(error) }); } finally { setLoading(false); } }; const fetchCurrentUserProfile = async () => { try { const { data: { user } } = await supabase.auth.getUser(); if (!user) { navigate('/auth'); return; } const { data, error } = await supabase .from('profiles') .select(`*, location:locations(*)`) .eq('user_id', user.id) .maybeSingle(); if (error) throw error; if (data) { setProfile(data as ProfileType); setEditForm({ username: data.username || '', display_name: data.display_name || '', bio: data.bio || '' }); 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) { logger.error('Error fetching profile', { error }); toast({ variant: "destructive", title: "Error loading profile", description: getErrorMessage(error) }); } finally { setLoading(false); } }; const validateForm = () => { const result = profileEditSchema.safeParse(editForm); if (!result.success) { const errors: Record = {}; result.error.issues.forEach(issue => { if (issue.path[0]) { errors[issue.path[0] as string] = issue.message; } }); setFormErrors(errors); return false; } if (!usernameValidation.isValid && editForm.username !== profile?.username) { setFormErrors({ username: usernameValidation.error || 'Invalid username' }); return false; } setFormErrors({}); return true; }; const [isSaving, setIsSaving] = useState(false); const handleSaveProfile = async () => { if (!profile || !currentUser) return; if (!validateForm()) return; const usernameChanged = editForm.username !== profile.username; if (usernameChanged && !showUsernameDialog) { setShowUsernameDialog(true); return; } setIsSaving(true); try { const updateData: any = { display_name: editForm.display_name, bio: editForm.bio, avatar_url: avatarUrl, avatar_image_id: avatarImageId }; if (usernameChanged) { updateData.username = editForm.username; } const { error } = await supabase.from('profiles').update(updateData).eq('user_id', currentUser.id); if (error) throw error; setProfile(prev => prev ? { ...prev, ...updateData } : null); setEditing(false); setShowUsernameDialog(false); if (usernameChanged) { toast({ title: "Profile updated", description: "Your username and profile URL have been updated successfully." }); // Navigate to new username URL navigate(`/profile/${editForm.username}`); } else { toast({ title: "Profile updated", description: "Your profile has been updated successfully." }); } } catch (error) { toast({ variant: "destructive", title: "Error updating profile", description: getErrorMessage(error) }); } finally { setIsSaving(false); } }; const confirmUsernameChange = () => { setShowUsernameDialog(false); handleSaveProfile(); }; const handleAvatarUpload = async (urls: string[], imageId?: string) => { if (!currentUser || !urls[0]) return; const newAvatarUrl = urls[0]; const newImageId = imageId || ''; // Update local state immediately setAvatarUrl(newAvatarUrl); setAvatarImageId(newImageId); try { // Update database immediately const { error } = await supabase.from('profiles').update({ avatar_url: newAvatarUrl, avatar_image_id: newImageId }).eq('user_id', currentUser.id); if (error) throw error; // Update local profile state setProfile(prev => prev ? { ...prev, avatar_url: newAvatarUrl, avatar_image_id: newImageId } : null); // Refresh the auth context to update header avatar if (refreshProfile) { await refreshProfile(); } toast({ title: "Avatar updated", description: "Your profile picture has been updated successfully." }); } catch (error) { // Revert local state on error setAvatarUrl(profile?.avatar_url || ''); setAvatarImageId(profile?.avatar_image_id || ''); toast({ variant: "destructive", title: "Error updating avatar", description: getErrorMessage(error) }); } }; const isOwnProfile = currentUser && profile && currentUser.id === profile.user_id; if (loading) { return
; } if (!profile) { return

Profile Not Found

The profile you're looking for doesn't exist.

; } return
{/* Profile Header */}
{ toast({ title: "Upload Error", description: error, variant: "destructive" }); }} className="mb-4" />
{isOwnProfile && !editing && } {!isOwnProfile && }
{editing && isOwnProfile ?
setEditForm(prev => ({ ...prev, username: e.target.value }))} placeholder="your_username" className={`pr-10 ${formErrors.username ? 'border-destructive' : ''}`} />
{usernameValidation.isChecking ? : editForm.username === profile?.username ? : usernameValidation.isValid ? : usernameValidation.error ? : null}
{formErrors.username &&

{formErrors.username}

} {usernameValidation.error && editForm.username !== profile?.username &&

{usernameValidation.error}

} {usernameValidation.isValid && editForm.username !== profile?.username &&

Username is available!

}

Your profile URL will be /profile/{editForm.username}

setEditForm(prev => ({ ...prev, display_name: e.target.value }))} placeholder="Your display name" className={formErrors.display_name ? 'border-destructive' : ''} /> {formErrors.display_name &&

{formErrors.display_name}

}