488 lines
18 KiB
TypeScript
488 lines
18 KiB
TypeScript
|
|
"use client"
|
||
|
|
|
||
|
|
import { useEffect, useState, Suspense } from "react"
|
||
|
|
import { useRouter, useSearchParams } from "next/navigation"
|
||
|
|
import Link from "next/link"
|
||
|
|
import { Button } from "@/components/ui/button"
|
||
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||
|
|
import { Badge } from "@/components/ui/badge"
|
||
|
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||
|
|
import {
|
||
|
|
Vote,
|
||
|
|
ArrowLeft,
|
||
|
|
CheckCircle2,
|
||
|
|
Clock,
|
||
|
|
AlertCircle,
|
||
|
|
User,
|
||
|
|
Image as ImageIcon,
|
||
|
|
Loader2,
|
||
|
|
Shield,
|
||
|
|
Timer,
|
||
|
|
CheckCircle
|
||
|
|
} from "lucide-react"
|
||
|
|
import { useAuth } from "@/hooks/use-auth"
|
||
|
|
import apiClient from "@/lib/api-client"
|
||
|
|
import { API_CONFIG } from "@/lib/config"
|
||
|
|
|
||
|
|
interface Candidate {
|
||
|
|
id: string
|
||
|
|
vote_event_id: string
|
||
|
|
name: string
|
||
|
|
image_url: string
|
||
|
|
description: string
|
||
|
|
created_at: string
|
||
|
|
updated_at: string
|
||
|
|
}
|
||
|
|
|
||
|
|
interface VoteEvent {
|
||
|
|
id: string
|
||
|
|
title: string
|
||
|
|
description: string
|
||
|
|
start_date: string
|
||
|
|
end_date: string
|
||
|
|
is_active: boolean
|
||
|
|
is_voting_open: boolean
|
||
|
|
created_at: string
|
||
|
|
updated_at: string
|
||
|
|
candidates: Candidate[]
|
||
|
|
}
|
||
|
|
|
||
|
|
interface VoteStatus {
|
||
|
|
has_voted: boolean
|
||
|
|
}
|
||
|
|
|
||
|
|
interface VoteRequest {
|
||
|
|
vote_event_id: string
|
||
|
|
candidate_id: string
|
||
|
|
}
|
||
|
|
|
||
|
|
function VotePageContent() {
|
||
|
|
const { user, isAuthenticated, loading } = useAuth()
|
||
|
|
const router = useRouter()
|
||
|
|
const searchParams = useSearchParams()
|
||
|
|
const eventId = searchParams.get('event_id')
|
||
|
|
|
||
|
|
const [event, setEvent] = useState<VoteEvent | null>(null)
|
||
|
|
const [voteStatus, setVoteStatus] = useState<VoteStatus | null>(null)
|
||
|
|
const [selectedCandidate, setSelectedCandidate] = useState<string | null>(null)
|
||
|
|
const [isLoading, setIsLoading] = useState(true)
|
||
|
|
const [isVoting, setIsVoting] = useState(false)
|
||
|
|
const [error, setError] = useState<string | null>(null)
|
||
|
|
const [success, setSuccess] = useState<string | null>(null)
|
||
|
|
|
||
|
|
// Fetch event details and voting status
|
||
|
|
useEffect(() => {
|
||
|
|
if (isAuthenticated && eventId) {
|
||
|
|
fetchEventDetails()
|
||
|
|
fetchVoteStatus()
|
||
|
|
}
|
||
|
|
}, [isAuthenticated, eventId])
|
||
|
|
|
||
|
|
const fetchEventDetails = async () => {
|
||
|
|
try {
|
||
|
|
setIsLoading(true)
|
||
|
|
const response = await apiClient.get(`${API_CONFIG.ENDPOINTS.VOTE_EVENTS}/${eventId}`)
|
||
|
|
|
||
|
|
if (response.data.success) {
|
||
|
|
setEvent(response.data.data)
|
||
|
|
} else {
|
||
|
|
setError("Failed to fetch event details")
|
||
|
|
}
|
||
|
|
} catch (error: any) {
|
||
|
|
console.error('Error fetching event details:', error)
|
||
|
|
setError(error.response?.data?.errors || "Failed to fetch event details")
|
||
|
|
} finally {
|
||
|
|
setIsLoading(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const fetchVoteStatus = async () => {
|
||
|
|
try {
|
||
|
|
const response = await apiClient.get(`${API_CONFIG.ENDPOINTS.VOTE_EVENTS}/${eventId}/vote-status`)
|
||
|
|
|
||
|
|
if (response.data.success) {
|
||
|
|
setVoteStatus(response.data.data)
|
||
|
|
} else {
|
||
|
|
setError("Failed to fetch voting status")
|
||
|
|
}
|
||
|
|
} catch (error: any) {
|
||
|
|
console.error('Error fetching vote status:', error)
|
||
|
|
setError(error.response?.data?.errors || "Failed to fetch voting status")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleVote = async () => {
|
||
|
|
if (!selectedCandidate || !event) return
|
||
|
|
|
||
|
|
try {
|
||
|
|
setIsVoting(true)
|
||
|
|
setError(null)
|
||
|
|
setSuccess(null)
|
||
|
|
|
||
|
|
const voteData: VoteRequest = {
|
||
|
|
vote_event_id: event.id,
|
||
|
|
candidate_id: selectedCandidate
|
||
|
|
}
|
||
|
|
|
||
|
|
const response = await apiClient.post(API_CONFIG.ENDPOINTS.VOTES, voteData)
|
||
|
|
|
||
|
|
if (response.data.success) {
|
||
|
|
setSuccess("Your vote has been recorded successfully!")
|
||
|
|
setVoteStatus({ has_voted: true })
|
||
|
|
// Refresh vote status
|
||
|
|
setTimeout(() => {
|
||
|
|
fetchVoteStatus()
|
||
|
|
}, 1000)
|
||
|
|
} else {
|
||
|
|
setError(response.data.errors || "Failed to submit vote")
|
||
|
|
}
|
||
|
|
} catch (error: any) {
|
||
|
|
console.error('Error submitting vote:', error)
|
||
|
|
setError(error.response?.data?.errors || "Failed to submit vote. Please try again.")
|
||
|
|
} finally {
|
||
|
|
setIsVoting(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleCandidateSelect = (candidateId: string) => {
|
||
|
|
setSelectedCandidate(candidateId)
|
||
|
|
setError(null)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Show loading while checking authentication
|
||
|
|
if (loading) {
|
||
|
|
return (
|
||
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||
|
|
<div className="text-center">
|
||
|
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||
|
|
<p className="text-gray-600">Loading...</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Redirect to login if not authenticated
|
||
|
|
if (!isAuthenticated) {
|
||
|
|
router.push("/login")
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
|
||
|
|
// Redirect to home if no event ID
|
||
|
|
if (!eventId) {
|
||
|
|
router.push("/")
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if event is still active and voting is open
|
||
|
|
const isEventActive = event && new Date() >= new Date(event.start_date) && new Date() <= new Date(event.end_date)
|
||
|
|
const canVote = event && event.is_voting_open && isEventActive && !voteStatus?.has_voted
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50">
|
||
|
|
{/* Header */}
|
||
|
|
<header className="bg-white shadow-sm border-b">
|
||
|
|
<div className="container mx-auto px-4 py-4">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="flex items-center gap-4">
|
||
|
|
<Link href="/">
|
||
|
|
<Button variant="ghost" size="sm" className="gap-2">
|
||
|
|
<ArrowLeft className="h-4 w-4" />
|
||
|
|
Back to Home
|
||
|
|
</Button>
|
||
|
|
</Link>
|
||
|
|
<div className="h-6 w-px bg-gray-300"></div>
|
||
|
|
<h1 className="text-xl font-semibold text-gray-900">Voting Interface</h1>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||
|
|
<User className="h-4 w-4" />
|
||
|
|
<span>{user?.name}</span>
|
||
|
|
</div>
|
||
|
|
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
|
||
|
|
<Shield className="h-3 w-3 mr-1" />
|
||
|
|
Authenticated
|
||
|
|
</Badge>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<div className="container mx-auto px-4 py-8">
|
||
|
|
{isLoading ? (
|
||
|
|
<div className="text-center py-16">
|
||
|
|
<div className="relative">
|
||
|
|
<div className="animate-spin rounded-full h-16 w-16 border-4 border-blue-200 border-t-blue-600 mx-auto mb-6"></div>
|
||
|
|
</div>
|
||
|
|
<p className="text-gray-600 text-lg">Loading event details...</p>
|
||
|
|
</div>
|
||
|
|
) : error ? (
|
||
|
|
<div className="max-w-2xl mx-auto">
|
||
|
|
<Alert className="border-red-200 bg-red-50">
|
||
|
|
<AlertCircle className="h-4 w-4 text-red-600" />
|
||
|
|
<AlertDescription className="text-red-800">
|
||
|
|
{error}
|
||
|
|
</AlertDescription>
|
||
|
|
</Alert>
|
||
|
|
<div className="mt-4 text-center">
|
||
|
|
<Button onClick={() => window.location.reload()} variant="outline">
|
||
|
|
Try Again
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
) : event ? (
|
||
|
|
<div className="max-w-4xl mx-auto space-y-8">
|
||
|
|
{/* Event Header */}
|
||
|
|
<Card className="bg-white shadow-lg border-0">
|
||
|
|
<CardHeader className="text-center pb-6">
|
||
|
|
<div className="flex items-center justify-center gap-2 mb-4">
|
||
|
|
{event.is_voting_open && isEventActive ? (
|
||
|
|
<Badge className="bg-green-100 text-green-800 border-green-200">
|
||
|
|
<Timer className="h-3 w-3 mr-1" />
|
||
|
|
Voting Open
|
||
|
|
</Badge>
|
||
|
|
) : !isEventActive ? (
|
||
|
|
<Badge className="bg-gray-100 text-gray-800 border-gray-200">
|
||
|
|
<Clock className="h-3 w-3 mr-1" />
|
||
|
|
Event Ended
|
||
|
|
</Badge>
|
||
|
|
) : (
|
||
|
|
<Badge className="bg-blue-100 text-blue-800 border-blue-200">
|
||
|
|
<Clock className="h-3 w-3 mr-1" />
|
||
|
|
Coming Soon
|
||
|
|
</Badge>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<CardTitle className="text-3xl font-bold text-gray-900 mb-3">
|
||
|
|
{event.title}
|
||
|
|
</CardTitle>
|
||
|
|
<CardDescription className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||
|
|
{event.description}
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="text-center">
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm text-gray-600">
|
||
|
|
<div className="flex items-center justify-center gap-2">
|
||
|
|
<Clock className="h-4 w-4 text-blue-600" />
|
||
|
|
<span>Start: {new Date(event.start_date).toLocaleDateString('id-ID', {
|
||
|
|
weekday: 'long',
|
||
|
|
year: 'numeric',
|
||
|
|
month: 'long',
|
||
|
|
day: 'numeric',
|
||
|
|
hour: '2-digit',
|
||
|
|
minute: '2-digit'
|
||
|
|
})}</span>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center justify-center gap-2">
|
||
|
|
<Clock className="h-4 w-4 text-red-600" />
|
||
|
|
<span>End: {new Date(event.end_date).toLocaleDateString('id-ID', {
|
||
|
|
weekday: 'long',
|
||
|
|
year: 'numeric',
|
||
|
|
month: 'long',
|
||
|
|
day: 'numeric',
|
||
|
|
hour: '2-digit',
|
||
|
|
minute: '2-digit'
|
||
|
|
})}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* Voting Status - Only show if user hasn't voted */}
|
||
|
|
{voteStatus && !voteStatus.has_voted && (
|
||
|
|
<Card className="border-0 shadow-lg bg-blue-50 border-blue-200">
|
||
|
|
<CardContent className="p-6">
|
||
|
|
<div className="flex items-center justify-center gap-3">
|
||
|
|
<Vote className="h-6 w-6 text-blue-600" />
|
||
|
|
<div className="text-center">
|
||
|
|
<h3 className="text-lg font-semibold text-blue-800">Ready to vote</h3>
|
||
|
|
<p className="text-blue-600">Please select your preferred candidate below</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Success Message */}
|
||
|
|
{success && (
|
||
|
|
<Alert className="border-green-200 bg-green-50">
|
||
|
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||
|
|
<AlertDescription className="text-green-800">
|
||
|
|
{success}
|
||
|
|
</AlertDescription>
|
||
|
|
</Alert>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Candidates Section - Only show if user hasn't voted */}
|
||
|
|
{!voteStatus?.has_voted && event.candidates && event.candidates.length > 0 ? (
|
||
|
|
<div className="space-y-6">
|
||
|
|
<div className="text-center">
|
||
|
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Candidates</h2>
|
||
|
|
<p className="text-gray-600">
|
||
|
|
Choose your preferred candidate by clicking on their card
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
|
|
{event.candidates.map((candidate) => (
|
||
|
|
<Card
|
||
|
|
key={candidate.id}
|
||
|
|
className={`cursor-pointer transition-all duration-300 border-2 hover:shadow-lg ${
|
||
|
|
selectedCandidate === candidate.id
|
||
|
|
? 'border-blue-500 bg-blue-50 shadow-lg'
|
||
|
|
: 'border-gray-200 hover:border-blue-300'
|
||
|
|
}`}
|
||
|
|
onClick={() => handleCandidateSelect(candidate.id)}
|
||
|
|
>
|
||
|
|
<CardHeader className="text-center pb-4">
|
||
|
|
<div className="w-20 h-20 mx-auto mb-4 rounded-full bg-gradient-to-br from-blue-100 to-purple-100 flex items-center justify-center">
|
||
|
|
{candidate.image_url ? (
|
||
|
|
<img
|
||
|
|
src={candidate.image_url}
|
||
|
|
alt={candidate.name}
|
||
|
|
className="w-full h-full rounded-full object-cover"
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<ImageIcon className="h-8 w-8 text-gray-400" />
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<CardTitle className="text-xl font-bold text-gray-900">
|
||
|
|
{candidate.name}
|
||
|
|
</CardTitle>
|
||
|
|
{candidate.description && (
|
||
|
|
<CardDescription className="text-gray-600">
|
||
|
|
{candidate.description}
|
||
|
|
</CardDescription>
|
||
|
|
)}
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="text-center">
|
||
|
|
{selectedCandidate === candidate.id && (
|
||
|
|
<div className="mb-4">
|
||
|
|
<Badge className="bg-blue-100 text-blue-800 border-blue-200">
|
||
|
|
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||
|
|
Selected
|
||
|
|
</Badge>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Voting Button */}
|
||
|
|
{canVote && (
|
||
|
|
<div className="text-center pt-6">
|
||
|
|
<Button
|
||
|
|
onClick={handleVote}
|
||
|
|
disabled={!selectedCandidate || isVoting}
|
||
|
|
size="lg"
|
||
|
|
className="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-105 active:scale-95 px-8 py-3 text-lg"
|
||
|
|
>
|
||
|
|
{isVoting ? (
|
||
|
|
<>
|
||
|
|
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||
|
|
Submitting Vote...
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<Vote className="h-5 w-5 mr-2" />
|
||
|
|
Submit Vote
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</Button>
|
||
|
|
{!selectedCandidate && (
|
||
|
|
<p className="text-sm text-gray-500 mt-2">
|
||
|
|
Please select a candidate first
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Cannot Vote Messages */}
|
||
|
|
{!canVote && (
|
||
|
|
<div className="text-center pt-6">
|
||
|
|
{!event.is_voting_open && (
|
||
|
|
<Alert className="border-orange-200 bg-orange-50 max-w-md mx-auto">
|
||
|
|
<AlertCircle className="h-4 w-4 text-orange-600" />
|
||
|
|
<AlertDescription className="text-orange-800">
|
||
|
|
Voting is currently closed for this event
|
||
|
|
</AlertDescription>
|
||
|
|
</Alert>
|
||
|
|
)}
|
||
|
|
{!isEventActive && (
|
||
|
|
<Alert className="border-gray-200 bg-gray-50 max-w-md mx-auto">
|
||
|
|
<Clock className="h-4 w-4 text-gray-600" />
|
||
|
|
<AlertDescription className="text-gray-800">
|
||
|
|
This event has ended
|
||
|
|
</AlertDescription>
|
||
|
|
</Alert>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
) : voteStatus?.has_voted ? (
|
||
|
|
/* Show message when user has already voted */
|
||
|
|
<Card className="bg-green-50 border-green-200 shadow-lg border-0">
|
||
|
|
<CardContent className="text-center py-16">
|
||
|
|
<CheckCircle2 className="h-16 w-16 text-green-600 mx-auto mb-4" />
|
||
|
|
<h3 className="text-xl font-semibold text-green-800 mb-2">Vote Submitted Successfully!</h3>
|
||
|
|
<p className="text-green-600 mb-6">
|
||
|
|
Thank you for participating in this election. Your vote has been recorded.
|
||
|
|
</p>
|
||
|
|
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||
|
|
<Button variant="outline" onClick={() => router.push('/')}>
|
||
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||
|
|
Back to Home
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
) : (
|
||
|
|
/* Show when no candidates available */
|
||
|
|
<Card className="bg-white shadow-lg border-0">
|
||
|
|
<CardContent className="text-center py-16">
|
||
|
|
<User className="h-16 w-16 text-gray-400 mx-auto mb-4" />
|
||
|
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">No Candidates Available</h3>
|
||
|
|
<p className="text-gray-600 mb-6">
|
||
|
|
There are no candidates registered for this voting event yet.
|
||
|
|
</p>
|
||
|
|
<Button variant="outline" onClick={() => router.push('/')}>
|
||
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||
|
|
Back to Home
|
||
|
|
</Button>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="text-center py-16">
|
||
|
|
<AlertCircle className="h-16 w-16 text-gray-400 mx-auto mb-4" />
|
||
|
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">Event Not Found</h3>
|
||
|
|
<p className="text-gray-600 mb-6">
|
||
|
|
The voting event you're looking for could not be found.
|
||
|
|
</p>
|
||
|
|
<Button onClick={() => router.push('/')} variant="outline">
|
||
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||
|
|
Back to Home
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function VotePage() {
|
||
|
|
return (
|
||
|
|
<Suspense fallback={
|
||
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||
|
|
<div className="text-center">
|
||
|
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||
|
|
<p className="text-gray-600">Loading...</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
}>
|
||
|
|
<VotePageContent />
|
||
|
|
</Suspense>
|
||
|
|
)
|
||
|
|
}
|