PHASE 4: Frontend Web App (Next.js)
4.1 Supabase Client Setup
client/src/lib/supabase.js
// ====================================
// Supabase Client for Frontend
// ====================================
import { createClient } from '@supabase/supabase-js' ;
const supabaseUrl = process . env . NEXT_PUBLIC_SUPABASE_URL ;
const supabaseAnonKey = process . env . NEXT_PUBLIC_SUPABASE_ANON_KEY ;
export const supabase = createClient ( supabaseUrl , supabaseAnonKey ) ;
/**
* Get the current session
*/
export const getSession = async ( ) => {
const { data : { session } } = await supabase . auth . getSession ( ) ;
return session ;
} ;
/**
* Get auth token for API calls
*/
export const getAuthToken = async ( ) => {
const session = await getSession ( ) ;
return session ?. access_token || null ;
} ;
// ====================================
// API Client — Axios wrapper
// ====================================
import axios from 'axios' ;
import { getAuthToken } from './supabase' ;
const API_BASE = process . env . NEXT_PUBLIC_API_URL || 'http://localhost:5000/api' ;
const api = axios . create ( {
baseURL : API_BASE ,
timeout : 15000 ,
headers : {
'Content-Type' : 'application/json'
}
} ) ;
// Attach auth token to every request
api . interceptors . request . use ( async ( config ) => {
const token = await getAuthToken ( ) ;
if ( token ) {
config . headers . Authorization = `Bearer ${ token } ` ;
}
return config ;
} ) ;
// Handle response errors globally
api . interceptors . response . use (
( response ) => response . data ,
( error ) => {
const message = error . response ?. data ?. message || 'Something went wrong' ;
console . error ( 'API Error:' , message ) ;
if ( error . response ?. status === 401 ) {
// Redirect to login if unauthorized
if ( typeof window !== 'undefined' ) {
window . location . href = '/login' ;
}
}
return Promise . reject ( { message, status : error . response ?. status } ) ;
}
) ;
// ============ API METHODS ============
// Auth
export const authAPI = {
register : ( data ) => api . post ( '/auth/register' , data ) ,
login : ( data ) => api . post ( '/auth/login' , data ) ,
getMe : ( ) => api . get ( '/auth/me' ) ,
logout : ( ) => api . post ( '/auth/logout' ) ,
} ;
// Tasks
export const taskAPI = {
getAvailable : ( params ) => api . get ( '/tasks' , { params } ) ,
getMyTasks : ( params ) => api . get ( '/tasks/my' , { params } ) ,
getTodaysTask : ( ) => api . get ( '/tasks/today' ) ,
getById : ( id ) => api . get ( `/tasks/${ id } ` ) ,
accept : ( id ) => api . post ( `/tasks/${ id } /accept` ) ,
submit : ( id , formData ) => api . post ( `/tasks/${ id } /submit` , formData , {
headers : { 'Content-Type' : 'multipart/form-data' }
} ) ,
getCategories : ( ) => api . get ( '/tasks/categories/list' ) ,
} ;
// Issues
export const issueAPI = {
getAll : ( params ) => api . get ( '/issues' , { params } ) ,
getById : ( id ) => api . get ( `/issues/${ id } ` ) ,
create : ( formData ) => api . post ( '/issues' , formData , {
headers : { 'Content-Type' : 'multipart/form-data' }
} ) ,
addUpdate : ( id , formData ) => api . post ( `/issues/${ id } /update` , formData , {
headers : { 'Content-Type' : 'multipart/form-data' }
} ) ,
upvote : ( id ) => api . post ( `/issues/${ id } /upvote` ) ,
getAIAnalysis : ( id ) => api . get ( `/issues/${ id } /ai-analysis` ) ,
} ;
// Circles
export const circleAPI = {
getAll : ( params ) => api . get ( '/circles' , { params } ) ,
getById : ( id ) => api . get ( `/circles/${ id } ` ) ,
create : ( data ) => api . post ( '/circles' , data ) ,
join : ( id ) => api . post ( `/circles/${ id } /join` ) ,
adoptIssue : ( id , data ) => api . post ( `/circles/${ id } /adopt-issue` , data ) ,
} ;
// Wards
export const wardAPI = {
getAll : ( ) => api . get ( '/wards' ) ,
getDashboard : ( id ) => api . get ( `/wards/${ id } /dashboard` ) ,
getLeaderboard : ( ) => api . get ( '/wards/leaderboard/global' ) ,
getWardLeaderboard : ( id ) => api . get ( `/wards/${ id } /leaderboard` ) ,
} ;
// Portfolio
export const portfolioAPI = {
get : ( userId ) => api . get ( `/portfolio/${ userId } ` ) ,
generateSummary : ( userId ) => api . get ( `/portfolio/${ userId } /generate-summary` ) ,
getCertificate : ( userId ) => api . get ( `/portfolio/${ userId } /certificate` ) ,
} ;
// Users
export const userAPI = {
getProfile : ( ) => api . get ( '/users/profile' ) ,
updateProfile : ( data ) => api . put ( '/users/profile' , data ) ,
updateAvatar : ( formData ) => api . put ( '/users/avatar' , formData , {
headers : { 'Content-Type' : 'multipart/form-data' }
} ) ,
completeOnboarding : ( data ) => api . post ( '/users/onboarding' , data ) ,
getStats : ( ) => api . get ( '/users/stats' ) ,
getNotifications : ( ) => api . get ( '/users/notifications' ) ,
markNotificationRead : ( id ) => api . put ( `/users/notifications/${ id } /read` ) ,
} ;
export default api ;
// ====================================
// Utility Functions
// ====================================
import { clsx } from "clsx" ;
import { twMerge } from "tailwind-merge" ;
/**
* Merge Tailwind classes
*/
export function cn ( ...inputs ) {
return twMerge ( clsx ( inputs ) ) ;
}
/**
* Format XP number with comma separators
*/
export function formatXP ( xp ) {
return new Intl . NumberFormat ( 'en-IN' ) . format ( xp || 0 ) ;
}
/**
* Get level color based on level name
*/
export function getLevelColor ( level ) {
const colors = {
'Newcomer' : 'text-gray-500' ,
'Curious Citizen' : 'text-green-500' ,
'Active Citizen' : 'text-blue-500' ,
'Ward Warrior' : 'text-purple-500' ,
'Civic Champion' : 'text-orange-500' ,
'CivicStreak Fellow' : 'text-yellow-500' ,
} ;
return colors [ level ] || 'text-gray-500' ;
}
/**
* Get level badge emoji
*/
export function getLevelEmoji ( level ) {
const emojis = {
'Newcomer' : '🌱' ,
'Curious Citizen' : '🔥' ,
'Active Citizen' : '💪' ,
'Ward Warrior' : '⚔️' ,
'Civic Champion' : '🏆' ,
'CivicStreak Fellow' : '🎖️' ,
} ;
return emojis [ level ] || '🌱' ;
}
/**
* Get category icon
*/
export function getCategoryIcon ( category ) {
const icons = {
'DOCUMENT' : '📸' ,
'LEARN' : '📝' ,
'VOICE' : '🌊' ,
'CONNECT' : '🤝' ,
'TRACK' : '📊' ,
'MENTOR' : '🎓' ,
} ;
return icons [ category ] || '📋' ;
}
/**
* Get status badge styling
*/
export function getStatusStyle ( status ) {
const styles = {
'reported' : 'bg-yellow-100 text-yellow-800' ,
'acknowledged' : 'bg-blue-100 text-blue-800' ,
'in_progress' : 'bg-purple-100 text-purple-800' ,
'resolved' : 'bg-green-100 text-green-800' ,
'stale' : 'bg-red-100 text-red-800' ,
} ;
return styles [ status ] || 'bg-gray-100 text-gray-800' ;
}
/**
* Time ago formatter
*/
export function timeAgo ( date ) {
const seconds = Math . floor ( ( new Date ( ) - new Date ( date ) ) / 1000 ) ;
const intervals = [
{ label : 'year' , seconds : 31536000 } ,
{ label : 'month' , seconds : 2592000 } ,
{ label : 'week' , seconds : 604800 } ,
{ label : 'day' , seconds : 86400 } ,
{ label : 'hour' , seconds : 3600 } ,
{ label : 'minute' , seconds : 60 } ,
] ;
for ( const interval of intervals ) {
const count = Math . floor ( seconds / interval . seconds ) ;
if ( count >= 1 ) {
return `${ count } ${ interval . label } ${ count > 1 ? 's' : '' } ago` ;
}
}
return 'Just now' ;
}
4.2 Auth Context Provider
client/src/context/AuthContext.js
'use client' ;
// ====================================
// Auth Context — Global auth state
// ====================================
import { createContext , useContext , useEffect , useState } from 'react' ;
import { supabase } from '@/lib/supabase' ;
import { authAPI , userAPI } from '@/lib/api' ;
const AuthContext = createContext ( { } ) ;
export const useAuth = ( ) => useContext ( AuthContext ) ;
export function AuthProvider ( { children } ) {
const [ user , setUser ] = useState ( null ) ;
const [ profile , setProfile ] = useState ( null ) ;
const [ loading , setLoading ] = useState ( true ) ;
useEffect ( ( ) => {
// Check initial session
checkUser ( ) ;
// Listen for auth changes
const { data : { subscription } } = supabase . auth . onAuthStateChange (
async ( event , session ) => {
if ( event === 'SIGNED_IN' && session ) {
setUser ( session . user ) ;
await fetchProfile ( ) ;
} else if ( event === 'SIGNED_OUT' ) {
setUser ( null ) ;
setProfile ( null ) ;
}
}
) ;
return ( ) => subscription ?. unsubscribe ( ) ;
} , [ ] ) ;
const checkUser = async ( ) => {
try {
const { data : { session } } = await supabase . auth . getSession ( ) ;
if ( session ) {
setUser ( session . user ) ;
await fetchProfile ( ) ;
}
} catch ( error ) {
console . error ( 'Check user error:' , error ) ;
} finally {
setLoading ( false ) ;
}
} ;
const fetchProfile = async ( ) => {
try {
const response = await authAPI . getMe ( ) ;
if ( response . success ) {
setProfile ( response . data ) ;
}
} catch ( error ) {
console . error ( 'Fetch profile error:' , error ) ;
}
} ;
const signUp = async ( email , password , metadata ) => {
const { data, error } = await supabase . auth . signUp ( {
email,
password,
options : { data : metadata }
} ) ;
if ( error ) throw error ;
return data ;
} ;
const signIn = async ( email , password ) => {
const { data, error } = await supabase . auth . signInWithPassword ( {
email,
password
} ) ;
if ( error ) throw error ;
await fetchProfile ( ) ;
return data ;
} ;
const signOut = async ( ) => {
await supabase . auth . signOut ( ) ;
setUser ( null ) ;
setProfile ( null ) ;
} ;
const refreshProfile = async ( ) => {
await fetchProfile ( ) ;
} ;
return (
< AuthContext . Provider value = { {
user,
profile,
loading,
signUp,
signIn,
signOut,
refreshProfile,
isAuthenticated : ! ! user
} } >
{ children }
</ AuthContext . Provider >
) ;
}
// ====================================
// Root Layout
// ====================================
import { Inter } from 'next/font/google' ;
import './globals.css' ;
import { AuthProvider } from '@/context/AuthContext' ;
import { Toaster } from 'react-hot-toast' ;
const inter = Inter ( { subsets : [ 'latin' ] } ) ;
export const metadata = {
title : 'CivicStreak — Your City, Your Commitment' ,
description : 'Transforming episodic volunteering into sustained civic habit through gamified micro-engagement' ,
keywords : 'civic engagement, youth, democracy, Mumbai, India, volunteering' ,
} ;
export default function RootLayout ( { children } ) {
return (
< html lang = "en" >
< body className = { inter . className } >
< AuthProvider >
< Toaster
position = "top-right"
toastOptions = { {
duration : 4000 ,
style : {
background : '#1a1a2e' ,
color : '#fff' ,
borderRadius : '12px' ,
} ,
} }
/>
{ children }
</ AuthProvider >
</ body >
</ html >
) ;
}
client/src/app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
: root {
--primary : # 6366f1 ; /* Indigo */
--primary-dark : # 4f46e5 ;
--secondary : # f59e0b ; /* Amber — for streaks */
--success : # 10b981 ; /* Emerald */
--danger : # ef4444 ;
--bg-dark : # 0f172a ; /* Slate 900 */
--bg-card : # 1e293b ; /* Slate 800 */
--text-primary : # f8fafc ; /* Slate 50 */
--text-secondary : # 94a3b8 ; /* Slate 400 */
}
@layer base {
body {
@apply bg-slate-950 text-white min-h-screen;
}
}
@layer components {
.card {
@apply bg-slate-800/50 backdrop-blur-sm border border-slate-700/50
rounded-2xl p-6 shadow-lg;
}
.btn-primary {
@apply bg-indigo-600 hover:bg-indigo-700 text-white font-semibold
py-3 px-6 rounded-xl transition-all duration-200
active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-secondary {
@apply bg-slate-700 hover:bg-slate-600 text-white font-semibold
py-3 px-6 rounded-xl transition-all duration-200 active:scale-95;
}
.btn-success {
@apply bg-emerald-600 hover:bg-emerald-700 text-white font-semibold
py-3 px-6 rounded-xl transition-all duration-200 active:scale-95;
}
.input-field {
@apply w-full bg-slate-700/50 border border-slate-600 rounded-xl
py-3 px-4 text-white placeholder-slate-400
focus:outline-none focus:ring-2 focus:ring-indigo-500
focus:border-transparent transition-all;
}
.badge {
@apply inline-flex items-center px-3 py-1 rounded-full text-xs
font-semibold;
}
.streak-fire {
@apply text-amber-400 animate-pulse;
}
.xp-glow {
@apply text-indigo-400 font-bold;
}
.glass-card {
@apply bg-white/5 backdrop-blur-lg border border-white/10
rounded-2xl p-6 shadow-2xl;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width : 6px ;
}
::-webkit-scrollbar-track {
background : transparent;
}
::-webkit-scrollbar-thumb {
background : # 4b5563 ;
border-radius : 3px ;
}
/* Animations */
@keyframes slideUp {
from { opacity : 0 ; transform : translateY (20px ); }
to { opacity : 1 ; transform : translateY (0 ); }
}
.animate-slide-up {
animation : slideUp 0.5s ease-out;
}
@keyframes countUp {
from { opacity : 0 ; transform : scale (0.5 ); }
to { opacity : 1 ; transform : scale (1 ); }
}
.animate-count-up {
animation : countUp 0.3s ease-out;
}
client/src/app/page.js (Landing Page)
'use client' ;
// ====================================
// Landing Page
// ====================================
import Link from 'next/link' ;
import { motion } from 'framer-motion' ;
import { ArrowRight , MapPin , Award , Users , BarChart3 } from 'lucide-react' ;
export default function LandingPage ( ) {
return (
< div className = "min-h-screen bg-gradient-to-b from-slate-950 via-indigo-950/20 to-slate-950" >
{ /* Navigation */ }
< nav className = "flex items-center justify-between px-6 py-4 max-w-7xl mx-auto" >
< div className = "flex items-center gap-2" >
< span className = "text-2xl" > 🏙️</ span >
< span className = "text-xl font-bold text-white" >
CivicStreak
</ span >
</ div >
< div className = "flex gap-4" >
< Link href = "/login" className = "btn-secondary text-sm py-2 px-4" >
Login
</ Link >
< Link href = "/register" className = "btn-primary text-sm py-2 px-4" >
Get Started
</ Link >
</ div >
</ nav >
{ /* Hero Section */ }
< section className = "max-w-7xl mx-auto px-6 pt-20 pb-32 text-center" >
< motion . div
initial = { { opacity : 0 , y : 30 } }
animate = { { opacity : 1 , y : 0 } }
transition = { { duration : 0.8 } }
>
< div className = "inline-flex items-center gap-2 bg-indigo-500/10 border border-indigo-500/20 rounded-full px-4 py-2 mb-8" >
< span className = "text-sm text-indigo-400" >
🇮🇳 Bridging the civic engagement gap for Indian youth
</ span >
</ div >
< h1 className = "text-5xl md:text-7xl font-bold mb-6 leading-tight" >
< span className = "text-white" > Your City,</ span >
< br />
< span className = "bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent" >
Your Commitment
</ span >
</ h1 >
< p className = "text-xl text-slate-400 max-w-2xl mx-auto mb-4" >
CivicStreak turns democracy from a once-in-5-years vote
into a < strong className = "text-white" > daily 5-minute habit</ strong > .
</ p >
< p className = "text-lg text-slate-500 max-w-xl mx-auto mb-10" >
Break down massive civic goals into quick, visible, social micro-actions.
Make democracy feel like a daily habit, not a boring duty.
</ p >
< div className = "flex flex-col sm:flex-row gap-4 justify-center" >
< Link href = "/register" className = "btn-primary text-lg py-4 px-8 flex items-center gap-2 justify-center" >
Start Your Civic Journey < ArrowRight size = { 20 } />
</ Link >
< Link href = "/ward" className = "btn-secondary text-lg py-4 px-8" >
Explore Ward Dashboards
</ Link >
</ div >
</ motion . div >
{ /* Stats Bar */ }
< motion . div
initial = { { opacity : 0 , y : 50 } }
animate = { { opacity : 1 , y : 0 } }
transition = { { duration : 0.8 , delay : 0.3 } }
className = "mt-20 grid grid-cols-2 md:grid-cols-4 gap-6 max-w-4xl mx-auto"
>
{ [
{ number : '500+' , label : 'Active CivicStreaks' , icon : '👥' } ,
{ number : '25+' , label : 'Community Circles' , icon : '🤝' } ,
{ number : '200+' , label : 'Issues Documented' , icon : '📸' } ,
{ number : '30+' , label : 'RTIs Filed' , icon : '📜' } ,
] . map ( ( stat , i ) => (
< div key = { i } className = "card text-center" >
< div className = "text-3xl mb-2" > { stat . icon } </ div >
< div className = "text-2xl font-bold text-white" > { stat . number } </ div >
< div className = "text-sm text-slate-400" > { stat . label } </ div >
</ div >
) ) }
</ motion . div >
</ section >
{ /* Four Pillars Section */ }
< section className = "max-w-7xl mx-auto px-6 py-20" >
< h2 className = "text-3xl font-bold text-center mb-4" >
The Four Pillars of CivicStreak
</ h2 >
< p className = "text-slate-400 text-center mb-16 max-w-xl mx-auto" >
A complete ecosystem designed to make civic engagement
feel like scrolling social media — fast, rewarding, and social.
</ p >
< div className = "grid md:grid-cols-2 gap-8" >
{ [
{
icon : < MapPin className = "text-indigo-400" size = { 32 } /> ,
title : '🎯 Micro-Tasks ("Civic Bites")' ,
description : 'Break down massive civic goals into 5-15 minute daily tasks. Photograph a budget board, take a ward quiz, sign a petition.' ,
features : [ 'Location-based' , 'Skill-matched' , 'Time-flexible' ]
} ,
{
icon : < Award className = "text-amber-400" size = { 32 } /> ,
title : '🔥 Streaks & Portfolio' ,
description : 'Duolingo-style streaks keep you coming back. Build a shareable "Civic Portfolio" — your democratic resume for college & career.' ,
features : [ 'XP System' , 'Level progression' , 'Shareable certificates' ]
} ,
{
icon : < Users className = "text-green-400" size = { 32 } /> ,
title : '🤝 Community Circles' ,
description : 'Teams of 5-8 youth from the same ward. Adopt one civic issue, meet weekly, present quarterly to ward councilors.' ,
features : [ 'Team accountability' , 'Rotating roles' , 'Mentor support' ]
} ,
{
icon : < BarChart3 className = "text-purple-400" size = { 32 } /> ,
title : '📊 Ward Impact Dashboard' ,
description : 'Real-time ward-level transparency. Track issues, measure responsiveness, compare wards — making invisible work visible.' ,
features : [ 'Issue tracking' , 'Ward scoring' , 'Public accountability' ]
}
] . map ( ( pillar , i ) => (
< motion . div
key = { i }
initial = { { opacity : 0 , y : 30 } }
whileInView = { { opacity : 1 , y : 0 } }
transition = { { duration : 0.5 , delay : i * 0.1 } }
viewport = { { once : true } }
className = "card hover:border-indigo-500/30 transition-colors"
>
< div className = "mb-4" > { pillar . icon } </ div >
< h3 className = "text-xl font-bold mb-2" > { pillar . title } </ h3 >
< p className = "text-slate-400 mb-4" > { pillar . description } </ p >
< div className = "flex flex-wrap gap-2" >
{ pillar . features . map ( ( f , j ) => (
< span key = { j } className = "badge bg-slate-700 text-slate-300" >
{ f }
</ span >
) ) }
</ div >
</ motion . div >
) ) }
</ div >
</ section >
{ /* CTA Section */ }
< section className = "max-w-4xl mx-auto px-6 py-20 text-center" >
< div className = "glass-card" >
< h2 className = "text-3xl font-bold mb-4" >
Every CivicStreak starts with one micro-task.
</ h2 >
< p className = "text-slate-400 mb-8" >
5 minutes today. A better city tomorrow.
Join the civic revolution.
</ p >
< Link href = "/register" className = "btn-primary text-lg py-4 px-10 inline-flex items-center gap-2" >
Become a CivicStreak < ArrowRight size = { 20 } />
</ Link >
</ div >
</ section >
{ /* Footer */ }
< footer className = "border-t border-slate-800 py-8 px-6" >
< div className = "max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4" >
< div className = "flex items-center gap-2" >
< span className = "text-xl" > 🏙️</ span >
< span className = "font-bold" > CivicStreak</ span >
< span className = "text-slate-500 text-sm ml-2" >
नगरमित्र — Your City, Your Commitment
</ span >
</ div >
< div className = "text-slate-500 text-sm" >
Built with ❤️ for Indian democracy
</ div >
</ div >
</ footer >
</ div >
) ;
}
client/src/app/(auth)/register/page.js
'use client' ;
// ====================================
// Registration Page
// ====================================
import { useState } from 'react' ;
import { useRouter } from 'next/navigation' ;
import Link from 'next/link' ;
import { useAuth } from '@/context/AuthContext' ;
import { userAPI , wardAPI } from '@/lib/api' ;
import toast from 'react-hot-toast' ;
import { motion } from 'framer-motion' ;
import { useEffect } from 'react' ;
export default function RegisterPage ( ) {
const router = useRouter ( ) ;
const { signUp } = useAuth ( ) ;
const [ step , setStep ] = useState ( 1 ) ; // 1: Account, 2: Profile, 3: Interests
const [ loading , setLoading ] = useState ( false ) ;
const [ wards , setWards ] = useState ( [ ] ) ;
const [ formData , setFormData ] = useState ( {
email : '' ,
password : '' ,
full_name : '' ,
phone : '' ,
age : '' ,
college : '' ,
ward_id : '' ,
interests : [ ] ,
preferred_language : 'English'
} ) ;
useEffect ( ( ) => {
fetchWards ( ) ;
} , [ ] ) ;
const fetchWards = async ( ) => {
try {
const response = await wardAPI . getAll ( ) ;
if ( response . success ) {
setWards ( response . data ) ;
}
} catch ( error ) {
console . error ( 'Fetch wards error:' , error ) ;
}
} ;
const handleChange = ( e ) => {
setFormData ( { ...formData , [ e . target . name ] : e . target . value } ) ;
} ;
const toggleInterest = ( interest ) => {
setFormData ( prev => ( {
...prev ,
interests : prev . interests . includes ( interest )
? prev . interests . filter ( i => i !== interest )
: [ ...prev . interests , interest ]
} ) ) ;
} ;
const handleSubmit = async ( ) => {
setLoading ( true ) ;
try {
// Step 1: Create account
await signUp ( formData . email , formData . password , {
full_name : formData . full_name ,
phone : formData . phone
} ) ;
// Step 2: Complete onboarding
await userAPI . completeOnboarding ( {
ward_id : formData . ward_id ,
interests : formData . interests ,
college : formData . college ,
age : parseInt ( formData . age ) ,
preferred_language : formData . preferred_language
} ) ;
toast . success ( 'Welcome to CivicStreak! 🎉' ) ;
router . push ( '/dashboard' ) ;
} catch ( error ) {
toast . error ( error . message || 'Registration failed' ) ;
} finally {
setLoading ( false ) ;
}
} ;
const interests = [
'Environment' , 'Infrastructure' , 'Education' ,
'Health' , 'Safety' , 'Governance' ,
'Sanitation' , 'Transportation' , 'Community'
] ;
return (
< div className = "min-h-screen flex items-center justify-center px-4 py-10" >
< motion . div
initial = { { opacity : 0 , y : 20 } }
animate = { { opacity : 1 , y : 0 } }
className = "w-full max-w-md"
>
{ /* Logo */ }
< div className = "text-center mb-8" >
< span className = "text-4xl" > 🏙️</ span >
< h1 className = "text-2xl font-bold mt-2" > Join CivicStreak</ h1 >
< p className = "text-slate-400 mt-1" > Your civic journey starts here</ p >
</ div >
{ /* Progress indicator */ }
< div className = "flex items-center justify-center gap-2 mb-8" >
{ [ 1 , 2 , 3 ] . map ( s => (
< div key = { s } className = { `h-2 rounded-full transition-all ${
s <= step ? 'w-10 bg-indigo-500' : 'w-6 bg-slate-700'
} `} />
) ) }
</ div >
< div className = "card" >
{ /* Step 1: Account Details */ }
{ step === 1 && (
< div className = "space-y-4" >
< h2 className = "text-lg font-semibold mb-4" > Create Account</ h2 >
< div >
< label className = "block text-sm text-slate-400 mb-1" > Full Name</ label >
< input
type = "text"
name = "full_name"
value = { formData . full_name }
onChange = { handleChange }
className = "input-field"
placeholder = "Rohan Sharma"
/>
</ div >
< div >
< label className = "block text-sm text-slate-400 mb-1" > Email</ label >
< input
type = "email"
name = "email"
value = { formData . email }
onChange = { handleChange }
className = "input-field"
placeholder = "rohan@example.com"
/>
</ div >
< div >
< label className = "block text-sm text-slate-400 mb-1" > Password</ label >
< input
type = "password"
name = "password"
value = { formData . password }
onChange = { handleChange }
className = "input-field"
placeholder = "Min 6 characters"
/>
</ div >
< div >
< label className = "block text-sm text-slate-400 mb-1" > Phone (WhatsApp)</ label >
< input
type = "tel"
name = "phone"
value = { formData . phone }
onChange = { handleChange }
className = "input-field"
placeholder = "+919876543210"
/>
</ div >
< button
onClick = { ( ) => setStep ( 2 ) }
className = "btn-primary w-full"
disabled = { ! formData . email || ! formData . password || ! formData . full_name }
>
Next →
</ button >
</ div >
) }
{ /* Step 2: Profile Details */ }
{ step === 2 && (
< div className = "space-y-4" >
< h2 className = "text-lg font-semibold mb-4" > Your Profile</ h2 >
< div >
< label className = "block text-sm text-slate-400 mb-1" > Age</ label >
< input
type = "number"
name = "age"
value = { formData . age }
onChange = { handleChange }
className = "input-field"
placeholder = "18"
min = "14"
max = "35"
/>
</ div >
< div >
< label className = "block text-sm text-slate-400 mb-1" > College / Institution</ label >
< input
type = "text"
name = "college"
value = { formData . college }
onChange = { handleChange }
className = "input-field"
placeholder = "Mumbai University"
/>
</ div >
< div >
< label className = "block text-sm text-slate-400 mb-1" > Your Ward</ label >
< select
name = "ward_id"
value = { formData . ward_id }
onChange = { handleChange }
className = "input-field"
>
< option value = "" > Select your ward</ option >
{ wards . map ( ward => (
< option key = { ward . id } value = { ward . id } >
Ward { ward . ward_number } — { ward . ward_name }
</ option >
) ) }
</ select >
</ div >
< div >
< label className = "block text-sm text-slate-400 mb-1" > Preferred Language</ label >
< select
name = "preferred_language"
value = { formData . preferred_language }
onChange = { handleChange }
className = "input-field"
>
< option value = "English" > English</ option >
< option value = "Hindi" > Hindi</ option >
< option value = "Marathi" > Marathi</ option >
</ select >
</ div >
< div className = "flex gap-3" >
< button onClick = { ( ) => setStep ( 1 ) } className = "btn-secondary flex-1" >
← Back
</ button >
< button
onClick = { ( ) => setStep ( 3 ) }
className = "btn-primary flex-1"
disabled = { ! formData . ward_id }
>
Next →
</ button >
</ div >
</ div >
) }
{ /* Step 3: Interests */ }
{ step === 3 && (
< div className = "space-y-4" >
< h2 className = "text-lg font-semibold mb-2" > What do you care about?</ h2 >
< p className = "text-sm text-slate-400 mb-4" >
Select topics you're interested in. We'll personalize your tasks.
</ p >
< div className = "flex flex-wrap gap-2" >
{ interests . map ( interest => (
< button
key = { interest }
onClick = { ( ) => toggleInterest ( interest ) }
className = { `badge text-sm py-2 px-4 cursor-pointer transition-all ${
formData . interests . includes ( interest )
? 'bg-indigo-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
} `}
>
{ interest }
</ button >
) ) }
</ div >
< div className = "flex gap-3 mt-6" >
< button onClick = { ( ) => setStep ( 2 ) } className = "btn-secondary flex-1" >
← Back
</ button >
< button
onClick = { handleSubmit }
className = "btn-success flex-1"
disabled = { loading || formData . interests . length === 0 }
>
{ loading ? '🔄 Creating...' : '🎉 Join CivicStreak' }
</ button >
</ div >
</ div >
) }
</ div >
{ /* Login link */ }
< p className = "text-center text-slate-400 mt-6" >
Already a CivicStreak?{ ' ' }
< Link href = "/login" className = "text-indigo-400 hover:text-indigo-300" >
Login here
</ Link >
</ p >
</ motion . div >
</ div >
) ;
}
client/src/app/(auth)/login/page.js
'use client' ;
// ====================================
// Login Page
// ====================================
import { useState } from 'react' ;
import { useRouter } from 'next/navigation' ;
import Link from 'next/link' ;
import { useAuth } from '@/context/AuthContext' ;
import toast from 'react-hot-toast' ;
import { motion } from 'framer-motion' ;
export default function LoginPage ( ) {
const router = useRouter ( ) ;
const { signIn } = useAuth ( ) ;
const [ email , setEmail ] = useState ( '' ) ;
const [ password , setPassword ] = useState ( '' ) ;
const [ loading , setLoading ] = useState ( false ) ;
const handleLogin = async ( e ) => {
e . preventDefault ( ) ;
setLoading ( true ) ;
try {
await signIn ( email , password ) ;
toast . success ( 'Welcome back! 🏙️' ) ;
router . push ( '/dashboard' ) ;
} catch ( error ) {
toast . error ( error . message || 'Invalid credentials' ) ;
} finally {
setLoading ( false ) ;
}
} ;
return (
< div className = "min-h-screen flex items-center justify-center px-4" >
< motion . div
initial = { { opacity : 0 , y : 20 } }
animate = { { opacity : 1 , y : 0 } }
className = "w-full max-w-md"
>
< div className = "text-center mb-8" >
< span className = "text-4xl" > 🏙️</ span >
< h1 className = "text-2xl font-bold mt-2" > Welcome Back</ h1 >
< p className = "text-slate-400 mt-1" > Continue your civic journey</ p >
</ div >
< div className = "card" >
< form onSubmit = { handleLogin } className = "space-y-4" >
< div >
< label className = "block text-sm text-slate-400 mb-1" > Email</ label >
< input
type = "email"
value = { email }
onChange = { ( e ) => setEmail ( e . target . value ) }
className = "input-field"
placeholder = "rohan@example.com"
required
/>
</ div >
< div >
< label className = "block text-sm text-slate-400 mb-1" > Password</ label >
< input
type = "password"
value = { password }
onChange = { ( e ) => setPassword ( e . target . value ) }
className = "input-field"
placeholder = "Your password"
required
/>
</ div >
< button
type = "submit"
className = "btn-primary w-full"
disabled = { loading }
>
{ loading ? '🔄 Logging in...' : '🔑 Login' }
</ button >
</ form >
</ div >
< p className = "text-center text-slate-400 mt-6" >
New to CivicStreak?{ ' ' }
< Link href = "/register" className = "text-indigo-400 hover:text-indigo-300" >
Register here
</ Link >
</ p >
</ motion . div >
</ div >
) ;
}
4.5 Dashboard (Main App Shell)
client/src/components/layout/Sidebar.js
'use client' ;
// ====================================
// Sidebar Navigation
// ====================================
import Link from 'next/link' ;
import { usePathname } from 'next/navigation' ;
import { useAuth } from '@/context/AuthContext' ;
import { formatXP , getLevelEmoji } from '@/lib/utils' ;
import {
Home , Target , Briefcase , Users , Map ,
Trophy , Bell , Settings , LogOut , Flame
} from 'lucide-react' ;
const navItems = [
{ href : '/dashboard' , label : 'Dashboard' , icon : Home } ,
{ href : '/tasks' , label : 'Micro-Tasks' , icon : Target } ,
{ href : '/portfolio' , label : 'My Portfolio' , icon : Briefcase } ,
{ href : '/circles' , label : 'Circles' , icon : Users } ,
{ href : '/ward' , label : 'Ward Dashboard' , icon : Map } ,
{ href : '/leaderboard' , label : 'Leaderboard' , icon : Trophy } ,
] ;
export default function Sidebar ( ) {
const pathname = usePathname ( ) ;
const { profile, signOut } = useAuth ( ) ;
return (
< aside className = "fixed left-0 top-0 h-screen w-64 bg-slate-900 border-r border-slate-800 flex flex-col z-50" >
{ /* Logo */ }
< div className = "p-6 border-b border-slate-800" >
< Link href = "/dashboard" className = "flex items-center gap-2" >
< span className = "text-2xl" > 🏙️</ span >
< span className = "text-lg font-bold" > CivicStreak</ span >
</ Link >
</ div >
{ /* User summary card */ }
{ profile && (
< div className = "p-4 mx-3 mt-4 rounded-xl bg-slate-800/50 border border-slate-700/50" >
< div className = "flex items-center gap-3 mb-3" >
< div className = "w-10 h-10 rounded-full bg-indigo-600 flex items-center justify-center text-sm font-bold" >
{ profile . full_name ?. charAt ( 0 ) ?. toUpperCase ( ) }
</ div >
< div className = "flex-1 min-w-0" >
< p className = "text-sm font-semibold truncate" >
{ profile . full_name }
</ p >
< p className = "text-xs text-slate-400" >
{ getLevelEmoji ( profile . level ) } { profile . level }
</ p >
</ div >
</ div >
< div className = "grid grid-cols-2 gap-2 text-center" >
< div className = "bg-slate-700/50 rounded-lg py
<div className=" bg-slate-700 / 50 rounded-lg py-1 . 5 ">
< div className = "flex items-center justify-center gap-1" >
< Flame size = { 12 } className = "text-amber-400" />
< span className = "text-sm font-bold" > { profile . current_streak } </ span >
</ div >
< p className = "text-[10px] text-slate-400" > Streak</ p >
</ div >
< div className = "bg-slate-700/50 rounded-lg py-1.5" >
< div className = "text-sm font-bold text-indigo-400" >
{ formatXP ( profile . xp_points ) }
</ div >
< p className = "text-[10px] text-slate-400" > XP</ p >
</ div >
</ div >
</ div >
) }
{ /* Navigation Links */ }
< nav className = "flex-1 px-3 py-4 space-y-1 overflow-y-auto" >
{ navItems . map ( item => {
const Icon = item . icon ;
const isActive = pathname === item . href || pathname . startsWith ( item . href + '/' ) ;
return (
< Link
key = { item . href }
href = { item . href }
className = { `flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all ${
isActive
? 'bg-indigo-600/20 text-indigo-400 border border-indigo-500/30'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
} `}
>
< Icon size = { 18 } />
{ item . label }
</ Link >
) ;
} ) }
</ nav >
{ /* Bottom Actions */ }
< div className = "p-3 border-t border-slate-800 space-y-1" >
< Link
href = "/notifications"
className = "flex items-center gap-3 px-4 py-3 rounded-xl text-sm text-slate-400 hover:bg-slate-800 hover:text-white transition-all"
>
< Bell size = { 18 } />
Notifications
</ Link >
< Link
href = "/settings"
className = "flex items-center gap-3 px-4 py-3 rounded-xl text-sm text-slate-400 hover:bg-slate-800 hover:text-white transition-all"
>
< Settings size = { 18 } />
Settings
</ Link >
< button
onClick = { signOut }
className = "flex items-center gap-3 px-4 py-3 rounded-xl text-sm text-red-400 hover:bg-red-500/10 transition-all w-full"
>
< LogOut size = { 18 } />
Logout
</ button >
</ div >
</ aside >
) ;
}
client/src/components/layout/MobileNav.js
'use client' ;
// ====================================
// Mobile Bottom Navigation
// ====================================
import Link from 'next/link' ;
import { usePathname } from 'next/navigation' ;
import { Home , Target , Briefcase , Users , Map } from 'lucide-react' ;
const navItems = [
{ href : '/dashboard' , label : 'Home' , icon : Home } ,
{ href : '/tasks' , label : 'Tasks' , icon : Target } ,
{ href : '/portfolio' , label : 'Portfolio' , icon : Briefcase } ,
{ href : '/circles' , label : 'Circles' , icon : Users } ,
{ href : '/ward' , label : 'Ward' , icon : Map } ,
] ;
export default function MobileNav ( ) {
const pathname = usePathname ( ) ;
return (
< nav className = "md:hidden fixed bottom-0 left-0 right-0 bg-slate-900/95 backdrop-blur-lg border-t border-slate-800 z-50 safe-area-bottom" >
< div className = "flex justify-around items-center py-2" >
{ navItems . map ( item => {
const Icon = item . icon ;
const isActive = pathname === item . href || pathname . startsWith ( item . href + '/' ) ;
return (
< Link
key = { item . href }
href = { item . href }
className = { `flex flex-col items-center gap-1 px-3 py-2 rounded-xl transition-all ${
isActive
? 'text-indigo-400'
: 'text-slate-500'
} `}
>
< Icon size = { 20 } />
< span className = "text-[10px] font-medium" > { item . label } </ span >
</ Link >
) ;
} ) }
</ div >
</ nav >
) ;
}
client/src/app/dashboard/layout.js (App Shell Layout)
'use client' ;
// ====================================
// Dashboard Layout (App Shell)
// Wraps all authenticated pages
// ====================================
import { useEffect } from 'react' ;
import { useRouter } from 'next/navigation' ;
import { useAuth } from '@/context/AuthContext' ;
import Sidebar from '@/components/layout/Sidebar' ;
import MobileNav from '@/components/layout/MobileNav' ;
export default function DashboardLayout ( { children } ) {
const { user, loading, profile } = useAuth ( ) ;
const router = useRouter ( ) ;
useEffect ( ( ) => {
if ( ! loading && ! user ) {
router . push ( '/login' ) ;
}
if ( ! loading && user && profile && ! profile . onboarding_completed ) {
router . push ( '/register' ) ;
}
} , [ user , loading , profile , router ] ) ;
if ( loading ) {
return (
< div className = "min-h-screen flex items-center justify-center" >
< div className = "text-center" >
< span className = "text-5xl animate-bounce inline-block" > 🏙️</ span >
< p className = "text-slate-400 mt-4" > Loading CivicStreak...</ p >
</ div >
</ div >
) ;
}
if ( ! user ) return null ;
return (
< div className = "min-h-screen flex" >
{ /* Desktop Sidebar */ }
< div className = "hidden md:block" >
< Sidebar />
</ div >
{ /* Main Content */ }
< main className = "flex-1 md:ml-64 pb-20 md:pb-0" >
< div className = "max-w-6xl mx-auto px-4 md:px-8 py-6" >
{ children }
</ div >
</ main >
{ /* Mobile Nav */ }
< MobileNav />
</ div >
) ;
}
client/src/app/dashboard/page.js
'use client' ;
// ====================================
// Dashboard Home
// The main hub after login
// ====================================
import { useState , useEffect } from 'react' ;
import { useAuth } from '@/context/AuthContext' ;
import { taskAPI , userAPI } from '@/lib/api' ;
import { formatXP , getLevelEmoji , getCategoryIcon , timeAgo } from '@/lib/utils' ;
import { motion } from 'framer-motion' ;
import Link from 'next/link' ;
import toast from 'react-hot-toast' ;
import {
Flame , Star , Target , Trophy , ArrowRight ,
Clock , CheckCircle , TrendingUp , ChevronRight
} from 'lucide-react' ;
export default function DashboardPage ( ) {
const { profile, refreshProfile } = useAuth ( ) ;
const [ todaysTask , setTodaysTask ] = useState ( null ) ;
const [ recentTasks , setRecentTasks ] = useState ( [ ] ) ;
const [ stats , setStats ] = useState ( null ) ;
const [ loading , setLoading ] = useState ( true ) ;
useEffect ( ( ) => {
fetchDashboardData ( ) ;
} , [ ] ) ;
const fetchDashboardData = async ( ) => {
try {
const [ taskRes , myTasksRes , statsRes ] = await Promise . all ( [
taskAPI . getTodaysTask ( ) ,
taskAPI . getMyTasks ( { status : 'submitted' } ) ,
userAPI . getStats ( )
] ) ;
if ( taskRes . success ) setTodaysTask ( taskRes . data ) ;
if ( myTasksRes . success ) setRecentTasks ( myTasksRes . data ?. slice ( 0 , 5 ) || [ ] ) ;
if ( statsRes . success ) setStats ( statsRes . data ) ;
} catch ( error ) {
console . error ( 'Dashboard fetch error:' , error ) ;
} finally {
setLoading ( false ) ;
}
} ;
const acceptTodaysTask = async ( ) => {
if ( ! todaysTask ) return ;
try {
const response = await taskAPI . accept ( todaysTask . id ) ;
if ( response . success ) {
toast . success ( 'Task accepted! 💪' ) ;
}
} catch ( error ) {
toast . error ( error . message || 'Failed to accept task' ) ;
}
} ;
if ( loading ) {
return (
< div className = "animate-pulse space-y-6" >
< div className = "h-8 bg-slate-800 rounded w-1/3" > </ div >
< div className = "grid grid-cols-1 md:grid-cols-4 gap-4" >
{ [ ...Array ( 4 ) ] . map ( ( _ , i ) => (
< div key = { i } className = "h-28 bg-slate-800 rounded-2xl" > </ div >
) ) }
</ div >
< div className = "h-60 bg-slate-800 rounded-2xl" > </ div >
</ div >
) ;
}
return (
< div className = "space-y-6" >
{ /* Welcome Header */ }
< motion . div
initial = { { opacity : 0 , y : - 10 } }
animate = { { opacity : 1 , y : 0 } }
className = "flex flex-col md:flex-row md:items-center justify-between gap-4"
>
< div >
< h1 className = "text-2xl font-bold" >
Namaste, { profile ?. full_name ?. split ( ' ' ) [ 0 ] } ! 🙏
</ h1 >
< p className = "text-slate-400 mt-1" >
{ profile ?. wards ?. ward_name
? `Ward ${ profile . wards . ward_number } — ${ profile . wards . ward_name } `
: 'Welcome to CivicStreak' }
</ p >
</ div >
< div className = "flex items-center gap-3" >
< div className = "flex items-center gap-1 bg-amber-500/10 border border-amber-500/20 rounded-full px-4 py-2" >
< Flame size = { 18 } className = "text-amber-400" />
< span className = "text-amber-400 font-bold" > { profile ?. current_streak || 0 } </ span >
< span className = "text-amber-400/70 text-sm" > day streak</ span >
</ div >
< div className = "flex items-center gap-1 bg-indigo-500/10 border border-indigo-500/20 rounded-full px-4 py-2" >
< Star size = { 18 } className = "text-indigo-400" />
< span className = "text-indigo-400 font-bold" > { formatXP ( profile ?. xp_points ) } </ span >
< span className = "text-indigo-400/70 text-sm" > XP</ span >
</ div >
</ div >
</ motion . div >
{ /* Stats Cards */ }
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
transition = { { delay : 0.1 } }
className = "grid grid-cols-2 md:grid-cols-4 gap-4"
>
< StatCard
icon = { < Target className = "text-indigo-400" size = { 24 } /> }
label = "Tasks Done"
value = { profile ?. tasks_completed || 0 }
color = "indigo"
/>
< StatCard
icon = { < Flame className = "text-amber-400" size = { 24 } /> }
label = "Current Streak"
value = { `${ profile ?. current_streak || 0 } days` }
color = "amber"
/>
< StatCard
icon = { < Trophy className = "text-purple-400" size = { 24 } /> }
label = "Level"
value = { profile ?. level || 'Newcomer' }
color = "purple"
small
/>
< StatCard
icon = { < TrendingUp className = "text-emerald-400" size = { 24 } /> }
label = "Issues Resolved"
value = { profile ?. issues_resolved || 0 }
color = "emerald"
/>
</ motion . div >
{ /* Today's Civic Bite */ }
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
transition = { { delay : 0.2 } }
>
< h2 className = "text-lg font-semibold mb-3 flex items-center gap-2" >
🍽️ Today's Civic Bite
</ h2 >
{ todaysTask ? (
< div className = "card border-indigo-500/30 bg-gradient-to-r from-indigo-950/30 to-slate-800/50" >
< div className = "flex items-start justify-between gap-4" >
< div className = "flex-1" >
< div className = "flex items-center gap-2 mb-2" >
< span className = "text-2xl" >
{ getCategoryIcon ( todaysTask . category ) }
</ span >
< span className = "badge bg-indigo-500/20 text-indigo-400" >
{ todaysTask . category }
</ span >
< span className = "badge bg-slate-700 text-slate-300" >
< Clock size = { 10 } className = "mr-1" />
{ todaysTask . estimated_minutes } min
</ span >
</ div >
< h3 className = "text-lg font-semibold mb-1" >
{ todaysTask . title }
</ h3 >
< p className = "text-slate-400 text-sm mb-4" >
{ todaysTask . description }
</ p >
< div className = "flex items-center gap-3" >
< Link
href = { `/tasks/${ todaysTask . id } ` }
className = "btn-primary text-sm py-2 px-4 inline-flex items-center gap-1"
>
Start Task < ArrowRight size = { 14 } />
</ Link >
< span className = "text-sm text-indigo-400 font-semibold" >
+{ todaysTask . xp_reward } XP
</ span >
</ div >
</ div >
</ div >
</ div >
) : (
< div className = "card text-center py-10" >
< span className = "text-4xl mb-4 block" > 🌟</ span >
< h3 className = "font-semibold mb-1" > All caught up!</ h3 >
< p className = "text-slate-400 text-sm" >
You've completed all available tasks. Check back tomorrow!
</ p >
</ div >
) }
</ motion . div >
{ /* Two column layout */ }
< div className = "grid md:grid-cols-2 gap-6" >
{ /* Recent Activity */ }
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
transition = { { delay : 0.3 } }
>
< div className = "flex items-center justify-between mb-3" >
< h2 className = "text-lg font-semibold" > Recent Activity</ h2 >
< Link href = "/tasks?tab=completed" className = "text-indigo-400 text-sm hover:underline flex items-center gap-1" >
View all < ChevronRight size = { 14 } />
</ Link >
</ div >
< div className = "card space-y-3" >
{ recentTasks . length > 0 ? (
recentTasks . map ( ( task , i ) => (
< div
key = { task . id }
className = "flex items-center gap-3 p-3 rounded-xl bg-slate-700/30 hover:bg-slate-700/50 transition-colors"
>
< CheckCircle size = { 18 } className = "text-emerald-400 flex-shrink-0" />
< div className = "flex-1 min-w-0" >
< p className = "text-sm font-medium truncate" >
{ task . micro_tasks ?. title || 'Task' }
</ p >
< p className = "text-xs text-slate-400" >
{ timeAgo ( task . submitted_at ) }
</ p >
</ div >
< span className = "text-xs font-semibold text-indigo-400" >
+{ task . xp_earned } XP
</ span >
</ div >
) )
) : (
< div className = "text-center py-8 text-slate-500" >
< Target size = { 32 } className = "mx-auto mb-2 opacity-50" />
< p className = "text-sm" > No completed tasks yet</ p >
< Link href = "/tasks" className = "text-indigo-400 text-sm hover:underline" >
Browse tasks →
</ Link >
</ div >
) }
</ div >
</ motion . div >
{ /* Quick Actions */ }
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
transition = { { delay : 0.4 } }
>
< h2 className = "text-lg font-semibold mb-3" > Quick Actions</ h2 >
< div className = "space-y-3" >
{ [
{
href : '/tasks' ,
icon : '🎯' ,
title : 'Browse Tasks' ,
desc : 'Find micro-tasks in your ward' ,
color : 'from-indigo-500/10 to-transparent'
} ,
{
href : '/issues/new' ,
icon : '📢' ,
title : 'Report an Issue' ,
desc : 'Document a civic problem' ,
color : 'from-red-500/10 to-transparent'
} ,
{
href : '/circles' ,
icon : '🤝' ,
title : 'Join a Circle' ,
desc : 'Find your civic squad' ,
color : 'from-green-500/10 to-transparent'
} ,
{
href : `/portfolio/${ profile ?. id } ` ,
icon : '📋' ,
title : 'View Portfolio' ,
desc : 'Your democratic resume' ,
color : 'from-purple-500/10 to-transparent'
} ,
] . map ( ( action , i ) => (
< Link
key = { i }
href = { action . href }
className = { `card flex items-center gap-4 py-4 hover:border-indigo-500/30 transition-all bg-gradient-to-r ${ action . color } ` }
>
< span className = "text-2xl" > { action . icon } </ span >
< div className = "flex-1" >
< h3 className = "font-semibold text-sm" > { action . title } </ h3 >
< p className = "text-xs text-slate-400" > { action . desc } </ p >
</ div >
< ChevronRight size = { 18 } className = "text-slate-500" />
</ Link >
) ) }
</ div >
</ motion . div >
</ div >
{ /* Level Progress */ }
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
transition = { { delay : 0.5 } }
>
< LevelProgress profile = { profile } />
</ motion . div >
</ div >
) ;
}
// ============ Sub-Components ============
function StatCard ( { icon, label, value, color, small } ) {
return (
< div className = "card flex flex-col items-center text-center py-4" >
{ icon }
< p className = { `${ small ? 'text-sm' : 'text-xl' } font-bold mt-2` } >
{ value }
</ p >
< p className = "text-xs text-slate-400 mt-1" > { label } </ p >
</ div >
) ;
}
function LevelProgress ( { profile } ) {
const levels = [
{ name : 'Newcomer' , minStreak : 0 } ,
{ name : 'Curious Citizen' , minStreak : 7 } ,
{ name : 'Active Citizen' , minStreak : 30 } ,
{ name : 'Ward Warrior' , minStreak : 90 } ,
{ name : 'Civic Champion' , minStreak : 180 } ,
{ name : 'CivicStreak Fellow' , minStreak : 365 } ,
] ;
const currentLevelIndex = levels . findIndex ( l => l . name === profile ?. level ) || 0 ;
const nextLevel = levels [ currentLevelIndex + 1 ] ;
const currentStreak = profile ?. current_streak || 0 ;
const progress = nextLevel
? ( ( currentStreak - levels [ currentLevelIndex ] . minStreak ) /
( nextLevel . minStreak - levels [ currentLevelIndex ] . minStreak ) ) * 100
: 100 ;
return (
< div className = "card" >
< h2 className = "text-lg font-semibold mb-4 flex items-center gap-2" >
🏅 Level Progress
</ h2 >
< div className = "flex items-center justify-between mb-2" >
< span className = "text-sm font-medium" >
{ getLevelEmoji ( profile ?. level ) } { profile ?. level }
</ span >
{ nextLevel && (
< span className = "text-sm text-slate-400" >
Next: { nextLevel . name } ({ nextLevel . minStreak - currentStreak } days to go)
</ span >
) }
</ div >
< div className = "w-full h-3 bg-slate-700 rounded-full overflow-hidden" >
< motion . div
className = "h-full bg-gradient-to-r from-indigo-500 to-purple-500 rounded-full"
initial = { { width : 0 } }
animate = { { width : `${ Math . min ( progress , 100 ) } %` } }
transition = { { duration : 1 , ease : "easeOut" } }
/>
</ div >
< div className = "flex justify-between mt-4" >
{ levels . map ( ( level , i ) => (
< div
key = { level . name }
className = { `text-center ${
i <= currentLevelIndex ? 'opacity-100' : 'opacity-30'
} `}
>
< div className = { `w-3 h-3 rounded-full mx-auto mb-1 ${
i <= currentLevelIndex ? 'bg-indigo-500' : 'bg-slate-600'
} `} />
< span className = "text-[9px] text-slate-400 hidden md:block" >
{ level . name }
</ span >
</ div >
) ) }
</ div >
</ div >
) ;
}
client/src/app/tasks/page.js
'use client' ;
// ====================================
// Tasks Page — Browse & manage micro-tasks
// ====================================
import { useState , useEffect } from 'react' ;
import { taskAPI } from '@/lib/api' ;
import { getCategoryIcon , timeAgo } from '@/lib/utils' ;
import Link from 'next/link' ;
import toast from 'react-hot-toast' ;
import { motion } from 'framer-motion' ;
import { Search , Filter , Clock , Star , ChevronRight , CheckCircle } from 'lucide-react' ;
export default function TasksPage ( ) {
const [ tab , setTab ] = useState ( 'available' ) ; // available, my, completed
const [ tasks , setTasks ] = useState ( [ ] ) ;
const [ categories , setCategories ] = useState ( [ ] ) ;
const [ loading , setLoading ] = useState ( true ) ;
const [ filters , setFilters ] = useState ( {
category : '' ,
difficulty : '' ,
time : ''
} ) ;
useEffect ( ( ) => {
fetchTasks ( ) ;
fetchCategories ( ) ;
} , [ tab , filters ] ) ;
const fetchTasks = async ( ) => {
setLoading ( true ) ;
try {
let response ;
if ( tab === 'available' ) {
response = await taskAPI . getAvailable ( filters ) ;
} else if ( tab === 'my' ) {
response = await taskAPI . getMyTasks ( { status : 'in_progress' } ) ;
} else {
response = await taskAPI . getMyTasks ( { status : 'submitted' } ) ;
}
if ( response . success ) {
setTasks ( response . data || [ ] ) ;
}
} catch ( error ) {
console . error ( 'Fetch tasks error:' , error ) ;
} finally {
setLoading ( false ) ;
}
} ;
const fetchCategories = async ( ) => {
try {
const response = await taskAPI . getCategories ( ) ;
if ( response . success ) {
setCategories ( response . data || [ ] ) ;
}
} catch ( error ) {
console . error ( 'Fetch categories error:' , error ) ;
}
} ;
const handleAcceptTask = async ( taskId ) => {
try {
const response = await taskAPI . accept ( taskId ) ;
if ( response . success ) {
toast . success ( response . message ) ;
fetchTasks ( ) ;
}
} catch ( error ) {
toast . error ( error . message || 'Failed to accept task' ) ;
}
} ;
const tabs = [
{ key : 'available' , label : '🎯 Available' } ,
{ key : 'my' , label : '⏳ In Progress' } ,
{ key : 'completed' , label : '✅ Completed' } ,
] ;
return (
< div className = "space-y-6" >
{ /* Header */ }
< div >
< h1 className = "text-2xl font-bold" > Micro-Tasks</ h1 >
< p className = "text-slate-400 mt-1" >
Quick civic actions — 5 to 15 minutes each
</ p >
</ div >
{ /* Category Filter Pills */ }
< div className = "flex gap-2 overflow-x-auto pb-2 scrollbar-hide" >
< button
onClick = { ( ) => setFilters ( { ...filters , category : '' } ) }
className = { `badge py-2 px-4 whitespace-nowrap cursor-pointer transition-all ${
! filters . category
? 'bg-indigo-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
} `}
>
All Tasks
</ button >
{ categories . map ( cat => (
< button
key = { cat . key }
onClick = { ( ) => setFilters ( { ...filters , category : cat . key } ) }
className = { `badge py-2 px-4 whitespace-nowrap cursor-pointer transition-all ${
filters . category === cat . key
? 'bg-indigo-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
} `}
>
{ cat . label } ({ cat . count } )
</ button >
) ) }
</ div >
{ /* Tabs */ }
< div className = "flex gap-1 bg-slate-800 rounded-xl p-1" >
{ tabs . map ( t => (
< button
key = { t . key }
onClick = { ( ) => setTab ( t . key ) }
className = { `flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-all ${
tab === t . key
? 'bg-indigo-600 text-white'
: 'text-slate-400 hover:text-white'
} `}
>
{ t . label }
</ button >
) ) }
</ div >
{ /* Difficulty & Time Filters */ }
< div className = "flex gap-3" >
< select
value = { filters . difficulty }
onChange = { ( e ) => setFilters ( { ...filters , difficulty : e . target . value } ) }
className = "input-field text-sm py-2 w-auto"
>
< option value = "" > All Levels</ option >
< option value = "Beginner" > 🌱 Beginner</ option >
< option value = "Intermediate" > 🌿 Intermediate</ option >
< option value = "Advanced" > 🌳 Advanced</ option >
</ select >
< select
value = { filters . time }
onChange = { ( e ) => setFilters ( { ...filters , time : e . target . value } ) }
className = "input-field text-sm py-2 w-auto"
>
< option value = "" > Any Time</ option >
< option value = "5" > ⚡ 5 min or less</ option >
< option value = "10" > 🕐 10 min or less</ option >
< option value = "15" > 🕑 15 min or less</ option >
< option value = "30" > 🕒 30 min or less</ option >
</ select >
</ div >
{ /* Task List */ }
{ loading ? (
< div className = "space-y-4" >
{ [ ...Array ( 4 ) ] . map ( ( _ , i ) => (
< div key = { i } className = "card animate-pulse" >
< div className = "h-5 bg-slate-700 rounded w-3/4 mb-3" > </ div >
< div className = "h-4 bg-slate-700 rounded w-1/2 mb-2" > </ div >
< div className = "h-4 bg-slate-700 rounded w-1/4" > </ div >
</ div >
) ) }
</ div >
) : tasks . length > 0 ? (
< div className = "space-y-3" >
{ tasks . map ( ( task , i ) => (
< TaskCard
key = { task . id }
task = { tab === 'available' ? task : task }
index = { i }
tab = { tab }
onAccept = { handleAcceptTask }
/>
) ) }
</ div >
) : (
< div className = "card text-center py-12" >
< span className = "text-4xl mb-4 block" >
{ tab === 'available' ? '🔍' : tab === 'my' ? '📭' : '🌟' }
</ span >
< h3 className = "font-semibold mb-1" >
{ tab === 'available' && 'No tasks available' }
{ tab === 'my' && 'No tasks in progress' }
{ tab === 'completed' && 'No completed tasks yet' }
</ h3 >
< p className = "text-sm text-slate-400" >
{ tab === 'available' && 'Check back soon for new civic bites!' }
{ tab === 'my' && 'Accept a task to get started!' }
{ tab === 'completed' && 'Complete your first task to see it here!' }
</ p >
</ div >
) }
</ div >
) ;
}
// ============ Task Card Component ============
function TaskCard ( { task, index, tab, onAccept } ) {
// For "my" and "completed" tabs, task data is nested
const taskData = task . micro_tasks || task ;
const userTask = task . micro_tasks ? task : null ;
return (
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
transition = { { delay : index * 0.05 } }
>
< Link
href = { `/tasks/${ taskData . id } ` }
className = "card flex items-start gap-4 hover:border-indigo-500/30 transition-all cursor-pointer"
>
{ /* Category Icon */ }
< div className = "text-3xl flex-shrink-0 mt-1" >
{ getCategoryIcon ( taskData . category ) }
</ div >
{ /* Task Details */ }
< div className = "flex-1 min-w-0" >
< div className = "flex items-center gap-2 mb-1 flex-wrap" >
< span className = { `badge text-[10px] ${
taskData . difficulty === 'Beginner' ? 'bg-green-500/20 text-green-400' :
taskData . difficulty === 'Intermediate' ? 'bg-yellow-500/20 text-yellow-400' :
'bg-red-500/20 text-red-400'
} `} >
{ taskData . difficulty }
</ span >
< span className = "badge bg-slate-700 text-slate-300 text-[10px]" >
{ taskData . category }
</ span >
{ userTask ?. status === 'submitted' && (
< span className = "badge bg-emerald-500/20 text-emerald-400 text-[10px]" >
< CheckCircle size = { 10 } className = "mr-1" /> Completed
</ span >
) }
</ div >
< h3 className = "font-semibold text-sm mb-1 line-clamp-1" >
{ taskData . title }
</ h3 >
< p className = "text-xs text-slate-400 line-clamp-2 mb-2" >
{ taskData . description }
</ p >
< div className = "flex items-center gap-4 text-xs text-slate-500" >
< span className = "flex items-center gap-1" >
< Clock size = { 12 } /> { taskData . estimated_minutes } min
</ span >
< span className = "flex items-center gap-1 text-indigo-400 font-semibold" >
< Star size = { 12 } /> +{ taskData . xp_reward } XP
</ span >
{ userTask ?. submitted_at && (
< span > { timeAgo ( userTask . submitted_at ) } </ span >
) }
</ div >
</ div >
{ /* Action */ }
< div className = "flex-shrink-0" >
{ tab === 'available' ? (
< button
onClick = { ( e ) => {
e . preventDefault ( ) ;
onAccept ( taskData . id ) ;
} }
className = "btn-primary text-xs py-2 px-3"
>
Accept
</ button >
) : (
< ChevronRight size = { 18 } className = "text-slate-500" />
) }
</ div >
</ Link >
</ motion . div >
) ;
}
client/src/app/tasks/[id]/page.js
'use client' ;
// ====================================
// Single Task Detail + Submission Page
// ====================================
import { useState , useEffect } from 'react' ;
import { useParams , useRouter } from 'next/navigation' ;
import { taskAPI } from '@/lib/api' ;
import { getCategoryIcon , getLevelEmoji } from '@/lib/utils' ;
import toast from 'react-hot-toast' ;
import { motion } from 'framer-motion' ;
import Confetti from 'react-confetti' ;
import {
Clock , Star , ArrowLeft , Upload , Camera ,
CheckCircle , AlertTriangle , Send
} from 'lucide-react' ;
export default function TaskDetailPage ( ) {
const { id } = useParams ( ) ;
const router = useRouter ( ) ;
const [ task , setTask ] = useState ( null ) ;
const [ loading , setLoading ] = useState ( true ) ;
const [ submitting , setSubmitting ] = useState ( false ) ;
const [ showConfetti , setShowConfetti ] = useState ( false ) ;
const [ submission , setSubmission ] = useState ( {
proof_text : '' ,
proof_file : null ,
quiz_score : null
} ) ;
const [ previewUrl , setPreviewUrl ] = useState ( null ) ;
useEffect ( ( ) => {
fetchTask ( ) ;
} , [ id ] ) ;
const fetchTask = async ( ) => {
try {
const response = await taskAPI . getById ( id ) ;
if ( response . success ) {
setTask ( response . data ) ;
}
} catch ( error ) {
toast . error ( 'Task not found' ) ;
router . push ( '/tasks' ) ;
} finally {
setLoading ( false ) ;
}
} ;
const handleAccept = async ( ) => {
try {
const response = await taskAPI . accept ( id ) ;
if ( response . success ) {
toast . success ( response . message ) ;
fetchTask ( ) ; // Refresh to show updated status
}
} catch ( error ) {
toast . error ( error . message || 'Failed to accept task' ) ;
}
} ;
const handleFileChange = ( e ) => {
const file = e . target . files [ 0 ] ;
if ( file ) {
setSubmission ( { ...submission , proof_file : file } ) ;
setPreviewUrl ( URL . createObjectURL ( file ) ) ;
}
} ;
const handleSubmit = async ( ) => {
setSubmitting ( true ) ;
try {
const formData = new FormData ( ) ;
if ( submission . proof_file ) {
formData . append ( 'proof' , submission . proof_file ) ;
}
if ( submission . proof_text ) {
formData . append ( 'proof_text' , submission . proof_text ) ;
}
if ( submission . quiz_score !== null ) {
formData . append ( 'quiz_score' , submission . quiz_score ) ;
}
const response = await taskAPI . submit ( id , formData ) ;
if ( response . success ) {
setShowConfetti ( true ) ;
toast . success ( response . message , {
duration : 5000 ,
icon : '🎉'
} ) ;
// Show achievement notifications
if ( response . data . new_achievements ?. length > 0 ) {
response . data . new_achievements . forEach ( achievement => {
setTimeout ( ( ) => {
toast . success (
`🏅 New Achievement: ${ achievement . icon } ${ achievement . name } !` ,
{ duration : 6000 }
) ;
} , 2000 ) ;
} ) ;
}
// Redirect after celebration
setTimeout ( ( ) => {
setShowConfetti ( false ) ;
router . push ( '/dashboard' ) ;
} , 4000 ) ;
}
} catch ( error ) {
toast . error ( error . message || 'Submission failed' ) ;
} finally {
setSubmitting ( false ) ;
}
} ;
if ( loading ) {
return (
< div className = "animate-pulse space-y-4" >
< div className = "h-8 bg-slate-800 rounded w-2/3" > </ div >
< div className = "h-40 bg-slate-800 rounded-2xl" > </ div >
< div className = "h-60 bg-slate-800 rounded-2xl" > </ div >
</ div >
) ;
}
if ( ! task ) return null ;
const userStatus = task . user_status ;
const isAccepted = userStatus && [ 'assigned' , 'in_progress' ] . includes ( userStatus . status ) ;
const isCompleted = userStatus && [ 'submitted' , 'verified' ] . includes ( userStatus . status ) ;
return (
< div className = "max-w-2xl mx-auto space-y-6" >
{ showConfetti && < Confetti recycle = { false } numberOfPieces = { 300 } /> }
{ /* Back Button */ }
< button
onClick = { ( ) => router . back ( ) }
className = "flex items-center gap-2 text-slate-400 hover:text-white transition-colors"
>
< ArrowLeft size = { 18 } /> Back to Tasks
</ button >
{ /* Task Header */ }
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
className = "card"
>
< div className = "flex items-center gap-3 mb-4" >
< span className = "text-4xl" > { getCategoryIcon ( task . category ) } </ span >
< div >
< div className = "flex items-center gap-2 mb-1" >
< span className = { `badge text-xs ${
task . difficulty === 'Beginner' ? 'bg-green-500/20 text-green-400' :
task . difficulty === 'Intermediate' ? 'bg-yellow-500/20 text-yellow-400' :
'bg-red-500/20 text-red-400'
} `} >
{ task . difficulty }
</ span >
< span className = "badge bg-slate-700 text-slate-300 text-xs" >
{ task . category }
</ span >
</ div >
< h1 className = "text-xl font-bold" > { task . title } </ h1 >
</ div >
</ div >
< p className = "text-slate-300 mb-4" > { task . description } </ p >
< div className = "flex items-center gap-6 text-sm" >
< span className = "flex items-center gap-1 text-slate-400" >
< Clock size = { 16 } /> { task . estimated_minutes } minutes
</ span >
< span className = "flex items-center gap-1 text-indigo-400 font-semibold" >
< Star size = { 16 } /> +{ task . xp_reward } XP
</ span >
< span className = "text-slate-400" >
Proof: { task . required_proof === 'photo' ? '📸 Photo' :
task . required_proof === 'text' ? '✍️ Text' :
task . required_proof === 'quiz' ? '❓ Quiz' : '✅ None' }
</ span >
</ div >
{ /* Location hint */ }
{ task . location_hint && (
< div className = "mt-4 p-3 rounded-xl bg-slate-700/50 flex items-center gap-2 text-sm" >
< span > 📍</ span >
< span className = "text-slate-300" > { task . location_hint } </ span >
</ div >
) }
</ motion . div >
{ /* Instructions */ }
{ task . instructions && (
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
transition = { { delay : 0.1 } }
className = "card"
>
< h2 className = "text-lg font-semibold mb-3" > 📋 Instructions</ h2 >
< div className = "text-slate-300 text-sm leading-relaxed whitespace-pre-line" >
{ task . instructions }
</ div >
</ motion . div >
) }
{ /* Action Area */ }
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
transition = { { delay : 0.2 } }
>
{ /* Not yet accepted */ }
{ ! userStatus && (
< button onClick = { handleAccept } className = "btn-primary w-full text-lg py-4" >
Accept This Task 💪
</ button >
) }
{ /* Already completed */ }
{ isCompleted && (
< div className = "card text-center py-8 border-emerald-500/30" >
< CheckCircle size = { 48 } className = "mx-auto text-emerald-400 mb-4" />
< h3 className = "text-lg font-semibold text-emerald-400" > Task Completed!</ h3 >
< p className = "text-slate-400 mt-1" >
You earned +{ userStatus . xp_earned } XP for this task
</ p >
</ div >
) }
{ /* In progress — Show submission form */ }
{ isAccepted && (
< div className = "card" >
< h2 className = "text-lg font-semibold mb-4" > 📤 Submit Your Work</ h2 >
{ /* Photo Upload */ }
{ task . required_proof === 'photo' && (
< div className = "mb-4" >
< label className = "block text-sm text-slate-400 mb-2" >
Upload Photo Proof
</ label >
< div
className = "border-2 border-dashed border-slate-600 rounded-xl p-6 text-center cursor-pointer hover:border-indigo-500 transition-colors"
onClick = { ( ) => document . getElementById ( 'file-input' ) . click ( ) }
>
{ previewUrl ? (
< img
src = { previewUrl }
alt = "Preview"
className = "max-h-60 mx-auto rounded-lg"
/>
) : (
< >
< Camera size = { 32 } className = "mx-auto text-slate-500 mb-2" />
< p className = "text-sm text-slate-400" >
Click to upload or drag & drop
</ p >
< p className = "text-xs text-slate-500 mt-1" >
JPG, PNG up to 5MB
</ p >
</ >
) }
< input
id = "file-input"
type = "file"
accept = "image/*"
className = "hidden"
onChange = { handleFileChange }
/>
</ div >
</ div >
) }
{ /* Text Submission */ }
{ ( task . required_proof === 'text' || task . required_proof === 'photo' ) && (
< div className = "mb-4" >
< label className = "block text-sm text-slate-400 mb-2" >
{ task . required_proof === 'photo'
? 'Add a note (optional)'
: 'Your Response' }
</ label >
< textarea
value = { submission . proof_text }
onChange = { ( e ) => setSubmission ( {
...submission ,
proof_text : e . target . value
} ) }
className = "input-field min-h-[120px] resize-none"
placeholder = "Describe what you did, what you observed..."
rows = { 4 }
/>
</ div >
) }
{ /* Submit Button */ }
< button
onClick = { handleSubmit }
disabled = { submitting || (
task . required_proof === 'photo' && ! submission . proof_file
) || (
task . required_proof === 'text' && ! submission . proof_text
) }
className = "btn-success w-full flex items-center justify-center gap-2"
>
{ submitting ? (
< > 🔄 Submitting...</ >
) : (
< >
< Send size = { 18 } />
Submit & Earn { task . xp_reward } XP
</ >
) }
</ button >
</ div >
) }
</ motion . div >
</ div >
) ;
}
4.8 Ward Impact Dashboard
client/src/app/ward/page.js
'use client' ;
// ====================================
// Ward Impact Dashboard
// ====================================
import { useState , useEffect } from 'react' ;
import { useAuth } from '@/context/AuthContext' ;
import { wardAPI , issueAPI } from '@/lib/api' ;
import { getStatusStyle , timeAgo , formatXP } from '@/lib/utils' ;
import { motion } from 'framer-motion' ;
import Link from 'next/link' ;
import {
Users , Target , CheckCircle , AlertTriangle ,
TrendingUp , MapPin , Award , ChevronDown
} from 'lucide-react' ;
export default function WardDashboardPage ( ) {
const { profile } = useAuth ( ) ;
const [ wards , setWards ] = useState ( [ ] ) ;
const [ selectedWardId , setSelectedWardId ] = useState ( null ) ;
const [ dashboard , setDashboard ] = useState ( null ) ;
const [ loading , setLoading ] = useState ( true ) ;
useEffect ( ( ) => {
fetchWards ( ) ;
} , [ ] ) ;
useEffect ( ( ) => {
if ( selectedWardId ) {
fetchDashboard ( selectedWardId ) ;
}
} , [ selectedWardId ] ) ;
const fetchWards = async ( ) => {
try {
const response = await wardAPI . getAll ( ) ;
if ( response . success ) {
setWards ( response . data ) ;
// Default to user's ward
const defaultWard = profile ?. ward_id || response . data [ 0 ] ?. id ;
setSelectedWardId ( defaultWard ) ;
}
} catch ( error ) {
console . error ( 'Fetch wards error:' , error ) ;
}
} ;
const fetchDashboard = async ( wardId ) => {
setLoading ( true ) ;
try {
const response = await wardAPI . getDashboard ( wardId ) ;
if ( response . success ) {
setDashboard ( response . data ) ;
}
} catch ( error ) {
console . error ( 'Fetch dashboard error:' , error ) ;
} finally {
setLoading ( false ) ;
}
} ;
return (
< div className = "space-y-6" >
{ /* Header */ }
< div className = "flex flex-col md:flex-row md:items-center justify-between gap-4" >
< div >
< h1 className = "text-2xl font-bold" > Ward Impact Dashboard</ h1 >
< p className = "text-slate-400 mt-1" >
Making invisible civic work visible 📊
</ p >
</ div >
{ /* Ward Selector */ }
< select
value = { selectedWardId || '' }
onChange = { ( e ) => setSelectedWardId ( e . target . value ) }
className = "input-field w-auto text-sm py-2"
>
{ wards . map ( ward => (
< option key = { ward . id } value = { ward . id } >
Ward { ward . ward_number } — { ward . ward_name }
{ ward . id === profile ?. ward_id ? ' (Your Ward)' : '' }
</ option >
) ) }
</ select >
</ div >
{ loading ? (
< div className = "animate-pulse space-y-4" >
< div className = "grid grid-cols-2 md:grid-cols-4 gap-4" >
{ [ ...Array ( 4 ) ] . map ( ( _ , i ) => (
< div key = { i } className = "h-28 bg-slate-800 rounded-2xl" > </ div >
) ) }
</ div >
< div className = "h-60 bg-slate-800 rounded-2xl" > </ div >
</ div >
) : dashboard ? (
< >
{ /* Ward Header Card */ }
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
className = "card bg-gradient-to-r from-indigo-950/30 to-purple-950/30 border-indigo-500/20"
>
< div className = "flex items-center justify-between" >
< div >
< div className = "flex items-center gap-2 mb-1" >
< MapPin size = { 18 } className = "text-indigo-400" />
< h2 className = "text-xl font-bold" >
Ward { dashboard . ward . ward_number } — { dashboard . ward . ward_name }
</ h2 >
</ div >
< p className = "text-slate-400 text-sm" >
{ dashboard . ward . area_description || dashboard . ward . city }
</ p >
</ div >
< div className = "text-center" >
< div className = "text-3xl font-bold text-indigo-400" >
{ dashboard . responsiveness_score ?. toFixed ( 1 ) || '—' }
</ div >
< div className = "text-xs text-slate-400" >
Responsiveness< br /> Score /10
</ div >
</ div >
</ div >
</ motion . div >
{ /* Stats Grid */ }
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
transition = { { delay : 0.1 } }
className = "grid grid-cols-2 md:grid-cols-4 gap-4"
>
< WardStatCard
icon = { < Users className = "text-blue-400" size = { 24 } /> }
value = { dashboard . stats . active_CivicStreaks }
label = "Active CivicStreaks"
/>
< WardStatCard
icon = { < Target className = "text-purple-400" size = { 24 } /> }
value = { dashboard . stats . active_circles }
label = "Active Circles"
/>
< WardStatCard
icon = { < AlertTriangle className = "text-amber-400" size = { 24 } /> }
value = { dashboard . stats . issues . total }
label = "Issues Tracked"
/>
< WardStatCard
icon = { < CheckCircle className = "text-emerald-400" size = { 24 } /> }
value = { dashboard . stats . issues . resolved }
label = "Issues Resolved"
/>
</ motion . div >
{ /* Two Column Layout */ }
< div className = "grid md:grid-cols-2 gap-6" >
{ /* Top Issues */ }
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
transition = { { delay : 0.2 } }
>
< h2 className = "text-lg font-semibold mb-3" >
📌 Top Issues This Month
</ h2 >
< div className = "card space-y-3" >
{ dashboard . top_issues . length > 0 ? (
dashboard . top_issues . map ( ( issue , i ) => (
< Link
key = { issue . id }
href = { `/issues/${ issue . id } ` }
className = "flex items-center gap-3 p-3 rounded-xl bg-slate-700/30 hover:bg-slate-700/50 transition-colors"
>
< span className = "text-lg font-bold text-slate-500 w-6" >
{ i + 1 } .
</ span >
< div className = "flex-1 min-w-0" >
< p className = "text-sm font-medium truncate" >
{ issue . title }
</ p >
< div className = "flex items-center gap-2 mt-1" >
< span className = { `badge text-[10px] ${ getStatusStyle ( issue . status ) } ` } >
{ issue . status . replace ( '_' , ' ' ) }
</ span >
< span className = "text-[10px] text-slate-500" >
Day { issue . days_ago }
</ span >
</ div >
</ div >
< span className = "text-xs text-slate-500" >
👍 { issue . upvotes }
</ span >
</ Link >
) )
) : (
< p className = "text-center text-slate-500 py-6 text-sm" >
No issues reported yet for this ward.
</ p >
) }
< Link
href = { `/issues?ward=${ selectedWardId } ` }
className = "block text-center text-indigo-400 text-sm hover:underline pt-2"
>
View all issues →
</ Link >
</ div >
</ motion . div >
{ /* Ward Leaderboard */ }
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
transition = { { delay : 0.3 } }
>
< h2 className = "text-lg font-semibold mb-3" >
🏆 Ward Leaderboard
</ h2 >
< div className = "card space-y-3" >
{ dashboard . top_users . length > 0 ? (
dashboard . top_users . map ( ( user , i ) => (
< div
key = { i }
className = "flex items-center gap-3 p-3 rounded-xl bg-slate-700/30"
>
< span className = "text-lg font-bold w-6 text-center" >
{ i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${ i + 1 } .` }
</ span >
< div className = "w-8 h-8 rounded-full bg-indigo-600 flex items-center justify-center text-xs font-bold flex-shrink-0" >
{ user . full_name ?. charAt ( 0 ) ?. toUpperCase ( ) }
</ div >
< div className = "flex-1 min-w-0" >
< p className = "text-sm font-medium truncate" >
{ user . full_name }
</ p >
< p className = "text-[10px] text-slate-400" >
{ user . level } • 🔥 { user . current_streak } day streak
</ p >
</ div >
< span className = "text-sm font-bold text-indigo-400" >
{ formatXP ( user . xp_points ) } XP
</ span >
</ div >
) )
) : (
< p className = "text-center text-slate-500 py-6 text-sm" >
No active users yet in this ward.
</ p >
) }
< Link
href = "/leaderboard"
className = "block text-center text-indigo-400 text-sm hover:underline pt-2"
>
View full leaderboard →
</ Link >
</ div >
</ motion . div >
</ div >
{ /* Issue Status Breakdown */ }
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
transition = { { delay : 0.4 } }
className = "card"
>
< h2 className = "text-lg font-semibold mb-4" >
📊 Issue Status Breakdown
</ h2 >
< div className = "grid grid-cols-2 md:grid-cols-5 gap-4" >
{ [
{ label : 'Reported' , value : dashboard . stats . issues . reported , color : 'bg-yellow-500' } ,
{ label : 'In Progress' , value : dashboard . stats . issues . in_progress , color : 'bg-purple-500' } ,
{ label : 'Resolved' , value : dashboard . stats . issues . resolved , color : 'bg-emerald-500' } ,
{ label : 'Stale' , value : dashboard . stats . issues . stale , color : 'bg-red-500' } ,
{ label : 'Total' , value : dashboard . stats . issues . total , color : 'bg-indigo-500' } ,
] . map ( ( item , i ) => (
< div key = { i } className = "text-center p-3 rounded-xl bg-slate-700/30" >
< div className = { `w-3 h-3 rounded-full ${ item . color } mx-auto mb-2` } />
< div className = "text-xl font-bold" > { item . value } </ div >
< div className = "text-xs text-slate-400" > { item . label } </ div >
</ div >
) ) }
</ div >
{ /* Simple Bar Visual */ }
{ dashboard . stats . issues . total > 0 && (
< div className = "mt-4 h-4 bg-slate-700 rounded-full overflow-hidden flex" >
< div
className = "bg-emerald-500 h-full transition-all"
style = { { width : `${ ( dashboard . stats . issues . resolved / dashboard . stats . issues . total ) * 100 } %` } }
title = "Resolved"
/>
< div
className = "bg-purple-500 h-full transition-all"
style = { { width : `${ ( dashboard . stats . issues . in_progress / dashboard . stats .
issues . total ) * 100 } %` } }
title = "In Progress"
/>
< div
className = "bg-yellow-500 h-full transition-all"
style = { { width : `${ ( dashboard . stats . issues . reported / dashboard . stats . issues . total ) * 100 } %` } }
title = "Reported"
/>
< div
className = "bg-red-500 h-full transition-all"
style = { { width : `${ ( dashboard . stats . issues . stale / dashboard . stats . issues . total ) * 100 } %` } }
title = "Stale"
/>
</ div >
) }
</ motion . div >
</ >
) : (
< div className = "card text-center py-12" >
< MapPin size = { 48 } className = "mx-auto text-slate-600 mb-4" />
< h3 className = "font-semibold mb-1" > Select a ward to view its dashboard</ h3 >
< p className = "text-sm text-slate-400" >
Track issues, see active volunteers, and measure impact.
</ p >
</ div >
) }
</ div >
) ;
}
// ============ Sub-Components ============
function WardStatCard ( { icon, value, label } ) {
return (
< div className = "card flex flex-col items-center text-center py-4" >
{ icon }
< p className = "text-2xl font-bold mt-2" > { value } </ p >
< p className = "text-xs text-slate-400 mt-1" > { label } </ p >
</ div >
) ;
}
client/src/app/portfolio/[userId]/page.js
'use client' ;
// ====================================
// Civic Portfolio — Public Profile Page
// "Your Democratic Resume"
// ====================================
import { useState , useEffect } from 'react' ;
import { useParams } from 'next/navigation' ;
import { portfolioAPI } from '@/lib/api' ;
import { formatXP , getLevelEmoji , getCategoryIcon , getStatusStyle , timeAgo } from '@/lib/utils' ;
import { motion } from 'framer-motion' ;
import toast from 'react-hot-toast' ;
import {
Flame , Star , Target , Award , MapPin ,
Calendar , ExternalLink , Download , Share2 ,
CheckCircle , BookOpen , FileText , Users
} from 'lucide-react' ;
export default function PortfolioPage ( ) {
const { userId } = useParams ( ) ;
const [ portfolio , setPortfolio ] = useState ( null ) ;
const [ aiSummary , setAiSummary ] = useState ( null ) ;
const [ loading , setLoading ] = useState ( true ) ;
const [ activeTab , setActiveTab ] = useState ( 'overview' ) ;
useEffect ( ( ) => {
fetchPortfolio ( ) ;
} , [ userId ] ) ;
const fetchPortfolio = async ( ) => {
try {
const response = await portfolioAPI . get ( userId ) ;
if ( response . success ) {
setPortfolio ( response . data ) ;
}
} catch ( error ) {
console . error ( 'Fetch portfolio error:' , error ) ;
toast . error ( 'Portfolio not found' ) ;
} finally {
setLoading ( false ) ;
}
} ;
const generateSummary = async ( ) => {
try {
toast . loading ( 'Generating AI summary...' ) ;
const response = await portfolioAPI . generateSummary ( userId ) ;
if ( response . success ) {
setAiSummary ( response . data . summary ) ;
toast . dismiss ( ) ;
toast . success ( 'Summary generated!' ) ;
}
} catch ( error ) {
toast . dismiss ( ) ;
toast . error ( 'Failed to generate summary' ) ;
}
} ;
const handleShare = async ( ) => {
const url = `${ window . location . origin } /portfolio/${ userId } ` ;
if ( navigator . share ) {
await navigator . share ( {
title : `${ portfolio . profile . full_name } 's Civic Portfolio — CivicStreak` ,
text : `Check out my civic engagement portfolio on CivicStreak!` ,
url
} ) ;
} else {
await navigator . clipboard . writeText ( url ) ;
toast . success ( 'Portfolio link copied! 📋' ) ;
}
} ;
const handleDownloadCertificate = async ( ) => {
try {
const response = await portfolioAPI . getCertificate ( userId ) ;
if ( response . success ) {
// Generate certificate using browser (simplified)
generateCertificatePDF ( response . data ) ;
toast . success ( 'Certificate downloaded! 📜' ) ;
}
} catch ( error ) {
toast . error ( 'Failed to generate certificate' ) ;
}
} ;
if ( loading ) {
return (
< div className = "max-w-4xl mx-auto animate-pulse space-y-6" >
< div className = "h-48 bg-slate-800 rounded-2xl" > </ div >
< div className = "grid grid-cols-4 gap-4" >
{ [ ...Array ( 4 ) ] . map ( ( _ , i ) => (
< div key = { i } className = "h-24 bg-slate-800 rounded-2xl" > </ div >
) ) }
</ div >
< div className = "h-80 bg-slate-800 rounded-2xl" > </ div >
</ div >
) ;
}
if ( ! portfolio ) {
return (
< div className = "text-center py-20" >
< span className = "text-5xl" > 🔍</ span >
< h2 className = "text-xl font-bold mt-4" > Portfolio Not Found</ h2 >
</ div >
) ;
}
const { profile, completed_tasks, reported_issues, achievements, circles, skills, category_breakdown, streak_history } = portfolio ;
const tabs = [
{ key : 'overview' , label : '📊 Overview' } ,
{ key : 'tasks' , label : '✅ Tasks' } ,
{ key : 'issues' , label : '📢 Issues' } ,
{ key : 'achievements' , label : '🏅 Achievements' } ,
] ;
return (
< div className = "max-w-4xl mx-auto space-y-6" >
{ /* Profile Header Card */ }
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
className = "card bg-gradient-to-br from-indigo-950/40 to-purple-950/30 border-indigo-500/20"
>
< div className = "flex flex-col md:flex-row items-start md:items-center gap-6" >
{ /* Avatar */ }
< div className = "w-20 h-20 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center text-3xl font-bold flex-shrink-0" >
{ profile . avatar_url ? (
< img src = { profile . avatar_url } alt = { profile . full_name } className = "w-full h-full rounded-2xl object-cover" />
) : (
profile . full_name ?. charAt ( 0 ) ?. toUpperCase ( )
) }
</ div >
{ /* Info */ }
< div className = "flex-1" >
< h1 className = "text-2xl font-bold" > { profile . full_name } </ h1 >
< div className = "flex flex-wrap items-center gap-3 mt-2 text-sm" >
{ profile . wards && (
< span className = "flex items-center gap-1 text-slate-400" >
< MapPin size = { 14 } />
Ward { profile . wards . ward_number } — { profile . wards . ward_name }
</ span >
) }
{ profile . college && (
< span className = "flex items-center gap-1 text-slate-400" >
< BookOpen size = { 14 } />
{ profile . college }
</ span >
) }
< span className = "flex items-center gap-1 text-slate-400" >
< Calendar size = { 14 } />
Active for { profile . days_since_joining } days
</ span >
</ div >
< div className = "flex items-center gap-3 mt-3" >
< span className = "badge bg-indigo-500/20 text-indigo-400" >
{ getLevelEmoji ( profile . level ) } { profile . level }
</ span >
< span className = "badge bg-amber-500/20 text-amber-400" >
🔥 { profile . current_streak } day streak
</ span >
< span className = "badge bg-purple-500/20 text-purple-400" >
⭐ { formatXP ( profile . xp_points ) } XP
</ span >
</ div >
</ div >
{ /* Actions */ }
< div className = "flex gap-2 flex-shrink-0" >
< button onClick = { handleShare } className = "btn-secondary text-sm py-2 px-3 flex items-center gap-1" >
< Share2 size = { 14 } /> Share
</ button >
< button onClick = { handleDownloadCertificate } className = "btn-primary text-sm py-2 px-3 flex items-center gap-1" >
< Download size = { 14 } /> Certificate
</ button >
</ div >
</ div >
</ motion . div >
{ /* Impact Stats Grid */ }
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
transition = { { delay : 0.1 } }
className = "grid grid-cols-3 md:grid-cols-6 gap-3"
>
{ [
{ icon : '✅' , value : profile . tasks_completed , label : 'Tasks Done' } ,
{ icon : '📢' , value : profile . issues_reported , label : 'Issues Reported' } ,
{ icon : '✨' , value : profile . issues_resolved , label : 'Resolved' } ,
{ icon : '📜' , value : profile . rtis_filed , label : 'RTIs Filed' } ,
{ icon : '🏛️' , value : profile . meetings_attended , label : 'Meetings' } ,
{ icon : '🎓' , value : profile . people_mentored , label : 'Mentored' } ,
] . map ( ( stat , i ) => (
< div key = { i } className = "card text-center py-3" >
< span className = "text-lg" > { stat . icon } </ span >
< p className = "text-xl font-bold mt-1" > { stat . value } </ p >
< p className = "text-[10px] text-slate-400" > { stat . label } </ p >
</ div >
) ) }
</ motion . div >
{ /* AI Summary */ }
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
transition = { { delay : 0.15 } }
className = "card"
>
< div className = "flex items-center justify-between mb-3" >
< h2 className = "text-lg font-semibold" > 🤖 AI Portfolio Summary</ h2 >
< button
onClick = { generateSummary }
className = "btn-secondary text-xs py-1.5 px-3"
>
{ aiSummary ? 'Regenerate' : 'Generate' }
</ button >
</ div >
{ aiSummary ? (
< p className = "text-slate-300 leading-relaxed italic" >
"{ aiSummary } "
</ p >
) : (
< p className = "text-slate-500 text-sm" >
Click "Generate" to create an AI-powered summary of your civic engagement for college applications, resumes, and LinkedIn.
</ p >
) }
</ motion . div >
{ /* Skills Earned */ }
{ skills . length > 0 && (
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
transition = { { delay : 0.2 } }
className = "card"
>
< h2 className = "text-lg font-semibold mb-3" > 🛠️ Skills Earned</ h2 >
< div className = "flex flex-wrap gap-2" >
{ skills . map ( ( skill , i ) => (
< span
key = { i }
className = "badge bg-emerald-500/20 text-emerald-400 py-2 px-4"
>
{ skill . verified ? '✅' : '⬜' } { skill . name }
</ span >
) ) }
</ div >
</ motion . div >
) }
{ /* Tabs */ }
< div className = "flex gap-1 bg-slate-800 rounded-xl p-1 sticky top-4 z-10" >
{ tabs . map ( t => (
< button
key = { t . key }
onClick = { ( ) => setActiveTab ( t . key ) }
className = { `flex-1 py-2 px-3 rounded-lg text-sm font-medium transition-all ${
activeTab === t . key
? 'bg-indigo-600 text-white'
: 'text-slate-400 hover:text-white'
} `}
>
{ t . label }
</ button >
) ) }
</ div >
{ /* Tab Content */ }
< motion . div
key = { activeTab }
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
>
{ /* Overview Tab */ }
{ activeTab === 'overview' && (
< div className = "space-y-6" >
{ /* Category Breakdown */ }
< div className = "card" >
< h3 className = "font-semibold mb-4" > 📊 Task Category Breakdown</ h3 >
< div className = "space-y-3" >
{ Object . entries ( category_breakdown ) . map ( ( [ cat , count ] ) => {
const total = Object . values ( category_breakdown ) . reduce ( ( a , b ) => a + b , 0 ) ;
const percent = total > 0 ? ( count / total ) * 100 : 0 ;
return (
< div key = { cat } >
< div className = "flex items-center justify-between text-sm mb-1" >
< span > { getCategoryIcon ( cat ) } { cat } </ span >
< span className = "text-slate-400" > { count } tasks ({ percent . toFixed ( 0 ) } %)</ span >
</ div >
< div className = "w-full h-2 bg-slate-700 rounded-full overflow-hidden" >
< motion . div
className = "h-full bg-indigo-500 rounded-full"
initial = { { width : 0 } }
animate = { { width : `${ percent } %` } }
transition = { { duration : 0.8 } }
/>
</ div >
</ div >
) ;
} ) }
</ div >
</ div >
{ /* Activity Heatmap (Simplified) */ }
< div className = "card" >
< h3 className = "font-semibold mb-4" > 📅 Activity History (Last 30 Days)</ h3 >
< div className = "grid grid-cols-7 gap-1" >
{ Array . from ( { length : 30 } , ( _ , i ) => {
const date = new Date ( ) ;
date . setDate ( date . getDate ( ) - ( 29 - i ) ) ;
const dateStr = date . toISOString ( ) . split ( 'T' ) [ 0 ] ;
const activity = streak_history ?. find (
s => s . activity_date === dateStr
) ;
const intensity = activity
? Math . min ( activity . tasks_completed , 4 )
: 0 ;
const colors = [
'bg-slate-700' ,
'bg-indigo-900' ,
'bg-indigo-700' ,
'bg-indigo-500' ,
'bg-indigo-400'
] ;
return (
< div
key = { i }
className = { `aspect-square rounded-sm ${ colors [ intensity ] } transition-colors` }
title = { `${ dateStr } : ${ activity ?. tasks_completed || 0 } tasks` }
/>
) ;
} ) }
</ div >
< div className = "flex items-center gap-2 mt-3 justify-end text-xs text-slate-500" >
< span > Less</ span >
{ [ 'bg-slate-700' , 'bg-indigo-900' , 'bg-indigo-700' , 'bg-indigo-500' , 'bg-indigo-400' ] . map ( ( c , i ) => (
< div key = { i } className = { `w-3 h-3 rounded-sm ${ c } ` } />
) ) }
< span > More</ span >
</ div >
</ div >
{ /* Circles */ }
{ circles . length > 0 && (
< div className = "card" >
< h3 className = "font-semibold mb-3" > 🤝 Community Circles</ h3 >
< div className = "space-y-3" >
{ circles . map ( ( membership , i ) => (
< div key = { i } className = "flex items-center gap-3 p-3 rounded-xl bg-slate-700/30" >
< Users size = { 18 } className = "text-green-400" />
< div className = "flex-1" >
< p className = "text-sm font-medium" >
{ membership . circles ?. name }
</ p >
< p className = "text-xs text-slate-400" >
Role: { membership . role } • { membership . circles ?. wards ?. ward_name }
</ p >
</ div >
</ div >
) ) }
</ div >
</ div >
) }
</ div >
) }
{ /* Tasks Tab */ }
{ activeTab === 'tasks' && (
< div className = "card space-y-3" >
< h3 className = "font-semibold mb-2" >
Completed Tasks ({ completed_tasks . length } )
</ h3 >
{ completed_tasks . length > 0 ? (
completed_tasks . map ( ( task , i ) => (
< div key = { task . id } className = "flex items-start gap-3 p-3 rounded-xl bg-slate-700/30" >
< span className = "text-xl mt-0.5" >
{ getCategoryIcon ( task . micro_tasks ?. category ) }
</ span >
< div className = "flex-1 min-w-0" >
< p className = "text-sm font-medium" >
{ task . micro_tasks ?. title }
</ p >
< div className = "flex items-center gap-3 mt-1 text-xs text-slate-400" >
< span > { task . micro_tasks ?. category } </ span >
< span > +{ task . xp_earned } XP</ span >
< span > { timeAgo ( task . submitted_at ) } </ span >
</ div >
{ task . proof_text && (
< p className = "text-xs text-slate-500 mt-2 italic line-clamp-2" >
"{ task . proof_text } "
</ p >
) }
</ div >
{ task . proof_url && (
< img
src = { task . proof_url }
alt = "proof"
className = "w-16 h-16 rounded-lg object-cover flex-shrink-0"
/>
) }
</ div >
) )
) : (
< p className = "text-center text-slate-500 py-8 text-sm" >
No completed tasks yet.
</ p >
) }
</ div >
) }
{ /* Issues Tab */ }
{ activeTab === 'issues' && (
< div className = "card space-y-3" >
< h3 className = "font-semibold mb-2" >
Reported Issues ({ reported_issues . length } )
</ h3 >
{ reported_issues . length > 0 ? (
reported_issues . map ( ( issue , i ) => (
< div key = { issue . id } className = "flex items-center gap-3 p-3 rounded-xl bg-slate-700/30" >
< div className = "flex-1 min-w-0" >
< p className = "text-sm font-medium" > { issue . title } </ p >
< div className = "flex items-center gap-2 mt-1" >
< span className = { `badge text-[10px] ${ getStatusStyle ( issue . status ) } ` } >
{ issue . status . replace ( '_' , ' ' ) }
</ span >
< span className = "text-[10px] text-slate-500" >
{ issue . category }
</ span >
< span className = "text-[10px] text-slate-500" >
👍 { issue . upvotes }
</ span >
</ div >
</ div >
< span className = "text-xs text-slate-500" >
{ timeAgo ( issue . created_at ) }
</ span >
</ div >
) )
) : (
< p className = "text-center text-slate-500 py-8 text-sm" >
No reported issues yet.
</ p >
) }
</ div >
) }
{ /* Achievements Tab */ }
{ activeTab === 'achievements' && (
< div className = "card" >
< h3 className = "font-semibold mb-4" >
Achievements ({ achievements . length } )
</ h3 >
< div className = "grid grid-cols-2 md:grid-cols-3 gap-3" >
{ achievements . length > 0 ? (
achievements . map ( ( ua , i ) => (
< div key = { i } className = "p-4 rounded-xl bg-slate-700/30 text-center border border-slate-600/30" >
< span className = "text-3xl block mb-2" >
{ ua . achievements ?. icon }
</ span >
< p className = "text-sm font-semibold" >
{ ua . achievements ?. name }
</ p >
< p className = "text-[10px] text-slate-400 mt-1" >
{ ua . achievements ?. description }
</ p >
< p className = "text-[10px] text-slate-500 mt-2" >
Earned { timeAgo ( ua . earned_at ) }
</ p >
</ div >
) )
) : (
< div className = "col-span-full text-center py-8" >
< Award size = { 48 } className = "mx-auto text-slate-600 mb-3" />
< p className = "text-sm text-slate-500" >
Complete tasks to earn achievements!
</ p >
</ div >
) }
</ div >
</ div >
) }
</ motion . div >
</ div >
) ;
}
/**
* Simple certificate generator (creates downloadable HTML)
*/
function generateCertificatePDF ( data ) {
const html = `
<!DOCTYPE html>
<html>
<head><title>CivicStreak Certificate</title></head>
<body style="font-family: Georgia, serif; text-align: center; padding: 60px; border: 4px double #4f46e5; margin: 20px; min-height: 600px;">
<div style="border: 2px solid #e5e7eb; padding: 40px;">
<h1 style="color: #4f46e5; font-size: 36px; margin-bottom: 5px;">🏙️ CivicStreak</h1>
<p style="color: #6b7280; font-size: 14px;">Certificate of Civic Engagement</p>
<hr style="margin: 30px auto; width: 200px; border-color: #e5e7eb;">
<p style="font-size: 16px; color: #6b7280;">This is to certify that</p>
<h2 style="font-size: 32px; color: #1f2937; margin: 10px 0;">${ data . name } </h2>
<p style="font-size: 16px; color: #6b7280;">has demonstrated outstanding civic engagement as a</p>
<h3 style="font-size: 24px; color: #4f46e5; margin: 10px 0;">${ data . level } </h3>
<p style="font-size: 14px; color: #6b7280;">Ward: ${ data . ward } | City: ${ data . city } </p>
<div style="margin: 30px auto; max-width: 400px; text-align: left; font-size: 13px; color: #374151;">
<p>⭐ XP Points: ${ data . xp_points } | 🔥 Longest Streak: ${ data . longest_streak } days</p>
<p>✅ Tasks Completed: ${ data . tasks_completed } | 📢 Issues Reported: ${ data . issues_reported } </p>
<p>✨ Issues Resolved: ${ data . issues_resolved } | 📅 Days Active: ${ data . days_active } </p>
</div>
<hr style="margin: 30px auto; width: 200px; border-color: #e5e7eb;">
<p style="font-size: 12px; color: #9ca3af;">Certificate ID: ${ data . certificate_id } </p>
<p style="font-size: 12px; color: #9ca3af;">Issued: ${ new Date ( data . issued_date ) . toLocaleDateString ( ) } </p>
<p style="font-size: 11px; color: #9ca3af; margin-top: 20px;">Verify at: CivicStreak.vercel.app/verify/${ data . certificate_id } </p>
</div>
</body>
</html>` ;
const blob = new Blob ( [ html ] , { type : 'text/html' } ) ;
const url = URL . createObjectURL ( blob ) ;
const a = document . createElement ( 'a' ) ;
a . href = url ;
a . download = `CivicStreak_Certificate_${ data . name . replace ( / \s / g, '_' ) } .html` ;
a . click ( ) ;
URL . revokeObjectURL ( url ) ;
}
client/src/app/leaderboard/page.js
'use client' ;
// ====================================
// Global Leaderboard Page
// ====================================
import { useState , useEffect } from 'react' ;
import { wardAPI } from '@/lib/api' ;
import { useAuth } from '@/context/AuthContext' ;
import { formatXP , getLevelEmoji } from '@/lib/utils' ;
import { motion } from 'framer-motion' ;
import Link from 'next/link' ;
import { Trophy , Flame , Crown , Medal } from 'lucide-react' ;
export default function LeaderboardPage ( ) {
const { profile } = useAuth ( ) ;
const [ leaderboard , setLeaderboard ] = useState ( [ ] ) ;
const [ loading , setLoading ] = useState ( true ) ;
useEffect ( ( ) => {
fetchLeaderboard ( ) ;
} , [ ] ) ;
const fetchLeaderboard = async ( ) => {
try {
const response = await wardAPI . getLeaderboard ( ) ;
if ( response . success ) {
setLeaderboard ( response . data ) ;
}
} catch ( error ) {
console . error ( 'Fetch leaderboard error:' , error ) ;
} finally {
setLoading ( false ) ;
}
} ;
const getRankIcon = ( rank ) => {
if ( rank === 1 ) return '🥇' ;
if ( rank === 2 ) return '🥈' ;
if ( rank === 3 ) return '🥉' ;
return `${ rank } .` ;
} ;
return (
< div className = "space-y-6" >
< div >
< h1 className = "text-2xl font-bold flex items-center gap-2" >
< Trophy className = "text-amber-400" /> Leaderboard
</ h1 >
< p className = "text-slate-400 mt-1" >
Top CivicStreaks making a civic impact
</ p >
</ div >
{ /* Top 3 Podium */ }
{ ! loading && leaderboard . length >= 3 && (
< motion . div
initial = { { opacity : 0 , y : 10 } }
animate = { { opacity : 1 , y : 0 } }
className = "grid grid-cols-3 gap-4 items-end"
>
{ /* 2nd Place */ }
< PodiumCard user = { leaderboard [ 1 ] } rank = { 2 } height = "h-36" />
{ /* 1st Place */ }
< PodiumCard user = { leaderboard [ 0 ] } rank = { 1 } height = "h-44" highlight />
{ /* 3rd Place */ }
< PodiumCard user = { leaderboard [ 2 ] } rank = { 3 } height = "h-28" />
</ motion . div >
) }
{ /* Full List */ }
< div className = "card" >
< div className = "flex items-center justify-between mb-4" >
< h2 className = "font-semibold" > All Rankings</ h2 >
< span className = "text-xs text-slate-400" >
{ leaderboard . length } CivicStreaks
</ span >
</ div >
{ loading ? (
< div className = "space-y-3" >
{ [ ...Array ( 10 ) ] . map ( ( _ , i ) => (
< div key = { i } className = "h-14 bg-slate-700/50 rounded-xl animate-pulse" > </ div >
) ) }
</ div >
) : (
< div className = "space-y-2" >
{ leaderboard . map ( ( user , i ) => {
const isCurrentUser = user . id === profile ?. id ;
return (
< motion . div
key = { user . id }
initial = { { opacity : 0 , x : - 10 } }
animate = { { opacity : 1 , x : 0 } }
transition = { { delay : i * 0.03 } }
>
< Link
href = { `/portfolio/${ user . id } ` }
className = { `flex items-center gap-3 p-3 rounded-xl transition-all ${
isCurrentUser
? 'bg-indigo-500/10 border border-indigo-500/30'
: 'bg-slate-700/20 hover:bg-slate-700/40'
} `}
>
{ /* Rank */ }
< span className = "text-lg font-bold w-8 text-center flex-shrink-0" >
{ getRankIcon ( user . rank ) }
</ span >
{ /* Avatar */ }
< div className = { `w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0 ${
isCurrentUser ? 'bg-indigo-600' : 'bg-slate-600'
} `} >
{ user . avatar_url ? (
< img src = { user . avatar_url } alt = "" className = "w-full h-full rounded-full object-cover" />
) : (
user . full_name ?. charAt ( 0 ) ?. toUpperCase ( )
) }
</ div >
{ /* Info */ }
< div className = "flex-1 min-w-0" >
< p className = "text-sm font-medium truncate" >
{ user . full_name }
{ isCurrentUser && (
< span className = "text-indigo-400 ml-2 text-xs" > (You)</ span >
) }
</ p >
< p className = "text-[10px] text-slate-400" >
{ getLevelEmoji ( user . level ) } { user . level }
{ user . wards ?. ward_name && ` • ${ user . wards . ward_name } ` }
</ p >
</ div >
{ /* Stats */ }
< div className = "flex items-center gap-4 flex-shrink-0" >
< div className = "text-center hidden md:block" >
< div className = "flex items-center gap-1 text-amber-400" >
< Flame size = { 12 } />
< span className = "text-xs font-bold" > { user . current_streak } </ span >
</ div >
< p className = "text-[9px] text-slate-500" > streak</ p >
</ div >
< div className = "text-center hidden md:block" >
< span className = "text-xs font-medium text-slate-400" >
{ user . tasks_completed }
</ span >
< p className = "text-[9px] text-slate-500" > tasks</ p >
</ div >
< div className = "text-right" >
< span className = "text-sm font-bold text-indigo-400" >
{ formatXP ( user . xp_points ) }
</ span >
< p className = "text-[9px] text-slate-500" > XP</ p >
</ div >
</ div >
</ Link >
</ motion . div >
) ;
} ) }
</ div >
) }
</ div >
</ div >
) ;
}
function PodiumCard ( { user, rank, height, highlight } ) {
return (
< div className = { `card text-center ${ height } flex flex-col justify-end items-center pb-4 ${
highlight ? 'border-amber-500/30 bg-gradient-to-b from-amber-950/20 to-slate-800/50' : ''
} `} >
< div className = "text-2xl mb-1" > { rank === 1 ? '👑' : rank === 2 ? '🥈' : '🥉' } </ div >
< div className = { `w-12 h-12 rounded-full flex items-center justify-center text-sm font-bold mb-2 ${
highlight ? 'bg-amber-600' : 'bg-slate-600'
} `} >
{ user ?. full_name ?. charAt ( 0 ) ?. toUpperCase ( ) }
</ div >
< p className = "text-sm font-semibold truncate w-full px-2" >
{ user ?. full_name }
</ p >
< p className = "text-sm font-bold text-indigo-400 mt-1" >
{ formatXP ( user ?. xp_points ) } XP
</ p >
< p className = "text-[10px] text-amber-400" >
🔥 { user ?. current_streak } days
</ p >
</ div >
) ;
}