elmeg-demo/frontend/components/profile/attendance-summary.tsx
fullsizemalt 2e4e0b811d feat: User profile enhancements - chase songs and attendance stats
Backend:
- Add ChaseSong model for tracking songs users want to see
- New /chase router with CRUD for chase songs
- Profile stats endpoint with heady versions, debuts, etc.

Frontend:
- ChaseSongsList component with search, add, remove
- AttendanceSummary with auto-generated stats
- Updated profile page with new Overview tab content
2025-12-21 18:39:39 -08:00

209 lines
8.9 KiB
TypeScript

"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Sparkles, Music, Trophy, Star, Calendar, Target, Eye } from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
import { motion } from "framer-motion"
interface ProfileStats {
shows_attended: number
unique_songs_seen: number
debuts_witnessed: number
heady_versions_attended: number
top_10_performances: number
total_ratings: number
total_reviews: number
chase_songs_count: number
chase_songs_caught: number
most_seen_song: string | null
most_seen_count: number
}
export function AttendanceSummary() {
const [stats, setStats] = useState<ProfileStats | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchStats()
}, [])
const fetchStats = async () => {
const token = localStorage.getItem("token")
if (!token) {
setLoading(false)
return
}
try {
const res = await fetch(`${getApiUrl()}/chase/profile/stats`, {
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
const data = await res.json()
setStats(data)
}
} catch (err) {
console.error("Failed to fetch profile stats", err)
} finally {
setLoading(false)
}
}
if (loading) {
return <div className="text-muted-foreground">Loading stats...</div>
}
if (!stats || stats.shows_attended === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-yellow-500" />
Attendance Summary
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-center py-4">
No shows marked as attended yet. Start adding shows to see your stats!
</p>
</CardContent>
</Card>
)
}
// Build the summary sentence
const highlights: string[] = []
if (stats.heady_versions_attended > 0) {
highlights.push(`${stats.heady_versions_attended} heady version${stats.heady_versions_attended !== 1 ? 's' : ''}`)
}
if (stats.top_10_performances > 0) {
highlights.push(`${stats.top_10_performances} top-rated performance${stats.top_10_performances !== 1 ? 's' : ''}`)
}
if (stats.debuts_witnessed > 0) {
highlights.push(`${stats.debuts_witnessed} debut${stats.debuts_witnessed !== 1 ? 's' : ''}`)
}
return (
<Card className="overflow-hidden">
<CardHeader className="bg-gradient-to-br from-primary/10 via-primary/5 to-transparent">
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-yellow-500" />
Your Attendance Story
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-6">
{/* Main Summary */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="text-lg leading-relaxed"
>
<p>
You've attended <strong className="text-primary">{stats.shows_attended} shows</strong> and
seen <strong className="text-primary">{stats.unique_songs_seen} unique songs</strong>.
</p>
{highlights.length > 0 && (
<p className="mt-2 text-muted-foreground">
In attendance for {highlights.join(", ")}.
</p>
)}
{stats.most_seen_song && (
<p className="mt-2">
Your most-seen song is <strong className="text-primary">{stats.most_seen_song}</strong> ({stats.most_seen_count} times).
</p>
)}
</motion.div>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 pt-4">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.1 }}
className="text-center p-4 rounded-lg bg-muted/50"
>
<Calendar className="h-6 w-6 mx-auto mb-2 text-primary" />
<div className="text-2xl font-bold">{stats.shows_attended}</div>
<div className="text-xs text-muted-foreground uppercase tracking-wider">Shows</div>
</motion.div>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 }}
className="text-center p-4 rounded-lg bg-muted/50"
>
<Music className="h-6 w-6 mx-auto mb-2 text-green-500" />
<div className="text-2xl font-bold">{stats.unique_songs_seen}</div>
<div className="text-xs text-muted-foreground uppercase tracking-wider">Songs Seen</div>
</motion.div>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 }}
className="text-center p-4 rounded-lg bg-yellow-500/10"
>
<Trophy className="h-6 w-6 mx-auto mb-2 text-yellow-500" />
<div className="text-2xl font-bold">{stats.heady_versions_attended}</div>
<div className="text-xs text-muted-foreground uppercase tracking-wider">Heady Versions</div>
</motion.div>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.4 }}
className="text-center p-4 rounded-lg bg-purple-500/10"
>
<Star className="h-6 w-6 mx-auto mb-2 text-purple-500" />
<div className="text-2xl font-bold">{stats.debuts_witnessed}</div>
<div className="text-xs text-muted-foreground uppercase tracking-wider">Debuts</div>
</motion.div>
</div>
{/* Chase Songs Progress */}
{stats.chase_songs_count > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="p-4 rounded-lg border bg-muted/30"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 font-medium">
<Target className="h-4 w-4 text-primary" />
Chase Progress
</div>
<span className="text-sm font-mono">
{stats.chase_songs_caught}/{stats.chase_songs_count}
</span>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${(stats.chase_songs_caught / stats.chase_songs_count) * 100}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-2">
{stats.chase_songs_count - stats.chase_songs_caught} songs left to catch!
</p>
</motion.div>
)}
{/* Activity Stats */}
<div className="flex items-center justify-center gap-8 pt-4 border-t text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Star className="h-4 w-4" />
<span>{stats.total_ratings} ratings</span>
</div>
<div className="flex items-center gap-2">
<Eye className="h-4 w-4" />
<span>{stats.total_reviews} reviews</span>
</div>
</div>
</CardContent>
</Card>
)
}