diff --git a/.env b/.env index 6c839da..2055dc5 100644 --- a/.env +++ b/.env @@ -1 +1,5 @@ -CILOGON_CLIENT_IDENTIFIER=cilogon:/client_id/3797793e01ddfd2ebf80aebebbdd4626 \ No newline at end of file +# Created by Vercel CLI +NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InFhcmpobGF3cnVvaXpnc3dnbnRnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDM2Mjg1NDAsImV4cCI6MjA1OTIwNDU0MH0.HZHTUP6Fd3s3mdS0OzChT-40t9QSs1YI-8MxmcJmji0" +NEXT_PUBLIC_SUPABASE_URL="https://qarjhlawruoizgswgntg.supabase.co" +PRIVATE_EVENTS_MANAGE_KEY="cs124hfa25A" +VERCEL_OIDC_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im1yay00MzAyZWMxYjY3MGY0OGE5OGFkNjFkYWRlNGEyM2JlNyJ9.eyJpc3MiOiJodHRwczovL29pZGMudmVyY2VsLmNvbS9jczEyNGhvbm9ycy1jb2RlcnMtcHJvamVjdHMiLCJzdWIiOiJvd25lcjpjczEyNGhvbm9ycy1jb2RlcnMtcHJvamVjdHM6cHJvamVjdDpjczEyNGgtc2l0ZTplbnZpcm9ubWVudDpkZXZlbG9wbWVudCIsInNjb3BlIjoib3duZXI6Y3MxMjRob25vcnMtY29kZXJzLXByb2plY3RzOnByb2plY3Q6Y3MxMjRoLXNpdGU6ZW52aXJvbm1lbnQ6ZGV2ZWxvcG1lbnQiLCJhdWQiOiJodHRwczovL3ZlcmNlbC5jb20vY3MxMjRob25vcnMtY29kZXJzLXByb2plY3RzIiwib3duZXIiOiJjczEyNGhvbm9ycy1jb2RlcnMtcHJvamVjdHMiLCJvd25lcl9pZCI6InRlYW1fMVNNUjU5QlRSNkJPTWoxbW9xbnQzQ0kwIiwicHJvamVjdCI6ImNzMTI0aC1zaXRlIiwicHJvamVjdF9pZCI6InByal9XVHMwU2VFWWpQRDhnVnFrN291WHE5elFCSGw2IiwiZW52aXJvbm1lbnQiOiJkZXZlbG9wbWVudCIsInBsYW4iOiJob2JieSIsInVzZXJfaWQiOiJ4VFY2b2ltSW8xc1pvWlRRcjZUcnpQZEsiLCJuYmYiOjE3NjM0MTcxMTUsImlhdCI6MTc2MzQxNzExNSwiZXhwIjoxNzYzNDYwMzE1fQ.fEEZSsoMmJACSNMTuMTWHSa7fXb1pmd9JWdgyg6H2sAtGx2KGAfkb_aPBcQWUZ4vlDL1lDZN3Yd1DbhV__3ifDOCRozkXv7yv15Bc3UfAEnPzlOXabhnSg16ZKOtb0AGbjQYM3hGddMb7w4ZhtfRywOvi7e5zPqGt0s8qtV0pcHvDGeAFzBaYXoijyob4nQFX4KkcfdG2zUQhAQapJeGhtRkRdVYk-uy5b7jJ3QE_hfA_oZSRlsjHsqdE8NUPD0oTdkevJFxd7VUr4Q8cQwaPnZb7eps_3V7xp0Ik3j903NSybfww3MWAvslkuo0G_960dKQMZBGvwsJCGGEHvA1rQ" diff --git a/.gitignore b/.gitignore index 0ae2628..673137a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ pnpm-debug.log* .DS_Store .idea/ .vscode/ +.vercel +.env*.local diff --git a/app/api/auth/admin/route.js b/app/api/auth/admin/route.js new file mode 100644 index 0000000..af914e0 --- /dev/null +++ b/app/api/auth/admin/route.js @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server'; + +/** + * POST /api/auth/admin + * Validates admin code + * Body: { admin_code } + */ +export async function POST(request) { + try { + const body = await request.json(); + const { admin_code } = body; + + // Get admin code from environment variable + const ADMIN_CODE = process.env.PRIVATE_EVENTS_MANAGE_KEY; + + if (!admin_code) { + return NextResponse.json( + { error: 'Admin code is required' }, + { status: 400 } + ); + } + + if (admin_code === ADMIN_CODE) { + // Generate a simple session token (in production, use JWT or proper session management) + const sessionToken = Buffer.from(`${admin_code}_${Date.now()}`).toString('base64'); + + return NextResponse.json( + { + success: true, + message: 'Admin authentication successful', + token: sessionToken + }, + { status: 200 } + ); + } else { + return NextResponse.json( + { error: 'Invalid admin code' }, + { status: 401 } + ); + } + } catch (error) { + console.error('Admin auth error:', error); + return NextResponse.json( + { error: 'Internal server error', details: error.message }, + { status: 500 } + ); + } +} diff --git a/app/api/events/[id]/route.js b/app/api/events/[id]/route.js new file mode 100644 index 0000000..a5e4ee0 --- /dev/null +++ b/app/api/events/[id]/route.js @@ -0,0 +1,138 @@ +import { NextResponse } from 'next/server'; +import { supabase } from '../../../../lib/supbaseClient'; + +/** + * GET /api/events/[id] + * Get a single event by ID + */ +export async function GET(request, { params }) { + try { + const { id } = await params; + + const { data: event, error } = await supabase + .from('events') + .select('*') + .eq('id', id) + .single(); + + if (error || !event) { + return NextResponse.json( + { error: 'Event not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ event }, { status: 200 }); + } catch (error) { + console.error('Error fetching event:', error); + return NextResponse.json( + { error: 'Internal server error', details: error.message }, + { status: 500 } + ); + } +} + +/** + * PUT /api/events/[id] + * Update an event + * Body: { title, description, location, presenter, start_time, end_time, point_value, qr_code_secret, join_link } + */ +export async function PUT(request, { params }) { + try { + const { id } = await params; + const body = await request.json(); + const { + title, + description, + location, + presenter, + start_time, + end_time, + point_value, + qr_code_secret, + join_link, + is_active + } = body; + + // Validate required fields + if (!title || !start_time || !end_time) { + return NextResponse.json( + { error: 'Missing required fields: title, start_time, end_time' }, + { status: 400 } + ); + } + + const updateData = { + title, + description, + location, + presenter, + start_time, + end_time, + point_value, + qr_code_secret, + join_link + }; + + if (is_active !== undefined) { + updateData.is_active = is_active; + } + + const { data: event, error } = await supabase + .from('events') + .update(updateData) + .eq('id', id) + .select() + .single(); + + if (error) { + console.error('Error updating event:', error); + return NextResponse.json( + { error: 'Failed to update event', details: error.message }, + { status: 500 } + ); + } + + return NextResponse.json({ event }, { status: 200 }); + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error', details: error.message }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/events/[id] + * Delete an event + */ +export async function DELETE(request, { params }) { + try { + const { id } = await params; + + const { error } = await supabase + .from('events') + .delete() + .eq('id', id); + + if (error) { + console.error('Error deleting event:', error); + return NextResponse.json( + { error: 'Failed to delete event', details: error.message }, + { status: 500 } + ); + } + + return NextResponse.json( + { success: true, message: 'Event deleted successfully' }, + { status: 200 } + ); + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error', details: error.message }, + { status: 500 } + ); + } +} diff --git a/app/api/events/checkin/route.js b/app/api/events/checkin/route.js new file mode 100644 index 0000000..e26e3ce --- /dev/null +++ b/app/api/events/checkin/route.js @@ -0,0 +1,207 @@ +import { NextResponse } from 'next/server'; +import { supabase } from '../../../../lib/supbaseClient'; + +/** + * POST /api/events/checkin + * Handles student check-in to an event + * Body: { event_id, net_id, qr_code_secret, student_name, student_email } + */ +export async function POST(request) { + try { + const body = await request.json(); + const { event_id, net_id, qr_code_secret, student_name, student_email } = body; + + // Validate required fields + if (!event_id || !net_id || !qr_code_secret) { + return NextResponse.json( + { error: 'Missing required fields: event_id, net_id, qr_code_secret' }, + { status: 400 } + ); + } + + // 1. Verify the event exists and QR code is correct + const { data: event, error: eventError } = await supabase + .from('events') + .select('*') + .eq('id', event_id) + .eq('qr_code_secret', qr_code_secret) + .single(); + + if (eventError || !event) { + return NextResponse.json( + { error: 'Invalid event or QR code' }, + { status: 404 } + ); + } + + // 2. Check if event is active and hasn't ended + const now = new Date(); + const eventEnd = new Date(event.end_time); + + if (!event.is_active) { + return NextResponse.json( + { error: 'This event is not active' }, + { status: 400 } + ); + } + + if (eventEnd < now) { + return NextResponse.json( + { error: 'This event has already ended' }, + { status: 400 } + ); + } + + // 3. Check if student has already checked in + const { data: existingCheckin } = await supabase + .from('event_checkins') + .select('*') + .eq('event_id', event_id) + .eq('net_id', net_id) + .single(); + + if (existingCheckin) { + return NextResponse.json( + { + error: 'Already checked in', + message: 'You have already checked into this event', + checkin: existingCheckin + }, + { status: 409 } // Conflict + ); + } + + // 4. Create check-in record + const { data: checkin, error: checkinError } = await supabase + .from('event_checkins') + .insert([ + { + event_id, + net_id, + } + ]) + .select() + .single(); + + if (checkinError) { + console.error('Error creating check-in:', checkinError); + return NextResponse.json( + { error: 'Failed to check in', details: checkinError.message }, + { status: 500 } + ); + } + + // 5. Update checked_in_students JSONB array + const currentCheckedInStudents = event.checked_in_students || []; + const newStudent = { + student_name: student_name || 'Unknown', + student_netid: net_id, + student_email: student_email || '', + checked_in_at: new Date().toISOString() + }; + + const updatedCheckedInStudents = [...currentCheckedInStudents, newStudent]; + + const { error: updateEventError } = await supabase + .from('events') + .update({ checked_in_students: updatedCheckedInStudents }) + .eq('id', event_id); + + if (updateEventError) { + console.warn('Warning: Failed to update checked_in_students:', updateEventError.message); + // Don't fail the check-in if this update fails + } + + // 6. Optional: Add points to attendance_sheet table + // (Integrate with existing leaderboard system) + const { error: attendanceError } = await supabase + .from('attendance_sheet') + .insert([ + { + net_id, + point_value: event.point_value + } + ]); + + if (attendanceError) { + console.warn('Warning: Failed to update attendance_sheet:', attendanceError.message); + // Don't fail the check-in if attendance sheet update fails + } + + return NextResponse.json( + { + success: true, + message: `Successfully checked in! You earned ${event.point_value} points.`, + checkin, + points_earned: event.point_value, + event: { + title: event.title, + location: event.location, + presenter: event.presenter + } + }, + { status: 201 } + ); + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error', details: error.message }, + { status: 500 } + ); + } +} + +/** + * GET /api/events/checkin?event_id={id} + * Get all check-ins for a specific event + */ +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const event_id = searchParams.get('event_id'); + + if (!event_id) { + return NextResponse.json( + { error: 'Missing event_id parameter' }, + { status: 400 } + ); + } + + // Fetch check-ins with group information + const { data: checkins, error } = await supabase + .from('event_checkins') + .select(` + *, + groups:net_id ( + net_id, + group_number, + group_name + ) + `) + .eq('event_id', event_id) + .order('checked_in_at', { ascending: false }); + + if (error) { + console.error('Error fetching check-ins:', error); + return NextResponse.json( + { error: 'Failed to fetch check-ins', details: error.message }, + { status: 500 } + ); + } + + return NextResponse.json( + { + event_id, + total_checkins: checkins.length, + checkins + }, + { status: 200 } + ); + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error', details: error.message }, + { status: 500 } + ); + } +} diff --git a/app/api/events/route.js b/app/api/events/route.js new file mode 100644 index 0000000..bf6b872 --- /dev/null +++ b/app/api/events/route.js @@ -0,0 +1,113 @@ +import { NextResponse } from 'next/server'; +import { supabase } from '../../../lib/supbaseClient'; + +/** + * GET /api/events + * Fetches all events from Supabase + * Query params: + * - upcoming: true/false (filter by upcoming events) + * - past: true/false (filter by past events) + */ +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const upcoming = searchParams.get('upcoming'); + const past = searchParams.get('past'); + + let query = supabase + .from('events') + .select('*') + .order('start_time', { ascending: true }); + + // Filter by upcoming or past events + const now = new Date().toISOString(); + + if (upcoming === 'true') { + query = query.gt('start_time', now); + } else if (past === 'true') { + query = query.lt('start_time', now); + } + + const { data: events, error } = await query; + + if (error) { + console.error('Error fetching events:', error); + return NextResponse.json( + { error: 'Failed to fetch events', details: error.message }, + { status: 500 } + ); + } + + return NextResponse.json({ events }, { status: 200 }); + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error', details: error.message }, + { status: 500 } + ); + } +} + +/** + * POST /api/events + * Creates a new event + * Body: { title, description, location, presenter, start_time, end_time, point_value, qr_code_secret, join_link } + */ +export async function POST(request) { + try { + const body = await request.json(); + const { + title, + description, + location, + presenter, + start_time, + end_time, + point_value = 10, + qr_code_secret, + join_link + } = body; + + // Validate required fields + if (!title || !start_time || !end_time) { + return NextResponse.json( + { error: 'Missing required fields: title, start_time, end_time' }, + { status: 400 } + ); + } + + const { data: event, error } = await supabase + .from('events') + .insert([ + { + title, + description, + location, + presenter, + start_time, + end_time, + point_value, + qr_code_secret, + join_link + } + ]) + .select() + .single(); + + if (error) { + console.error('Error creating event:', error); + return NextResponse.json( + { error: 'Failed to create event', details: error.message }, + { status: 500 } + ); + } + + return NextResponse.json({ event }, { status: 201 }); + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error', details: error.message }, + { status: 500 } + ); + } +} diff --git a/app/events/Events.module.css b/app/events/Events.module.css new file mode 100644 index 0000000..7b67bdd --- /dev/null +++ b/app/events/Events.module.css @@ -0,0 +1,538 @@ +/* Events Page Styles - Following CS124H Design System */ + +.pageContainer { + background-color: #1e3877; + min-height: 100vh; + width: 100%; +} + +.mainContent { + padding: 2rem; + max-width: 1400px; + margin: 0 auto; +} + +.headerContainer { + display: flex; + justify-content: center; + align-items: center; + position: relative; + margin-bottom: 2rem; +} + +.header { + text-align: center; + flex: 1; + transition: opacity 0.3s ease; +} + +.header h1 { + color: #f9f9f9; + font-size: 3rem; + font-family: 'Kufam', sans-serif; + margin: 0; + transition: opacity 0.3s ease; +} + +.headerBlurred { + opacity: 0.2; + pointer-events: none; +} + +.manageButton { + position: absolute; + right: 0; + background-color: #ff9a35; + color: #112a67; + border: 2px solid #f9f9f9; + padding: 0.75rem 1.5rem; + border-radius: 0.5rem; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + transition: all 0.3s ease; + font-family: 'Inter', sans-serif; +} + +.manageButton:hover { + background-color: #f67b00; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +/* Tabs */ +.tabsContainer { + display: flex; + justify-content: center; + gap: 1rem; + margin-bottom: 2rem; + flex-wrap: wrap; +} + +.tabButton { + background-color: #4f8dde; + color: #f9f9f9; + border: none; + padding: 0.75rem 2rem; + border-radius: 0.5rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + font-family: 'Inter', sans-serif; +} + +.tabButton:hover { + background-color: #3b82f6; + transform: translateY(-2px); +} + +.activeTab { + background-color: #2563eb; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); +} + +/* Loading and Empty States */ +.loadingContainer, +.emptyContainer { + text-align: center; + padding: 4rem 2rem; + color: #f9f9f9; + font-size: 1.2rem; + font-family: 'Inter', sans-serif; +} + +/* Events Grid */ +.eventsGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 2rem; + padding: 1rem; + transition: opacity 0.3s ease; +} + +.gridBlurred { + opacity: 0.2; + pointer-events: none; +} + +/* Event Cards */ +.eventCard { + background-color: #ff9a35; + border-radius: 1.5rem; + padding: 1.75rem; + border: 2px solid #f9f9f9; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + flex-direction: column; + gap: 1rem; + min-height: 280px; +} + +.eventCard:hover { + background-color: #f67b00; + transform: scale(1.03); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); +} + +.eventCardHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.eventCardHeader h3 { + color: #112a67; + font-size: 1.4rem; + font-weight: 700; + margin: 0; + line-height: 1.3; + font-family: 'Inter', sans-serif; + flex: 1; +} + +.pointsBadge { + background-color: #112a67; + color: #f9f9f9; + padding: 0.4rem 0.8rem; + border-radius: 1rem; + font-size: 0.85rem; + font-weight: 700; + white-space: nowrap; + font-family: 'Inter', sans-serif; +} + +.eventCardBody { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.eventDetail { + display: flex; + align-items: center; + gap: 0.6rem; + color: #112a67; + font-size: 0.95rem; + font-family: 'Inter', sans-serif; +} + +.eventDetail svg { + flex-shrink: 0; +} + +.eventDescription { + color: #112a67; + font-size: 0.95rem; + line-height: 1.5; + margin: 0.5rem 0 0; + font-family: 'Inter', sans-serif; +} + +/* Modal */ +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.6); + z-index: 9999; + display: flex; + justify-content: center; + align-items: center; + padding: 1rem; +} + +.modalContent { + background-color: #ff9a35; + border-radius: 1.5rem; + max-width: 700px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + padding: 2.5rem; + position: relative; + border: 3px solid #f9f9f9; +} + +.closeButton { + position: absolute; + top: 1rem; + right: 1rem; + background: #112a67; + border: none; + color: #f9f9f9; + font-size: 2rem; + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + line-height: 1; + padding: 0; +} + +.closeButton:hover { + background: #0d1f4d; + transform: rotate(90deg); +} + +.modalHeader { + margin-bottom: 2rem; + padding-right: 3rem; +} + +.modalHeader h2 { + color: #112a67; + font-size: 2rem; + font-weight: 700; + margin: 0 0 1rem 0; + font-family: 'Inter', sans-serif; +} + +.pointsBadgeLarge { + background-color: #112a67; + color: #f9f9f9; + padding: 0.5rem 1.2rem; + border-radius: 1.5rem; + font-size: 1rem; + font-weight: 700; + display: inline-block; + font-family: 'Inter', sans-serif; +} + +.modalBody { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.eventInfo { + display: flex; + flex-direction: column; + gap: 1.2rem; +} + +.infoRow { + display: flex; + align-items: flex-start; + gap: 1rem; + color: #112a67; +} + +.infoRow svg { + flex-shrink: 0; + margin-top: 0.2rem; +} + +.infoRow strong { + display: block; + margin-bottom: 0.25rem; + font-size: 1rem; + font-family: 'Inter', sans-serif; +} + +.infoRow p { + margin: 0; + font-size: 1rem; + line-height: 1.4; + font-family: 'Inter', sans-serif; +} + +.descriptionSection { + background-color: rgba(17, 42, 103, 0.1); + padding: 1.5rem; + border-radius: 1rem; +} + +.descriptionSection h3 { + color: #112a67; + font-size: 1.3rem; + margin: 0 0 1rem 0; + font-weight: 700; + font-family: 'Inter', sans-serif; +} + +.descriptionSection p { + color: #112a67; + line-height: 1.6; + margin: 0; + font-size: 1rem; + font-family: 'Inter', sans-serif; +} + +.modalActions { + display: flex; + flex-direction: column; + gap: 0.75rem; + align-items: center; +} + +.checkInButton { + background-color: #112a67; + color: #f9f9f9; + border: none; + padding: 1rem 2.5rem; + border-radius: 0.75rem; + font-size: 1.1rem; + font-weight: 700; + cursor: pointer; + transition: all 0.3s ease; + font-family: 'Inter', sans-serif; + width: 100%; + max-width: 400px; + text-decoration: none; + display: inline-block; + text-align: center; +} + +.checkInButton:hover { + background-color: #0d1f4d; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +.checkInButton:disabled { + background-color: #6b7280; + cursor: not-allowed; + opacity: 0.6; +} + +.checkInButton:disabled:hover { + transform: none; + box-shadow: none; +} + +.checkInNote { + color: #112a67; + font-size: 0.9rem; + margin: 0; + font-style: italic; + font-family: 'Inter', sans-serif; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .mainContent { + padding: 1rem; + } + + .header h1 { + font-size: 2rem; + } + + .eventsGrid { + grid-template-columns: 1fr; + gap: 1.5rem; + } + + .modalContent { + padding: 2rem 1.5rem; + max-height: 95vh; + } + + .modalHeader h2 { + font-size: 1.5rem; + } + + .tabButton { + padding: 0.6rem 1.5rem; + font-size: 0.9rem; + } +} + +@media (max-width: 480px) { + .header h1 { + font-size: 1.75rem; + } + + .eventCardHeader h3 { + font-size: 1.2rem; + } + + .modalContent { + padding: 1.5rem; + } + + .closeButton { + width: 2rem; + height: 2rem; + font-size: 1.5rem; + } + + .manageButton { + padding: 0.5rem 1rem; + font-size: 0.9rem; + } +} + +/* Admin Modal Styles */ +.adminModalContent { + background-color: #ff9a35; + border-radius: 1.5rem; + max-width: 500px; + width: 100%; + padding: 2.5rem; + position: relative; + border: 3px solid #f9f9f9; +} + +.adminModalHeader { + margin-bottom: 2rem; + padding-right: 3rem; +} + +.adminModalHeader h2 { + color: #112a67; + font-size: 2rem; + font-weight: 700; + margin: 0 0 0.5rem 0; + font-family: 'Inter', sans-serif; +} + +.adminModalHeader p { + color: #112a67; + font-size: 1rem; + margin: 0; + font-family: 'Inter', sans-serif; +} + +.adminForm { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.formGroup { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.formGroup label { + color: #112a67; + font-weight: 600; + font-size: 1rem; + font-family: 'Inter', sans-serif; +} + +.adminInput { + padding: 0.75rem 1rem; + border: 2px solid #112a67; + border-radius: 0.5rem; + font-size: 1rem; + font-family: 'Inter', sans-serif; + background-color: #f9f9f9; + color: #112a67; + transition: all 0.3s ease; +} + +.adminInput:focus { + outline: none; + border-color: #0d1f4d; + box-shadow: 0 0 0 3px rgba(17, 42, 103, 0.1); +} + +.adminInput::placeholder { + color: #6b7280; +} + +.errorMessage { + background-color: rgba(220, 38, 38, 0.1); + border: 2px solid #dc2626; + color: #dc2626; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + font-size: 0.9rem; + font-weight: 600; + font-family: 'Inter', sans-serif; +} + +.adminSubmitButton { + background-color: #112a67; + color: #f9f9f9; + border: none; + padding: 1rem 2rem; + border-radius: 0.75rem; + font-size: 1.1rem; + font-weight: 700; + cursor: pointer; + transition: all 0.3s ease; + font-family: 'Inter', sans-serif; +} + +.adminSubmitButton:hover { + background-color: #0d1f4d; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +.adminSubmitButton:active { + transform: translateY(0); +} diff --git a/app/events/checkin/Checkin.module.css b/app/events/checkin/Checkin.module.css new file mode 100644 index 0000000..7588851 --- /dev/null +++ b/app/events/checkin/Checkin.module.css @@ -0,0 +1,311 @@ +/* Check-In Page Styles */ + +.pageContainer { + background-color: #1e3877; + min-height: 100vh; + width: 100%; +} + +.mainContent { + padding: 2rem; + max-width: 800px; + margin: 0 auto; +} + +/* Loading State */ +.loadingContainer { + text-align: center; + padding: 4rem 2rem; + color: #f9f9f9; + font-size: 1.2rem; + font-family: 'Inter', sans-serif; +} + +/* Error State */ +.errorContainer { + background-color: #ff9a35; + border-radius: 1.5rem; + padding: 3rem 2rem; + text-align: center; + border: 3px solid #f9f9f9; +} + +.errorContainer h1 { + color: #112a67; + font-size: 2rem; + font-weight: 700; + margin: 0 0 1rem 0; + font-family: 'Kufam', sans-serif; +} + +.errorMessage { + color: #dc2626; + font-size: 1.1rem; + font-weight: 600; + margin: 1rem 0 2rem 0; + font-family: 'Inter', sans-serif; +} + +.backButton { + background-color: #112a67; + color: #f9f9f9; + border: none; + padding: 1rem 2rem; + border-radius: 0.75rem; + font-size: 1.1rem; + font-weight: 700; + cursor: pointer; + transition: all 0.3s ease; + font-family: 'Inter', sans-serif; +} + +.backButton:hover { + background-color: #0d1f4d; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +/* Success State */ +.successContainer { + background-color: #ff9a35; + border-radius: 1.5rem; + padding: 3rem 2rem; + text-align: center; + border: 3px solid #f9f9f9; +} + +.successIcon { + width: 100px; + height: 100px; + background-color: #22c55e; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 4rem; + color: #ffffff; + margin: 0 auto 2rem auto; + font-weight: bold; +} + +.successContainer h1 { + color: #112a67; + font-size: 2.5rem; + font-weight: 700; + margin: 0 0 1rem 0; + font-family: 'Kufam', sans-serif; +} + +.successMessage { + color: #112a67; + font-size: 1.2rem; + font-weight: 600; + margin: 1rem 0; + font-family: 'Inter', sans-serif; +} + +.successMessage strong { + color: #112a67; + font-weight: 700; +} + +.redirectMessage { + color: #112a67; + font-size: 1rem; + margin-top: 2rem; + font-family: 'Inter', sans-serif; + opacity: 0.8; +} + +/* Check-In Container */ +.checkinContainer { + background-color: #ff9a35; + border-radius: 1.5rem; + padding: 2.5rem; + border: 3px solid #f9f9f9; +} + +/* Event Info */ +.eventInfo { + margin-bottom: 2rem; +} + +.eventInfo h1 { + color: #112a67; + font-size: 2rem; + font-weight: 700; + margin: 0 0 1.5rem 0; + font-family: 'Kufam', sans-serif; + text-align: center; +} + +.eventDetails { + background-color: rgba(17, 42, 103, 0.1); + border: 2px solid #112a67; + border-radius: 1rem; + padding: 1.5rem; + margin-bottom: 2rem; +} + +.eventDetails h2 { + color: #112a67; + font-size: 1.5rem; + font-weight: 700; + margin: 0 0 1rem 0; + font-family: 'Inter', sans-serif; +} + +.detailRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 0; + border-bottom: 1px solid rgba(17, 42, 103, 0.1); + font-family: 'Inter', sans-serif; +} + +.detailRow:last-child { + border-bottom: none; +} + +.label { + color: #112a67; + font-weight: 600; + font-size: 0.95rem; +} + +.detailRow span:last-child { + color: #112a67; + font-weight: 500; +} + +.pointBadge { + background-color: #22c55e; + color: #ffffff; + padding: 0.25rem 0.75rem; + border-radius: 0.5rem; + font-weight: 700; + font-size: 1rem; +} + +/* Check-In Form */ +.checkinForm { + background-color: rgba(17, 42, 103, 0.05); + border: 2px solid #112a67; + border-radius: 1rem; + padding: 2rem; +} + +.checkinForm h3 { + color: #112a67; + font-size: 1.3rem; + font-weight: 700; + margin: 0 0 1.5rem 0; + font-family: 'Inter', sans-serif; +} + +.formGroup { + margin-bottom: 1.5rem; +} + +.formGroup label { + display: block; + color: #112a67; + font-weight: 600; + font-size: 0.95rem; + margin-bottom: 0.5rem; + font-family: 'Inter', sans-serif; +} + +.formGroup input { + width: 100%; + padding: 0.75rem 1rem; + border: 2px solid #112a67; + border-radius: 0.5rem; + font-size: 1rem; + font-family: 'Inter', sans-serif; + background-color: #f9f9f9; + color: #112a67; + transition: all 0.3s ease; + box-sizing: border-box; +} + +.formGroup input:focus { + outline: none; + border-color: #0d1f4d; + box-shadow: 0 0 0 3px rgba(17, 42, 103, 0.1); +} + +.errorBanner { + background-color: rgba(220, 38, 38, 0.1); + border: 2px solid #dc2626; + color: #dc2626; + padding: 1rem; + border-radius: 0.5rem; + margin-bottom: 1.5rem; + font-weight: 600; + font-family: 'Inter', sans-serif; +} + +.submitButton { + width: 100%; + background-color: #112a67; + color: #f9f9f9; + border: none; + padding: 1rem 2rem; + border-radius: 0.75rem; + font-size: 1.1rem; + font-weight: 700; + cursor: pointer; + transition: all 0.3s ease; + font-family: 'Inter', sans-serif; + margin-top: 0.5rem; +} + +.submitButton:hover:not(:disabled) { + background-color: #0d1f4d; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +.submitButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .mainContent { + padding: 1.5rem 1rem; + } + + .checkinContainer { + padding: 2rem 1.5rem; + } + + .eventInfo h1 { + font-size: 1.75rem; + } + + .eventDetails h2 { + font-size: 1.3rem; + } + + .checkinForm { + padding: 1.5rem; + } + + .successContainer { + padding: 2rem 1.5rem; + } + + .successIcon { + width: 80px; + height: 80px; + font-size: 3rem; + } + + .successContainer h1 { + font-size: 2rem; + } +} diff --git a/app/events/checkin/page.js b/app/events/checkin/page.js new file mode 100644 index 0000000..9bd393c --- /dev/null +++ b/app/events/checkin/page.js @@ -0,0 +1,273 @@ +"use client"; + +import React, { useState, useEffect, Suspense } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import Navbar from "../../../components/navbar.js"; +import styles from "./Checkin.module.css"; + +function CheckinPageContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [event, setEvent] = useState(null); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(false); + + const [formData, setFormData] = useState({ + net_id: "", + student_name: "", + student_email: "" + }); + + // Get event_id and secret from URL params + const eventId = searchParams.get("event_id"); + const secret = searchParams.get("secret"); + + useEffect(() => { + if (!eventId || !secret) { + setError("Invalid QR code. Missing event information."); + setLoading(false); + return; + } + + fetchEventDetails(); + }, [eventId, secret]); + + const fetchEventDetails = async () => { + setLoading(true); + try { + const response = await fetch(`/api/events/${eventId}`); + const data = await response.json(); + + if (response.ok && data.event) { + // Verify the QR code secret matches + if (data.event.qr_code_secret !== secret) { + setError("Invalid QR code secret. Please scan a valid event QR code."); + setLoading(false); + return; + } + + setEvent(data.event); + } else { + setError("Event not found."); + } + } catch (error) { + console.error("Error fetching event:", error); + setError("Failed to load event details."); + } finally { + setLoading(false); + } + }; + + const handleInputChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(""); + setSubmitting(true); + + try { + const response = await fetch("/api/events/checkin", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + event_id: eventId, + net_id: formData.net_id, + qr_code_secret: secret, + student_name: formData.student_name, + student_email: formData.student_email + }) + }); + + const data = await response.json(); + + if (response.ok) { + setSuccess(true); + // Redirect to events page after 3 seconds + setTimeout(() => { + router.push("/events"); + }, 3000); + } else { + setError(data.error || "Failed to check in. Please try again."); + } + } catch (error) { + console.error("Error checking in:", error); + setError("An error occurred. Please try again."); + } finally { + setSubmitting(false); + } + }; + + const formatDateTime = (dateString) => { + if (!dateString) return "N/A"; + const date = new Date(dateString); + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + }; + + if (loading) { + return ( +
+ +
+
+

Loading event details...

+
+
+
+ ); + } + + if (error && !event) { + return ( +
+ +
+
+

Check-In Error

+

{error}

+ +
+
+
+ ); + } + + if (success) { + return ( +
+ +
+
+
+

Check-In Successful!

+

+ You've earned {event.point_value} points for attending {event.title} +

+

+ Redirecting to events page... +

+
+
+
+ ); + } + + return ( +
+ +
+
+
+

Event Check-In

+
+

{event.title}

+
+ Location: + {event.location || "N/A"} +
+
+ Presenter: + {event.presenter || "N/A"} +
+
+ Time: + {formatDateTime(event.start_time)} +
+
+ Points: + {event.point_value} +
+
+
+ +
+

Enter Your Information

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {error && ( +
{error}
+ )} + + +
+
+
+
+ ); +} + +export default function CheckinPage() { + return ( + + +
+
+

Loading event details...

+
+
+ + }> + +
+ ); +} diff --git a/app/events/manage/Manage.module.css b/app/events/manage/Manage.module.css new file mode 100644 index 0000000..48fdd3a --- /dev/null +++ b/app/events/manage/Manage.module.css @@ -0,0 +1,564 @@ +/* Manage Events Page Styles */ + +.pageContainer { + background-color: #1e3877; + min-height: 100vh; + width: 100%; +} + +.mainContent { + padding: 2rem; + max-width: 1600px; + margin: 0 auto; +} + +.headerContainer { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.headerContainer h1 { + color: #f9f9f9; + font-size: 2.5rem; + font-family: 'Kufam', sans-serif; + margin: 0; +} + +.headerActions { + display: flex; + gap: 1rem; +} + +.createButton { + background-color: #ff9a35; + color: #112a67; + border: 2px solid #f9f9f9; + padding: 0.75rem 1.5rem; + border-radius: 0.5rem; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + transition: all 0.3s ease; + font-family: 'Inter', sans-serif; +} + +.createButton:hover { + background-color: #f67b00; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +.logoutButton { + background-color: #dc2626; + color: #f9f9f9; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 0.5rem; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + transition: all 0.3s ease; + font-family: 'Inter', sans-serif; +} + +.logoutButton:hover { + background-color: #b91c1c; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +/* Banners */ +.errorBanner, +.successBanner { + padding: 1rem 1.5rem; + border-radius: 0.5rem; + margin-bottom: 2rem; + font-weight: 600; + font-family: 'Inter', sans-serif; +} + +.errorBanner { + background-color: rgba(220, 38, 38, 0.1); + border: 2px solid #dc2626; + color: #dc2626; +} + +.successBanner { + background-color: rgba(34, 197, 94, 0.1); + border: 2px solid #22c55e; + color: #22c55e; +} + +.loadingContainer, +.emptyContainer { + text-align: center; + padding: 4rem 2rem; + color: #f9f9f9; + font-size: 1.2rem; + font-family: 'Inter', sans-serif; +} + +/* Events Table */ +.eventsTable { + background-color: #112a67; + border-radius: 1rem; + overflow: hidden; + border: 2px solid #f9f9f9; +} + +.eventsTable table { + width: 100%; + border-collapse: collapse; +} + +.eventsTable thead { + background-color: #ff9a35; +} + +.eventsTable th { + padding: 1rem; + text-align: left; + color: #112a67; + font-weight: 700; + font-size: 0.9rem; + font-family: 'Inter', sans-serif; + text-transform: uppercase; +} + +.eventsTable tbody tr { + border-bottom: 1px solid rgba(249, 249, 249, 0.1); + transition: background-color 0.2s ease; +} + +.eventsTable tbody tr:hover { + background-color: rgba(255, 154, 53, 0.1); +} + +.eventsTable td { + padding: 1rem; + color: #f9f9f9; + font-size: 0.9rem; + font-family: 'Inter', sans-serif; +} + +.titleCell { + font-weight: 600; + max-width: 250px; +} + +.eventsTable code { + background-color: rgba(249, 249, 249, 0.1); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.85rem; + font-family: 'Monaco', 'Courier New', monospace; +} + +.actionsCell { + display: flex; + gap: 0.5rem; +} + +.qrButton, +.editButton, +.deleteButton { + padding: 0.5rem 1rem; + border-radius: 0.375rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + font-family: 'Inter', sans-serif; + border: none; +} + +.qrButton { + background-color: #10b981; + color: #f9f9f9; +} + +.qrButton:hover { + background-color: #059669; +} + +.editButton { + background-color: #4f8dde; + color: #f9f9f9; +} + +.editButton:hover { + background-color: #3b82f6; +} + +.deleteButton { + background-color: #dc2626; + color: #f9f9f9; +} + +.deleteButton:hover { + background-color: #b91c1c; +} + +/* Modal */ +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.6); + z-index: 9999; + display: flex; + justify-content: center; + align-items: center; + padding: 1rem; + overflow-y: auto; +} + +.modalContent { + background-color: #ff9a35; + border-radius: 1.5rem; + max-width: 800px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + padding: 2.5rem; + position: relative; + border: 3px solid #f9f9f9; +} + +.closeButton { + position: absolute; + top: 1rem; + right: 1rem; + background: #112a67; + border: none; + color: #f9f9f9; + font-size: 2rem; + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + line-height: 1; + padding: 0; +} + +.closeButton:hover { + background: #0d1f4d; + transform: rotate(90deg); +} + +.modalHeader { + margin-bottom: 2rem; + padding-right: 3rem; +} + +.modalHeader h2 { + color: #112a67; + font-size: 2rem; + font-weight: 700; + margin: 0; + font-family: 'Inter', sans-serif; +} + +/* Form */ +.eventForm { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.formRow { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.formRow:has(textarea) { + grid-template-columns: 1fr; +} + +.formGroup { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.formGroup label { + color: #112a67; + font-weight: 600; + font-size: 0.95rem; + font-family: 'Inter', sans-serif; +} + +.formGroup input, +.formGroup textarea { + padding: 0.75rem 1rem; + border: 2px solid #112a67; + border-radius: 0.5rem; + font-size: 1rem; + font-family: 'Inter', sans-serif; + background-color: #f9f9f9; + color: #112a67; + transition: all 0.3s ease; +} + +.formGroup input:focus, +.formGroup textarea:focus { + outline: none; + border-color: #0d1f4d; + box-shadow: 0 0 0 3px rgba(17, 42, 103, 0.1); +} + +.formGroup textarea { + resize: vertical; + min-height: 100px; +} + +.errorMessage, +.successMessage { + padding: 0.75rem 1rem; + border-radius: 0.5rem; + font-size: 0.9rem; + font-weight: 600; + font-family: 'Inter', sans-serif; +} + +.errorMessage { + background-color: rgba(220, 38, 38, 0.1); + border: 2px solid #dc2626; + color: #dc2626; +} + +.successMessage { + background-color: rgba(34, 197, 94, 0.1); + border: 2px solid #22c55e; + color: #22c55e; +} + +.formActions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 1rem; +} + +.cancelButton, +.submitButton { + padding: 1rem 2rem; + border-radius: 0.75rem; + font-size: 1.1rem; + font-weight: 700; + cursor: pointer; + transition: all 0.3s ease; + font-family: 'Inter', sans-serif; + border: none; +} + +.cancelButton { + background-color: #6b7280; + color: #f9f9f9; +} + +.cancelButton:hover { + background-color: #4b5563; +} + +.submitButton { + background-color: #112a67; + color: #f9f9f9; +} + +.submitButton:hover { + background-color: #0d1f4d; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +/* QR Code Modal */ +.qrModalContent { + background-color: #ff9a35; + border-radius: 1.5rem; + max-width: 900px; + max-height: 90vh; + width: 100%; + padding: 2.5rem; + position: relative; + border: 3px solid #f9f9f9; + overflow-y: auto; +} + +.qrModalHeader { + margin-bottom: 2rem; + padding-right: 3rem; + text-align: center; +} + +.qrModalHeader h2 { + color: #112a67; + font-size: 2rem; + font-weight: 700; + margin: 0 0 0.5rem 0; + font-family: 'Inter', sans-serif; +} + +.eventTitle { + color: #112a67; + font-size: 1.2rem; + font-weight: 600; + margin: 0; + font-family: 'Inter', sans-serif; +} + +.qrContentLayout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + margin-bottom: 2rem; +} + +.qrCodeContainer { + background-color: #f9f9f9; + padding: 1.5rem; + border-radius: 1rem; + display: flex; + justify-content: center; + align-items: center; + border: 2px solid #112a67; + aspect-ratio: 1 / 1; +} + +.qrCodeContainer canvas { + width: 100%; + height: 100%; + object-fit: contain; +} + +.eventDetails { + background-color: rgba(17, 42, 103, 0.1); + padding: 1.5rem; + border-radius: 1rem; + border: 2px solid #112a67; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.eventDetails h3 { + color: #112a67; + font-size: 1.3rem; + font-weight: 700; + margin: 0 0 0.5rem 0; + font-family: 'Inter', sans-serif; +} + +.detailItem { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.detailItem strong { + color: #112a67; + font-size: 0.85rem; + font-family: 'Inter', sans-serif; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.detailItem span, +.detailItem code { + color: #112a67; + font-size: 1rem; + font-family: 'Inter', sans-serif; +} + +.detailItem code { + background-color: rgba(17, 42, 103, 0.15); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-family: 'Monaco', 'Courier New', monospace; + font-size: 0.9rem; + display: inline-block; +} + +.qrInstructions { + text-align: left; + background-color: rgba(17, 42, 103, 0.1); + padding: 1.5rem; + border-radius: 0.75rem; + border: 2px solid #112a67; +} + +.qrInstructions p { + color: #112a67; + margin: 0 0 0.75rem 0; + font-family: 'Inter', sans-serif; + font-size: 0.95rem; +} + +.qrInstructions ol { + color: #112a67; + margin: 0; + padding-left: 1.5rem; + font-family: 'Inter', sans-serif; + font-size: 0.9rem; +} + +.qrInstructions li { + margin-bottom: 0.5rem; +} + +.qrInstructions li:last-child { + margin-bottom: 0; +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .eventsTable { + overflow-x: auto; + } + + .eventsTable table { + min-width: 900px; + } +} + +@media (max-width: 768px) { + .headerContainer { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .formRow { + grid-template-columns: 1fr; + } + + .modalContent { + padding: 2rem 1.5rem; + } + + .qrModalContent { + padding: 2rem 1.5rem; + } + + .qrContentLayout { + grid-template-columns: 1fr; + gap: 1.5rem; + } + + .qrCodeContainer { + padding: 1rem; + } + + .eventDetails { + gap: 0.75rem; + } +} diff --git a/app/events/manage/page.js b/app/events/manage/page.js new file mode 100644 index 0000000..44e00cd --- /dev/null +++ b/app/events/manage/page.js @@ -0,0 +1,528 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; +import QRCode from "qrcode"; +import Navbar from "../../../components/navbar.js"; +import styles from "./Manage.module.css"; + +export default function ManageEventsPage() { + const router = useRouter(); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [showEventModal, setShowEventModal] = useState(false); + const [showQRModal, setShowQRModal] = useState(false); + const [selectedQREvent, setSelectedQREvent] = useState(null); + const [editingEvent, setEditingEvent] = useState(null); + const qrCanvasRef = useRef(null); + const [formData, setFormData] = useState({ + title: "", + description: "", + location: "", + presenter: "", + start_time: "", + end_time: "", + point_value: 10, + qr_code_secret: "", + join_link: "", + }); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + useEffect(() => { + // Check if user is authenticated + const token = localStorage.getItem('admin_token'); + if (!token) { + router.push('/events'); + return; + } + fetchEvents(); + }, [router]); + + const fetchEvents = async () => { + setLoading(true); + try { + const response = await fetch('/api/events'); + const data = await response.json(); + if (response.ok) { + setEvents(data.events || []); + } else { + setError('Failed to fetch events'); + } + } catch (error) { + console.error('Error fetching events:', error); + setError('Failed to fetch events'); + } finally { + setLoading(false); + } + }; + + const openCreateModal = () => { + setEditingEvent(null); + setFormData({ + title: "", + description: "", + location: "", + presenter: "", + start_time: "", + end_time: "", + point_value: 10, + qr_code_secret: "", + join_link: "", + }); + setShowEventModal(true); + }; + + const openEditModal = (event) => { + setEditingEvent(event); + setFormData({ + title: event.title || "", + description: event.description || "", + location: event.location || "", + presenter: event.presenter || "", + start_time: event.start_time ? new Date(event.start_time).toISOString().slice(0, 16) : "", + end_time: event.end_time ? new Date(event.end_time).toISOString().slice(0, 16) : "", + point_value: event.point_value || 10, + qr_code_secret: event.qr_code_secret || "", + join_link: event.join_link || "", + }); + setShowEventModal(true); + }; + + const closeEventModal = () => { + setShowEventModal(false); + setEditingEvent(null); + setError(""); + setSuccess(""); + }; + + const handleInputChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(""); + setSuccess(""); + + try { + const endpoint = editingEvent + ? `/api/events/${editingEvent.id}` + : '/api/events'; + const method = editingEvent ? 'PUT' : 'POST'; + + const response = await fetch(endpoint, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData) + }); + + const data = await response.json(); + + if (response.ok) { + setSuccess(editingEvent ? 'Event updated successfully!' : 'Event created successfully!'); + setTimeout(() => { + closeEventModal(); + fetchEvents(); + }, 1500); + } else { + setError(data.error || 'Failed to save event'); + } + } catch (error) { + console.error('Error saving event:', error); + setError('Failed to save event'); + } + }; + + const handleDelete = async (eventId) => { + if (!confirm('Are you sure you want to delete this event?')) { + return; + } + + try { + const response = await fetch(`/api/events/${eventId}`, { + method: 'DELETE' + }); + + if (response.ok) { + setSuccess('Event deleted successfully!'); + fetchEvents(); + setTimeout(() => setSuccess(""), 3000); + } else { + const data = await response.json(); + setError(data.error || 'Failed to delete event'); + } + } catch (error) { + console.error('Error deleting event:', error); + setError('Failed to delete event'); + } + }; + + const handleLogout = () => { + localStorage.removeItem('admin_token'); + router.push('/events'); + }; + + const openQRModal = async (event) => { + setSelectedQREvent(event); + setShowQRModal(true); + }; + + const closeQRModal = () => { + setShowQRModal(false); + setSelectedQREvent(null); + }; + + // Generate QR code when modal opens + useEffect(() => { + if (showQRModal && selectedQREvent && qrCanvasRef.current) { + // Create check-in URL with event data as query params + const baseUrl = window.location.origin; + const checkInUrl = `${baseUrl}/events/checkin?event_id=${selectedQREvent.id}&secret=${selectedQREvent.qr_code_secret}`; + + QRCode.toCanvas(qrCanvasRef.current, checkInUrl, { + width: 400, + margin: 2, + color: { + dark: '#112a67', + light: '#ffffff' + } + }, (error) => { + if (error) console.error('QR Code generation error:', error); + }); + } + }, [showQRModal, selectedQREvent]); + + const formatDateTime = (dateString) => { + if (!dateString) return 'N/A'; + const date = new Date(dateString); + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + }; + + return ( +
+ +
+
+

Manage Events

+
+ + +
+
+ + {error && !showEventModal && ( +
{error}
+ )} + + {success && !showEventModal && ( +
{success}
+ )} + + {loading ? ( +
+

Loading events...

+
+ ) : events.length === 0 ? ( +
+

No events found. Create your first event!

+
+ ) : ( +
+ + + + + + + + + + + + + + + {events.map((event) => ( + + + + + + + + + + + ))} + +
TitleLocationPresenterStart TimePointsQR CodeCheck-insActions
{event.title}{event.location}{event.presenter || 'N/A'}{formatDateTime(event.start_time)}{event.point_value}{event.qr_code_secret}{event.checked_in_students?.length || 0} + + + +
+
+ )} + + {/* Event Form Modal */} + {showEventModal && ( +
+
e.stopPropagation()} + > + + +
+

{editingEvent ? 'Edit Event' : 'Create New Event'}

+
+ +
+
+
+ + +
+
+ +
+
+ +