Add dockre file
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user