update UI result
This commit is contained in:
parent
c167df391f
commit
bc1900d3f7
1127
app/globals.css
1127
app/globals.css
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { BarChart3, Users, Trophy, TrendingUp, ArrowLeft, RotateCcw, Download, Eye, Maximize2, Minimize2, Table } from "lucide-react"
|
||||
import { BarChart3, Users, Trophy, TrendingUp, ArrowLeft, RotateCcw, Download, Eye, Maximize2, Minimize2, Table, Clock, RefreshCw } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
import { AuthGuard } from "@/components/auth-guard"
|
||||
@ -24,6 +24,14 @@ interface VoteEvent {
|
||||
end_date: string
|
||||
is_active: boolean
|
||||
is_voting_open: boolean
|
||||
results_open: boolean
|
||||
}
|
||||
|
||||
interface VoteEventDetails {
|
||||
vote_event: VoteEvent
|
||||
total_participants: number
|
||||
total_voted: number
|
||||
total_not_voted: number
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
@ -56,12 +64,16 @@ function ResultsPageContent() {
|
||||
const [events, setEvents] = useState<VoteEvent[]>([])
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>("")
|
||||
const [results, setResults] = useState<VoteResults | null>(null)
|
||||
const [eventDetails, setEventDetails] = useState<VoteEventDetails | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [eventsLoading, setEventsLoading] = useState(true)
|
||||
const [isFullPageChart, setIsFullPageChart] = useState(false)
|
||||
const [showCountdown, setShowCountdown] = useState(false)
|
||||
const [countdown, setCountdown] = useState(10)
|
||||
const [showResults, setShowResults] = useState(false)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [lastUpdated, setLastUpdated] = useState<Date>(new Date())
|
||||
|
||||
// Chart colors
|
||||
const COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#06B6D4', '#84CC16', '#F97316']
|
||||
@ -74,7 +86,17 @@ function ResultsPageContent() {
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEventId) {
|
||||
fetchResults(selectedEventId)
|
||||
fetchEventDetails(selectedEventId, false)
|
||||
fetchResults(selectedEventId, false)
|
||||
|
||||
// Set up interval for live updates every 10 seconds
|
||||
const interval = setInterval(() => {
|
||||
fetchEventDetails(selectedEventId, true)
|
||||
fetchResults(selectedEventId, true)
|
||||
}, 10000) // 10 seconds
|
||||
|
||||
// Cleanup interval on unmount or when selectedEventId changes
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [selectedEventId])
|
||||
|
||||
@ -85,12 +107,34 @@ function ResultsPageContent() {
|
||||
setCountdown(prev => prev - 1)
|
||||
}, 1000)
|
||||
} else if (showCountdown && countdown === 0) {
|
||||
// Simply transition to results - fullscreen container remains the same
|
||||
setShowCountdown(false)
|
||||
setShowResults(true)
|
||||
}
|
||||
return () => clearInterval(interval)
|
||||
}, [showCountdown, countdown])
|
||||
|
||||
// Fullscreen change listener
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
const fullscreenElement = document.fullscreenElement
|
||||
setIsFullscreen(!!fullscreenElement)
|
||||
|
||||
// Add/remove fullscreen styles to the event overview card
|
||||
const eventOverviewCard = document.querySelector('[data-fullscreen-target="event-overview"]')
|
||||
if (eventOverviewCard) {
|
||||
if (fullscreenElement === eventOverviewCard) {
|
||||
eventOverviewCard.classList.add('fullscreen-active')
|
||||
} else {
|
||||
eventOverviewCard.classList.remove('fullscreen-active')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
}, [])
|
||||
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
setEventsLoading(true)
|
||||
@ -115,28 +159,61 @@ function ResultsPageContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchResults = async (eventId: string) => {
|
||||
const fetchEventDetails = async (eventId: string, showRefreshIndicator = false) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
if (showRefreshIndicator) setIsRefreshing(true)
|
||||
const response = await apiClient.get(`/api/v1/vote-events/${eventId}/details`)
|
||||
if (response.data.success) {
|
||||
setEventDetails(response.data.data)
|
||||
setLastUpdated(new Date())
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching event details:', error)
|
||||
// Only show toast on initial load, not on refresh
|
||||
if (!showRefreshIndicator) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to fetch event details",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
if (showRefreshIndicator) {
|
||||
setTimeout(() => setIsRefreshing(false), 500) // Show indicator for at least 500ms
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchResults = async (eventId: string, showRefreshIndicator = false) => {
|
||||
try {
|
||||
if (!showRefreshIndicator) setLoading(true)
|
||||
if (showRefreshIndicator) setIsRefreshing(true)
|
||||
const response = await apiClient.get(`${API_CONFIG.ENDPOINTS.RESULTS}/${eventId}/results`)
|
||||
if (response.data.success) {
|
||||
setResults(response.data.data)
|
||||
setLastUpdated(new Date())
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching results:', error)
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to fetch voting results",
|
||||
variant: "destructive"
|
||||
})
|
||||
// Only show toast on initial load, not on refresh
|
||||
if (!showRefreshIndicator) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to fetch voting results",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (!showRefreshIndicator) setLoading(false)
|
||||
if (showRefreshIndicator) {
|
||||
setTimeout(() => setIsRefreshing(false), 500) // Show indicator for at least 500ms
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getChartData = (): (ChartData & { image_url: string; id: string })[] => {
|
||||
if (!results) return []
|
||||
|
||||
|
||||
return results.candidates
|
||||
.sort((a, b) => b.vote_count - a.vote_count) // Sort by vote count descending
|
||||
.map((candidate, index) => ({
|
||||
@ -151,7 +228,7 @@ function ResultsPageContent() {
|
||||
|
||||
const getWinner = (): Candidate | null => {
|
||||
if (!results || results.candidates.length === 0) return null
|
||||
return results.candidates.reduce((prev, current) =>
|
||||
return results.candidates.reduce((prev, current) =>
|
||||
prev.vote_count > current.vote_count ? prev : current
|
||||
)
|
||||
}
|
||||
@ -168,11 +245,11 @@ function ResultsPageContent() {
|
||||
|
||||
const getStatusBadge = (event: VoteEvent) => {
|
||||
const status = getEventStatus(event)
|
||||
|
||||
|
||||
if (event.is_voting_open && status === "active") {
|
||||
return <Badge className="bg-green-500 text-white">Live Voting</Badge>
|
||||
}
|
||||
|
||||
|
||||
switch (status) {
|
||||
case "active":
|
||||
return <Badge className="bg-orange-500 text-white">Active</Badge>
|
||||
@ -194,9 +271,41 @@ function ResultsPageContent() {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we're in fullscreen mode from the reveal button
|
||||
const isInFullscreen = document.fullscreenElement !== null
|
||||
|
||||
setShowCountdown(true)
|
||||
setCountdown(10)
|
||||
setShowResults(false)
|
||||
|
||||
// If we were in fullscreen from reveal, maintain it by making the container fullscreen
|
||||
if (isInFullscreen) {
|
||||
setTimeout(() => {
|
||||
const container = document.querySelector('[data-fullscreen-target="results-container"]')
|
||||
if (container && !document.fullscreenElement) {
|
||||
container.requestFullscreen().catch(err => {
|
||||
console.error('Error maintaining fullscreen:', err)
|
||||
})
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
// Find the event overview card and make it fullscreen
|
||||
const eventOverviewCard = document.querySelector('[data-fullscreen-target="event-overview"]')
|
||||
if (eventOverviewCard) {
|
||||
eventOverviewCard.requestFullscreen().catch(err => {
|
||||
console.error('Error attempting to enable fullscreen:', err)
|
||||
})
|
||||
setIsFullscreen(true)
|
||||
}
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
setIsFullscreen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedEvent = events.find(e => e.id === selectedEventId)
|
||||
@ -214,13 +323,25 @@ function ResultsPageContent() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Voting Results</h1>
|
||||
<p className="text-gray-600 mt-1">View detailed voting results and statistics</p>
|
||||
{selectedEventId && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className={`flex items-center gap-1 text-sm ${isRefreshing ? 'text-blue-600' : 'text-gray-500'}`}>
|
||||
<RefreshCw className={`h-3 w-3 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
<span>{isRefreshing ? 'Updating...' : 'Live updates every 10s'}</span>
|
||||
</div>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{results && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setIsFullPageChart(!isFullPageChart)}
|
||||
variant={isFullPageChart ? "default" : "outline"}
|
||||
<Button
|
||||
onClick={() => setIsFullPageChart(!isFullPageChart)}
|
||||
variant={isFullPageChart ? "default" : "outline"}
|
||||
className="gap-2"
|
||||
>
|
||||
{isFullPageChart ? (
|
||||
@ -237,6 +358,23 @@ function ResultsPageContent() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
onClick={toggleFullscreen}
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<>
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
Exit Fullscreen
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
Fullscreen Overview
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={fetchEvents} variant="outline" className="gap-2">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Refresh
|
||||
@ -280,7 +418,7 @@ function ResultsPageContent() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
|
||||
{selectedEvent && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-600">
|
||||
@ -303,193 +441,396 @@ function ResultsPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading Event Details */}
|
||||
{!loading && selectedEventId && !eventDetails && (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Loading event details...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Content */}
|
||||
{!loading && selectedEventId && results && (
|
||||
<>
|
||||
{/* Show Results Button */}
|
||||
{/* Event Statistics */}
|
||||
{!showCountdown && !showResults && (
|
||||
<Card>
|
||||
<CardContent className="text-center py-16">
|
||||
<Trophy className="h-16 w-16 text-blue-500 mx-auto mb-6" />
|
||||
<h2 className="text-2xl font-bold mb-4">Ready to Reveal Results?</h2>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Click the button below to see the voting results for "{selectedEvent?.title}"
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleShowResults}
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white px-8 py-3 text-lg"
|
||||
>
|
||||
<BarChart3 className="mr-2 h-5 w-5" />
|
||||
Show Results
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<>
|
||||
{eventDetails ? (
|
||||
<div className="space-y-6 mb-8 animate-fade-in-up">
|
||||
{/* Event Overview */}
|
||||
<Card data-fullscreen-target="event-overview" className="relative">
|
||||
<CardHeader className="card-header">
|
||||
<div className="flex items-center justify-center gap-3 mb-2">
|
||||
{isRefreshing && (
|
||||
<Badge className="bg-blue-500 text-white animate-pulse">
|
||||
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
|
||||
Updating
|
||||
</Badge>
|
||||
)}
|
||||
<Badge className="bg-green-500 text-white">
|
||||
<div className="w-2 h-2 bg-white rounded-full animate-pulse mr-2"></div>
|
||||
Live
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-4xl font-bold text-center">
|
||||
{eventDetails.vote_event.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="card-content">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="text-center p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<Users className="h-8 w-8 text-blue-600 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{eventDetails.total_participants}
|
||||
</div>
|
||||
<p className="text-sm text-blue-700 font-medium">Total Participants</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<TrendingUp className="h-8 w-8 text-green-600 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{eventDetails.total_voted}
|
||||
</div>
|
||||
<p className="text-sm text-green-700 font-medium">Total Voted</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-orange-50 rounded-lg border border-orange-200">
|
||||
<Clock className="h-8 w-8 text-orange-600 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{eventDetails.total_not_voted}
|
||||
</div>
|
||||
<p className="text-sm text-orange-700 font-medium">Not Voted</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Participation Rate */}
|
||||
<div className="mt-6 p-4 bg-gradient-to-r from-purple-50 to-blue-50 rounded-lg border border-purple-200">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-purple-600 mb-2">
|
||||
{eventDetails.total_participants > 0
|
||||
? ((eventDetails.total_voted / eventDetails.total_participants) * 100).toFixed(1)
|
||||
: '0'
|
||||
}%
|
||||
</div>
|
||||
<p className="text-gray-700 font-medium">Participation Rate</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{eventDetails.total_voted} out of {eventDetails.total_participants} participants have voted
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-8">
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<BarChart3 className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600">Event details not available</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show Results Button */}
|
||||
<Card data-fullscreen-target="reveal-results" className="relative">
|
||||
<CardContent className="text-center py-16 reveal-content">
|
||||
<Button
|
||||
onClick={() => {
|
||||
const revealCard = document.querySelector('[data-fullscreen-target="reveal-results"]')
|
||||
if (revealCard && !document.fullscreenElement) {
|
||||
revealCard.requestFullscreen().catch(err => {
|
||||
console.error('Error attempting to enable fullscreen:', err)
|
||||
})
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute top-4 right-4 gap-2 fullscreen-toggle-btn"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
Fullscreen
|
||||
</Button>
|
||||
|
||||
{/* Exit fullscreen button (hidden by default, shown in fullscreen) */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hidden exit-fullscreen-btn"
|
||||
>
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
Exit Fullscreen
|
||||
</Button>
|
||||
|
||||
{/* Event Details Section (hidden by default, shown in fullscreen) */}
|
||||
{eventDetails && (
|
||||
<div className="event-details-fullscreen hidden">
|
||||
<div className="event-stats-grid">
|
||||
<div className="stat-card participants-card">
|
||||
<Users className="stat-icon" />
|
||||
<div className="stat-value">{eventDetails.total_participants}</div>
|
||||
<p className="stat-label">Total Participants</p>
|
||||
</div>
|
||||
|
||||
<div className="stat-card voted-card">
|
||||
<TrendingUp className="stat-icon" />
|
||||
<div className="stat-value">{eventDetails.total_voted}</div>
|
||||
<p className="stat-label">Total Voted</p>
|
||||
</div>
|
||||
|
||||
<div className="stat-card not-voted-card">
|
||||
<Clock className="stat-icon" />
|
||||
<div className="stat-value">{eventDetails.total_not_voted}</div>
|
||||
<p className="stat-label">Not Voted</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="participation-rate-card">
|
||||
<div className="rate-value">
|
||||
{eventDetails.total_participants > 0
|
||||
? ((eventDetails.total_voted / eventDetails.total_participants) * 100).toFixed(1)
|
||||
: '0'
|
||||
}%
|
||||
</div>
|
||||
<p className="rate-label">Participation Rate</p>
|
||||
<p className="rate-description">
|
||||
{eventDetails.total_voted} out of {eventDetails.total_participants} participants have voted
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Trophy className="h-16 w-16 text-blue-500 mx-auto mb-6 trophy-icon" />
|
||||
<h2 className="text-2xl font-bold mb-4 reveal-title">Ready to Reveal Results?</h2>
|
||||
<p className="text-gray-600 mb-8 reveal-description">
|
||||
Click the button below to see the voting results for "{selectedEvent?.title}"
|
||||
</p>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleShowResults()
|
||||
}}
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white px-8 py-3 text-lg reveal-button"
|
||||
>
|
||||
<BarChart3 className="mr-2 h-5 w-5" />
|
||||
Show Results
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
|
||||
)}
|
||||
|
||||
{/* Countdown Animation */}
|
||||
{showCountdown && (
|
||||
<Card>
|
||||
<CardContent className="text-center py-24">
|
||||
<div className="relative">
|
||||
<div className={`text-8xl font-bold mb-4 transition-all duration-1000 ${
|
||||
{/* Results Container - Wraps both countdown and results for seamless fullscreen */}
|
||||
<div data-fullscreen-target="results-container" className="results-fullscreen-container">
|
||||
{/* Countdown Animation */}
|
||||
{showCountdown && (
|
||||
<Card className="relative countdown-card">
|
||||
<CardContent className="text-center py-24 countdown-content">
|
||||
<Button
|
||||
onClick={() => {
|
||||
const container = document.querySelector('[data-fullscreen-target="results-container"]')
|
||||
if (container && !document.fullscreenElement) {
|
||||
container.requestFullscreen().catch(err => {
|
||||
console.error('Error attempting to enable fullscreen:', err)
|
||||
})
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute top-4 right-4 gap-2 fullscreen-toggle-btn"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
Fullscreen
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hidden exit-fullscreen-btn"
|
||||
>
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
Exit Fullscreen
|
||||
</Button>
|
||||
|
||||
<div className="relative countdown-wrapper">
|
||||
<div className={`text-8xl font-bold mb-4 transition-all duration-1000 countdown-number ${
|
||||
countdown <= 3 ? 'text-red-500 scale-125' : 'text-blue-500 scale-100'
|
||||
}`}>
|
||||
{countdown}
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className={`w-32 h-32 border-4 border-blue-200 rounded-full animate-pulse ${
|
||||
<div className={`w-32 h-32 border-4 border-blue-200 rounded-full animate-pulse countdown-circle ${
|
||||
countdown <= 3 ? 'border-red-200' : ''
|
||||
}`}></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xl text-gray-600 animate-bounce">
|
||||
{countdown > 5 ? 'Preparing results...' :
|
||||
<p className="text-xl text-gray-600 animate-bounce countdown-text">
|
||||
{countdown > 5 ? 'Preparing results...' :
|
||||
countdown > 3 ? 'Almost ready...' : 'Here we go!'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Animated Results Display */}
|
||||
{showResults && (
|
||||
<div className="space-y-8">
|
||||
{/* Winner Announcement */}
|
||||
<Card className="animate-fade-in-up">
|
||||
<CardContent className="text-center py-12">
|
||||
<div className="mb-6">
|
||||
<Trophy className="h-16 w-16 text-yellow-500 mx-auto animate-bounce" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold mb-2 text-gray-900">
|
||||
🎉 Winner: {winner?.name || "No votes yet"} 🎉
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600">
|
||||
{winner ? `${winner.vote_count} votes (${((winner.vote_count / results.total_votes) * 100).toFixed(1)}%)` : "Waiting for votes"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Animated Results Display */}
|
||||
{showResults && (
|
||||
<div className="space-y-8 results-display">
|
||||
{/* Fullscreen Results Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
const container = document.querySelector('[data-fullscreen-target="results-container"]')
|
||||
if (container && !document.fullscreenElement) {
|
||||
container.requestFullscreen().catch(err => {
|
||||
console.error('Error attempting to enable fullscreen:', err)
|
||||
})
|
||||
} else if (document.fullscreenElement) {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
className="gap-2 fullscreen-results-btn"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4 maximize-icon" />
|
||||
<Minimize2 className="h-4 w-4 minimize-icon hidden" />
|
||||
<span className="btn-text">Fullscreen Results</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/* Animated Bar Chart with Profile Images */}
|
||||
<Card className="animate-fade-in-up" style={{ animationDelay: '0.3s' }}>
|
||||
<Card className="animate-fade-in-up results-card" style={{ animationDelay: '0.3s' }}>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Final Results</CardTitle>
|
||||
<CardDescription>Vote distribution by candidate</CardDescription>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
{isRefreshing && (
|
||||
<Badge className="bg-blue-500 text-white animate-pulse">
|
||||
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
|
||||
Updating
|
||||
</Badge>
|
||||
)}
|
||||
<Badge className="bg-green-500 text-white">
|
||||
<div className="w-2 h-2 bg-white rounded-full animate-pulse mr-2"></div>
|
||||
Live Results
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-2xl results-title">Final Results</CardTitle>
|
||||
<CardDescription className="results-subtitle">
|
||||
Vote distribution by candidate • Updated: {lastUpdated.toLocaleTimeString()}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
{chartData.map((candidate, index) => {
|
||||
const maxVotes = Math.max(...chartData.map(c => c.votes))
|
||||
const barWidth = candidate.votes > 0 ? (candidate.votes / maxVotes) * 100 : 0
|
||||
|
||||
return (
|
||||
<div key={candidate.id} className="relative">
|
||||
{/* Candidate Info Row */}
|
||||
<div className="flex items-center gap-4 mb-3 p-3 bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow">
|
||||
{/* Profile Image */}
|
||||
<div className="relative w-20 h-20 flex-shrink-0 profile-image-container">
|
||||
{candidate.image_url ? (
|
||||
<Image
|
||||
src={candidate.image_url}
|
||||
alt={candidate.name}
|
||||
fill
|
||||
className="object-cover rounded-full border-4 shadow-lg transition-transform"
|
||||
style={{ borderColor: candidate.fill }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full bg-gray-200 rounded-full flex items-center justify-center border-4 shadow-lg transition-transform"
|
||||
style={{ borderColor: candidate.fill }}
|
||||
>
|
||||
<Users className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
{/* Rank Badge */}
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<div className={`px-3 py-1 rounded-full text-white font-bold text-sm shadow-lg ${
|
||||
index === 0 ? 'bg-gradient-to-r from-yellow-400 to-yellow-600' :
|
||||
index === 1 ? 'bg-gradient-to-r from-gray-400 to-gray-600' :
|
||||
index === 2 ? 'bg-gradient-to-r from-orange-400 to-orange-600' :
|
||||
'bg-gradient-to-r from-blue-400 to-blue-600'
|
||||
}`}>
|
||||
#{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
{/* Winner Crown */}
|
||||
{index === 0 && candidate.votes > 0 && (
|
||||
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2">
|
||||
<Trophy className="h-6 w-6 text-yellow-500 animate-bounce" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Candidate Name and Info */}
|
||||
<div className="flex-grow">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-xl font-bold text-gray-900">{candidate.name}</h3>
|
||||
{index < 3 && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-gradient-to-r from-indigo-500 to-purple-600 text-white font-medium">
|
||||
{index === 0 ? '🏆 Winner' : index === 1 ? '🥈 2nd' : '🥉 3rd'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 font-medium">{candidate.votes} votes</p>
|
||||
</div>
|
||||
|
||||
{/* Percentage Display */}
|
||||
<div className="text-right">
|
||||
<div
|
||||
className="text-3xl font-bold mb-1"
|
||||
style={{ color: candidate.fill }}
|
||||
>
|
||||
{candidate.percentage.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 font-medium">
|
||||
of {results.total_votes} votes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Animated Bar */}
|
||||
<div className="relative bg-gradient-to-r from-gray-200 to-gray-300 rounded-full h-12 overflow-hidden shadow-inner mb-4">
|
||||
<div className="vertical-chart-container">
|
||||
{/* Vertical Bar Chart */}
|
||||
<div className="flex items-end justify-center gap-8 mb-8" style={{ height: '400px' }}>
|
||||
{chartData.map((candidate, index) => {
|
||||
const maxVotes = Math.max(...chartData.map(c => c.votes))
|
||||
const barHeight = candidate.votes > 0 ? (candidate.votes / maxVotes) * 100 : 0
|
||||
|
||||
return (
|
||||
<div key={candidate.id} className="flex flex-col items-center flex-1 h-full justify-end">
|
||||
{/* Percentage above bar */}
|
||||
<div
|
||||
className="h-full rounded-full flex items-center justify-between px-4 shadow-lg relative overflow-hidden"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${candidate.fill}, ${candidate.fill}dd)`,
|
||||
width: `${barWidth}%`,
|
||||
transition: 'width 2s ease-out',
|
||||
transitionDelay: `${index * 300}ms`
|
||||
}}
|
||||
className="text-2xl font-bold mb-2"
|
||||
style={{ color: candidate.fill }}
|
||||
>
|
||||
{/* Shimmer Effect */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-pulse"></div>
|
||||
|
||||
{/* Vote count inside bar */}
|
||||
<div className="relative z-10 flex items-center justify-between w-full">
|
||||
{barWidth > 20 && (
|
||||
<span className="text-white font-bold text-sm">
|
||||
{candidate.name}
|
||||
</span>
|
||||
)}
|
||||
{barWidth > 10 && (
|
||||
<span className="text-white font-bold text-lg">
|
||||
{candidate.percentage.toFixed(1)}%
|
||||
</div>
|
||||
|
||||
{/* Vertical Bar Container */}
|
||||
<div className="relative w-full max-w-[120px] h-full flex items-end">
|
||||
{/* Background bar */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-gray-200 to-gray-100 rounded-t-2xl"></div>
|
||||
|
||||
{/* Animated Bar */}
|
||||
<div
|
||||
className="relative w-full rounded-t-2xl shadow-lg overflow-hidden vertical-bar"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${candidate.fill}, ${candidate.fill}dd)`,
|
||||
height: `${barHeight}%`,
|
||||
transition: 'height 2s ease-out',
|
||||
transitionDelay: `${index * 300}ms`,
|
||||
minHeight: candidate.votes > 0 ? '40px' : '0'
|
||||
}}
|
||||
>
|
||||
{/* Shimmer Effect */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-transparent via-white/20 to-transparent animate-pulse"></div>
|
||||
|
||||
{/* Vote count inside bar */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-white font-bold text-lg transform rotate-0">
|
||||
{candidate.votes}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Percentage label outside bar for small bars */}
|
||||
{barWidth < 20 && candidate.votes > 0 && (
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-700 font-bold text-sm">
|
||||
{candidate.votes}
|
||||
|
||||
{/* Candidate Info Below Bar */}
|
||||
<div className="mt-4 text-center">
|
||||
{/* Profile Image */}
|
||||
<div className="relative w-20 h-20 mx-auto mb-2 profile-image-container">
|
||||
{candidate.image_url ? (
|
||||
<Image
|
||||
src={candidate.image_url}
|
||||
alt={candidate.name}
|
||||
fill
|
||||
className="object-cover rounded-full border-4 shadow-lg"
|
||||
style={{ borderColor: candidate.fill }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full bg-gray-200 rounded-full flex items-center justify-center border-4 shadow-lg"
|
||||
style={{ borderColor: candidate.fill }}
|
||||
>
|
||||
<Users className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rank Badge */}
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<div className={`px-2 py-1 rounded-full text-white font-bold text-xs shadow-lg ${
|
||||
index === 0 ? 'bg-gradient-to-r from-yellow-400 to-yellow-600' :
|
||||
index === 1 ? 'bg-gradient-to-r from-gray-400 to-gray-600' :
|
||||
index === 2 ? 'bg-gradient-to-r from-orange-400 to-orange-600' :
|
||||
'bg-gradient-to-r from-blue-400 to-blue-600'
|
||||
}`}>
|
||||
#{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Winner Crown */}
|
||||
{index === 0 && candidate.votes > 0 && (
|
||||
<div className="absolute -top-6 left-1/2 transform -translate-x-1/2">
|
||||
<Trophy className="h-6 w-6 text-yellow-500 animate-bounce" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Candidate Name */}
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-1">{candidate.name}</h3>
|
||||
|
||||
{/* Winner/Place Badge */}
|
||||
{index < 3 && candidate.votes > 0 && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-gradient-to-r from-indigo-500 to-purple-600 text-white font-medium">
|
||||
{index === 0 ? '🏆 Winner' : index === 1 ? '🥈 2nd' : '🥉 3rd'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Total Votes Summary */}
|
||||
<div className="mt-8 p-4 bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg border-2 border-blue-200">
|
||||
<div className="text-center">
|
||||
@ -510,55 +851,9 @@ function ResultsPageContent() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid md:grid-cols-3 gap-6 animate-fade-in-up" style={{ animationDelay: '0.6s' }}>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Votes</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-blue-600">{results.total_votes}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Cast across all candidates
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Candidates</CardTitle>
|
||||
<Trophy className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-green-600">{results.candidates.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Participated in voting
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Winning Margin</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-purple-600">
|
||||
{winner && results.candidates.length > 1 ?
|
||||
`${Math.max(0, winner.vote_count - Math.max(...results.candidates.filter(c => c.id !== winner.id).map(c => c.vote_count)))}` :
|
||||
'0'
|
||||
}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Vote difference
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -603,4 +898,4 @@ export default function ResultsPage() {
|
||||
</Suspense>
|
||||
</AuthGuard>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user