diff --git a/app/(landing)/hackathons/[slug]/team-invitations/[token]/accept/page.tsx b/app/(landing)/hackathons/[slug]/team-invitations/[token]/accept/page.tsx index 8a6a064a..07ee8742 100644 --- a/app/(landing)/hackathons/[slug]/team-invitations/[token]/accept/page.tsx +++ b/app/(landing)/hackathons/[slug]/team-invitations/[token]/accept/page.tsx @@ -60,13 +60,14 @@ const AcceptTeamInvitationPage = () => { setError(null); try { - const response = await acceptTeamInvitation(hackathonSlug, { - token: invitationToken, - }); + const response = await acceptTeamInvitation( + hackathonSlug, + invitationToken + ); if (response.success) { - setSuccessTeamName(response.data.teamName); - toast.success(`Successfully joined ${response.data.teamName}!`); + setSuccessTeamName(response.data?.teamId || 'the team'); + toast.success('Successfully joined the team!'); setTimeout(() => { router.push(`/hackathons/${hackathonSlug}`); }, 2000); diff --git a/app/(landing)/hackathons/[slug]/team-invitations/[token]/reject/page.tsx b/app/(landing)/hackathons/[slug]/team-invitations/[token]/reject/page.tsx new file mode 100644 index 00000000..581e3474 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/team-invitations/[token]/reject/page.tsx @@ -0,0 +1,222 @@ +'use client'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import React, { useEffect, useState } from 'react'; +import { Users, AlertCircle, Loader2, XCircle } from 'lucide-react'; +import { useAuthStatus } from '@/hooks/use-auth'; +import { rejectTeamInvitation } from '@/lib/api/hackathons'; +import { toast } from 'sonner'; + +const RejectTeamInvitationPage = () => { + const params = useParams(); + const searchParams = useSearchParams(); + const router = useRouter(); + const { isAuthenticated, isLoading: authLoading } = useAuthStatus(); + + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const hackathonSlug = params.slug as string; + const token = params.token as string; + const redirectToken = searchParams.get('token'); + const invitationToken = token || redirectToken; + + useEffect(() => { + if (!invitationToken) { + router.push(`/hackathons/${hackathonSlug}`); + return; + } + + if (!isAuthenticated && !authLoading) { + redirectToAuth(); + } + }, [isAuthenticated, authLoading, invitationToken, hackathonSlug]); + + const redirectToAuth = () => { + const currentUrl = window.location.href; + const encodedRedirect = encodeURIComponent(currentUrl); + router.push(`/auth/login?redirect=${encodedRedirect}`); + }; + + const handleReject = async () => { + if (!invitationToken || !hackathonSlug) return; + + setIsProcessing(true); + setError(null); + + try { + const response = await rejectTeamInvitation( + hackathonSlug, + invitationToken + ); + + if (response.success) { + setSuccess(true); + toast.success('Invitation declined'); + setTimeout(() => { + router.push(`/hackathons/${hackathonSlug}`); + }, 2000); + } + } catch (err: any) { + const errorMessage = err?.message || 'Failed to decline invitation'; + setError(errorMessage); + + if (err?.status === 403) { + toast.error('Authentication required'); + redirectToAuth(); + } else if (err?.status === 404) { + toast.error('Invitation not found or has expired'); + } else { + toast.error(errorMessage); + } + } finally { + setIsProcessing(false); + } + }; + + // Loading authentication state + if (authLoading) { + return ( +
+
+
+
+
+
+ +
+
+ +
+
+
+ +

+ Verifying Invitation +

+ +

+ Please wait while we verify your invitation... +

+ +
+ +
+
+
+
+ ); + } + + // Error state + if (error && !success) { + return ( +
+
+
+
+
+ +
+
+ +

+ Unable to Process Invitation +

+ +

{error}

+ + +
+
+
+ ); + } + + // Success state + if (success) { + return ( +
+
+
+
+
+ +
+
+ +

+ Invitation Declined +

+ +

+ You've declined this team invitation. Redirecting... +

+ +
+ +
+
+
+
+ ); + } + + // Main decline confirmation + return ( +
+
+
+
+
+ +
+
+ +

+ Decline Team Invitation +

+ +

+ Are you sure you want to decline this team invitation? This action + cannot be undone. +

+ +
+ + + +
+
+
+
+ ); +}; + +export default RejectTeamInvitationPage; diff --git a/app/me/settings/SettingsContent.tsx b/app/me/settings/SettingsContent.tsx index d27d9d92..63bcb429 100644 --- a/app/me/settings/SettingsContent.tsx +++ b/app/me/settings/SettingsContent.tsx @@ -32,7 +32,7 @@ const SettingsContent = () => { ); } return ( -
+
{/* Header */}
diff --git a/components/hackathons/participants/participantAvatar.tsx b/components/hackathons/participants/participantAvatar.tsx index 50ee8b95..43ad96ab 100644 --- a/components/hackathons/participants/participantAvatar.tsx +++ b/components/hackathons/participants/participantAvatar.tsx @@ -10,63 +10,100 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { ProfileCard } from './profileCard'; import type { ParticipantDisplay } from '@/lib/api/hackathons/index'; import Image from 'next/image'; +import { useState, useMemo } from 'react'; +import { useParticipants } from '@/hooks/hackathon/use-participants'; +import { useAuthStatus } from '@/hooks/use-auth'; +import { useHackathonData } from '@/lib/providers/hackathonProvider'; +import { InviteUserModal } from '../team-formation/InviteUserModal'; interface ParticipantAvatarProps { participant: ParticipantDisplay; } export function ParticipantAvatar({ participant }: ParticipantAvatarProps) { + const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); + const { allParticipants } = useParticipants(); + const { user } = useAuthStatus(); + const { currentHackathon } = useHackathonData(); + + const currentUserParticipant = useMemo(() => { + if (!user) return null; + const currentUsername = user.username || (user.profile as any)?.username; + const currentUserId = user.id || (user as any).userId; + + return allParticipants.find( + p => + (currentUsername && p.username === currentUsername) || + (currentUserId && p.userId === currentUserId) + ); + }, [user, allParticipants]); + return ( - - - -
-
- - - {/* */} - {/* {participant.username.slice(0, 2).toUpperCase()} */} - - avatar + + + +
+
+ + + {/* */} + {/* {participant.username.slice(0, 2).toUpperCase()} */} + + {participant.name?.charAt(0) || + participant.username?.charAt(0) || + 'U'} + + + + {/* Green status dot if submitted */} + {participant.hasSubmitted && ( +
- - {/* */} - + )} +
- {/* Green status dot if submitted */} - {participant.hasSubmitted && ( -
- )} + + {participant.username + ? participant.username.slice(0, 1).toUpperCase() + + participant.username.slice(1) + : participant.name || 'User'} +
+ - - {participant.username.slice(0, 1).toUpperCase() + - participant.username.slice(1)} - -
- + + setIsInviteModalOpen(true)} + /> + + + - - - - - + {isInviteModalOpen && + currentUserParticipant?.teamId && + currentHackathon?.id && ( + + )} + ); } diff --git a/components/hackathons/participants/profileCard.tsx b/components/hackathons/participants/profileCard.tsx index 3e67dcf8..2ba466dc 100644 --- a/components/hackathons/participants/profileCard.tsx +++ b/components/hackathons/participants/profileCard.tsx @@ -7,14 +7,19 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Separator } from '@/components/ui/separator'; import type { ParticipantDisplay } from '@/lib/api/hackathons/index'; import Image from 'next/image'; -import { MessageCircle, Users, CheckCircle2 } from 'lucide-react'; +import { MessageCircle, Users, CheckCircle2, UserPlus } from 'lucide-react'; import { useParticipants } from '@/hooks/hackathon/use-participants'; import Link from 'next/link'; +import { useAuthStatus } from '@/hooks/use-auth'; +import { useRegisterHackathon } from '@/hooks/hackathon/use-register-hackathon'; +import { useHackathonData } from '@/lib/providers/hackathonProvider'; +import { InviteUserModal } from '../team-formation/InviteUserModal'; const BRAND_COLOR = '#a7f950'; interface ProfileCardProps { participant: ParticipantDisplay; + onInviteClick?: () => void; } // Simple date formatter @@ -37,9 +42,9 @@ const formatJoinDate = (dateString: string) => { return `${months[date.getMonth()]} ${date.getFullYear()}`; }; -export function ProfileCard({ participant }: ProfileCardProps) { +export function ProfileCard({ participant, onInviteClick }: ProfileCardProps) { const [isFollowing, setIsFollowing] = useState(false); - const { participants } = useParticipants(); + const { participants, allParticipants, teams } = useParticipants(); const teamMembers = useMemo(() => { if (participant.role === 'leader' && participant.teamId) { return participants.filter( @@ -51,6 +56,66 @@ export function ProfileCard({ participant }: ProfileCardProps) { const isTeamLeader = participant.role === 'leader' && participant.teamId; + const { user } = useAuthStatus(); + const { currentHackathon } = useHackathonData(); + + const currentUserParticipant = useMemo(() => { + if (!user) return null; + const currentUsername = user.username || (user.profile as any)?.username; + const currentUserId = user.id || (user as any).userId; + + // Use allParticipants to find current user even if search filtering is active + return allParticipants.find( + p => + (currentUsername && p.username === currentUsername) || + (currentUserId && p.userId === currentUserId) + ); + }, [user, allParticipants]); + + // Check if current user can invite this participant + const canInvite = useMemo(() => { + if (!user || !currentUserParticipant || !currentHackathon) { + console.log('[DEBUG] canInvite: missing prerequisites', { + user: !!user, + currentUserParticipant: !!currentUserParticipant, + currentHackathon: !!currentHackathon, + }); + return false; + } + + const currentUsername = user.username || (user.profile as any)?.username; + const currentUserId = user.id || (user as any).userId; + + // Don't invite yourself + if ( + participant.id === currentUserParticipant.id || + (currentUsername && participant.username === currentUsername) || + (currentUserId && participant.userId === currentUserId) + ) { + return false; + } + + // 1. Check leadership from participants list (enriched) + const isEnrichedLeader = + currentUserParticipant.role?.toLowerCase() === 'leader'; + + // 2. Check leadership directly from teams list (fallback/backup) + const isDirectLeader = teams.some(t => t.leaderId === currentUserId); + + const isLeader = isEnrichedLeader || isDirectLeader; + + console.log('[DEBUG] ProfileCard canInvite check:', { + target: participant.username, + isLeader, + isEnrichedLeader, + isDirectLeader, + currentUserRole: currentUserParticipant.role, + currentUserId, + }); + + return isLeader; + }, [user, currentUserParticipant, participant, currentHackathon, teams]); + return ( {/* Header with gradient background and wave pattern */} @@ -158,6 +223,19 @@ export function ProfileCard({ participant }: ProfileCardProps) { > + {canInvite && ( + + )}
{/* Team Members */} diff --git a/components/hackathons/team-formation/InviteUserModal.tsx b/components/hackathons/team-formation/InviteUserModal.tsx new file mode 100644 index 00000000..06e5b40a --- /dev/null +++ b/components/hackathons/team-formation/InviteUserModal.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { useTeamInvitations } from '@/hooks/hackathon/use-team-invitations'; +import { Loader2 } from 'lucide-react'; + +interface InviteUserModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + hackathonId: string; + teamId: string; + inviteeId: string; + inviteeName: string; +} + +export function InviteUserModal({ + open, + onOpenChange, + hackathonId, + teamId, + inviteeId, + inviteeName, +}: InviteUserModalProps) { + const [message, setMessage] = useState(''); + const { inviteUser, isInviting } = useTeamInvitations({ + hackathonId, + teamId, + autoFetch: false, + }); + + const handleInvite = async () => { + try { + await inviteUser({ + inviteeIdentifier: inviteeId, + message: message.trim() || undefined, + }); + onOpenChange(false); + setMessage(''); + } catch (error) { + // Error handled by hook toast + } + }; + + return ( + + + + Invite to Team + + Invite{' '} + {inviteeName} to + join your team. + + + +
+
+ +