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}
+
+
+
+
+
+
+
+
+ );
+}
+
+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 ? (
+
+ ) : events.length === 0 ? (
+
+
No events found. Create your first event!
+
+ ) : (
+
+
+
+
+ | Title |
+ Location |
+ Presenter |
+ Start Time |
+ Points |
+ QR Code |
+ Check-ins |
+ Actions |
+
+
+
+ {events.map((event) => (
+
+ | {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'}
+
+
+
+
+
+ )}
+
+ {/* QR Code Modal */}
+ {showQRModal && selectedQREvent && (
+
+
e.stopPropagation()}
+ >
+
+
+
+
Event QR Code
+
{selectedQREvent.title}
+
+
+
+
+
+
+
+
+
Event Details
+
+ Location:
+ {selectedQREvent.location || 'N/A'}
+
+
+ Presenter:
+ {selectedQREvent.presenter || 'N/A'}
+
+
+ Start Time:
+ {formatDateTime(selectedQREvent.start_time)}
+
+
+ End Time:
+ {formatDateTime(selectedQREvent.end_time)}
+
+
+ Points:
+ {selectedQREvent.point_value}
+
+
+ QR Secret:
+ {selectedQREvent.qr_code_secret}
+
+
+ Check-ins:
+ {selectedQREvent.checked_in_students?.length || 0} students
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/app/events/page.js b/app/events/page.js
new file mode 100644
index 0000000..abcbca3
--- /dev/null
+++ b/app/events/page.js
@@ -0,0 +1,487 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import Navbar from "../../components/navbar.js";
+import styles from "./Events.module.css";
+
+export default function EventsPage() {
+ const [events, setEvents] = useState([]);
+ const [selectedEvent, setSelectedEvent] = useState(null);
+ const [activeTab, setActiveTab] = useState("upcoming"); // "upcoming" or "past"
+ const [loading, setLoading] = useState(true);
+ const [showAdminModal, setShowAdminModal] = useState(false);
+ const [adminCode, setAdminCode] = useState("");
+ const [adminError, setAdminError] = useState("");
+
+ useEffect(() => {
+ fetchEvents();
+ }, []);
+
+ // Lock scroll & add ESC-to-close when modal is open
+ useEffect(() => {
+ if (!selectedEvent) return;
+
+ const onKeyDown = (e) => {
+ if (e.key === "Escape") closeModal();
+ };
+ document.addEventListener("keydown", onKeyDown);
+
+ const prevOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+
+ return () => {
+ document.removeEventListener("keydown", onKeyDown);
+ document.body.style.overflow = prevOverflow;
+ };
+ }, [selectedEvent]);
+
+ const fetchEvents = async () => {
+ setLoading(true);
+
+ try {
+ const response = await fetch('/api/events');
+ const data = await response.json();
+
+ if (response.ok) {
+ setEvents(data.events || []);
+ } else {
+ console.error('Failed to fetch events:', data.error);
+ // Fallback to empty array on error
+ setEvents([]);
+ }
+ } catch (error) {
+ console.error('Error fetching events:', error);
+ setEvents([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const openModal = (event) => setSelectedEvent(event);
+ const closeModal = () => setSelectedEvent(null);
+
+ const openAdminModal = () => setShowAdminModal(true);
+ const closeAdminModal = () => {
+ setShowAdminModal(false);
+ setAdminCode("");
+ setAdminError("");
+ };
+
+ const handleAdminLogin = async (e) => {
+ e.preventDefault();
+ setAdminError("");
+
+ try {
+ const response = await fetch('/api/auth/admin', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ admin_code: adminCode })
+ });
+
+ const data = await response.json();
+
+ if (response.ok) {
+ // Store token in localStorage
+ localStorage.setItem('admin_token', data.token);
+ // Redirect to manage page
+ window.location.href = '/events/manage';
+ } else {
+ setAdminError(data.error || 'Invalid admin code');
+ }
+ } catch (error) {
+ console.error('Admin login error:', error);
+ setAdminError('Failed to authenticate. Please try again.');
+ }
+ };
+
+ const formatDate = (dateString) => {
+ const date = new Date(dateString);
+ return date.toLocaleDateString("en-US", {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+ };
+
+ const formatTime = (dateString) => {
+ const date = new Date(dateString);
+ return date.toLocaleTimeString("en-US", {
+ hour: "numeric",
+ minute: "2-digit",
+ hour12: true,
+ });
+ };
+
+ const now = new Date();
+ const upcomingEvents = events.filter(
+ (event) => new Date(event.start_time) > now
+ );
+ const pastEvents = events.filter(
+ (event) => new Date(event.start_time) <= now
+ );
+
+ const displayEvents = activeTab === "upcoming" ? upcomingEvents : pastEvents;
+
+ return (
+
+
+
+
+
+
+ Events
+
+
+
+
+
+
+
+
+
+
+ {loading ? (
+
+ ) : displayEvents.length === 0 ? (
+
+
+ {activeTab === "upcoming"
+ ? "No upcoming events at the moment. Check back soon!"
+ : "No past events to display."}
+
+
+ ) : (
+
+ {displayEvents.map((event) => (
+
openModal(event)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) =>
+ (e.key === "Enter" || e.key === " ") && openModal(event)
+ }
+ >
+
+
{event.title}
+ {event.point_value > 0 && (
+
+ {event.point_value} pts
+
+ )}
+
+
+
+
+
+ {formatDate(event.start_time)}
+
+
+
+
+
+ {formatTime(event.start_time)} - {formatTime(event.end_time)}
+
+
+
+
+
+
{event.location}
+
+
+
+
+ {event.description.length > 100
+ ? event.description.substring(0, 100) + "..."
+ : event.description}
+
+
+ ))}
+
+ )}
+
+ {/* Event Detail Modal */}
+ {selectedEvent && (
+
+
e.stopPropagation()}
+ role="document"
+ >
+
+
+
+
{selectedEvent.title}
+ {selectedEvent.point_value > 0 && (
+
+ {selectedEvent.point_value} points
+
+ )}
+
+
+
+
+
+
+
+
Date:
+
{formatDate(selectedEvent.start_time)}
+
+
+
+
+
+
+
Time:
+
+ {formatTime(selectedEvent.start_time)} -{" "}
+ {formatTime(selectedEvent.end_time)}
+
+
+
+
+
+
+
+
Location:
+
{selectedEvent.location}
+
+
+
+ {selectedEvent.presenter && (
+
+
+
+
Presenter:
+
{selectedEvent.presenter}
+
+
+ )}
+
+
+
+
About This Event
+
{selectedEvent.description}
+
+
+ {activeTab === "upcoming" && (
+
+ )}
+
+
+
+ )}
+
+ {/* Admin Login Modal */}
+ {showAdminModal && (
+
+
e.stopPropagation()}
+ role="document"
+ >
+
+
+
+
Admin Login
+
Enter the admin code to manage events
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/components/navbar.js b/components/navbar.js
index 3d6f505..8d2fb91 100644
--- a/components/navbar.js
+++ b/components/navbar.js
@@ -101,6 +101,15 @@ export default function Navbar() {
Timeline
+
+
+
@@ -170,6 +179,15 @@ export default function Navbar() {
Timeline
+
+
+
>
diff --git a/package-lock.json b/package-lock.json
index fa0880c..93bd18a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@supabase/supabase-js": "^2.49.4",
"lucide-react": "^0.487.0",
"next": "^15.3.0",
+ "qrcode": "^1.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
@@ -713,6 +714,30 @@
"@types/node": "*"
}
},
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
@@ -784,6 +809,15 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001707",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz",
@@ -810,6 +844,44 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
+ "node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz",
@@ -820,6 +892,12 @@
"node": ">=8"
}
},
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.135",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.135.tgz",
@@ -827,6 +905,12 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -837,6 +921,19 @@
"node": ">=6"
}
},
+ "node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -851,6 +948,36 @@
"url": "https://github.com/sponsors/rawify"
}
},
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/lucide-react": {
"version": "0.487.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.487.0.tgz",
@@ -975,12 +1102,66 @@
"node": ">=0.10.0"
}
},
+ "node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
@@ -1017,6 +1198,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
@@ -1038,6 +1236,21 @@
"react": "^19.1.0"
}
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
"node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
@@ -1057,6 +1270,12 @@
"node": ">=10"
}
},
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
"node_modules/sharp": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz",
@@ -1109,6 +1328,32 @@
"node": ">=0.10.0"
}
},
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -1204,6 +1449,26 @@
"webidl-conversions": "^3.0.0"
}
},
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
@@ -1224,6 +1489,47 @@
"optional": true
}
}
+ },
+ "node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
+ "node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
}
}
}
diff --git a/package.json b/package.json
index 0753aee..ea4db72 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"@supabase/supabase-js": "^2.49.4",
"lucide-react": "^0.487.0",
"next": "^15.3.0",
+ "qrcode": "^1.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
diff --git a/supabase/migrations/001_create_events_tables.sql b/supabase/migrations/001_create_events_tables.sql
new file mode 100644
index 0000000..851e9b2
--- /dev/null
+++ b/supabase/migrations/001_create_events_tables.sql
@@ -0,0 +1,113 @@
+-- Migration: Create Events and Event Check-ins Tables
+-- Description: Sets up the database schema for the event management system
+-- Created: 2025-11-17
+
+-- Enable UUID extension if not already enabled
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+
+-- ============================================
+-- EVENTS TABLE
+-- ============================================
+-- Stores all event information
+CREATE TABLE IF NOT EXISTS events (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ title VARCHAR(255) NOT NULL,
+ description TEXT,
+ location VARCHAR(255),
+ presenter VARCHAR(255),
+ start_time TIMESTAMP WITH TIME ZONE NOT NULL,
+ end_time TIMESTAMP WITH TIME ZONE NOT NULL,
+ point_value INTEGER DEFAULT 10,
+ qr_code_secret VARCHAR(255) UNIQUE, -- Unique code for QR check-in
+ join_link TEXT, -- Discord event signup link or other join URL
+ checked_in_students JSONB DEFAULT '[]'::jsonb, -- Array of checked-in students with {student_name, student_netid, student_email}
+ is_active BOOLEAN DEFAULT true,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+-- ============================================
+-- EVENT CHECK-INS TABLE
+-- ============================================
+-- Tracks which students have checked into which events
+CREATE TABLE IF NOT EXISTS event_checkins (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
+ net_id VARCHAR(50) NOT NULL,
+ checked_in_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ UNIQUE(event_id, net_id) -- Prevent duplicate check-ins
+);
+
+-- ============================================
+-- INDEXES
+-- ============================================
+-- Improve query performance
+CREATE INDEX IF NOT EXISTS idx_events_start_time ON events(start_time);
+CREATE INDEX IF NOT EXISTS idx_events_is_active ON events(is_active);
+CREATE INDEX IF NOT EXISTS idx_event_checkins_event_id ON event_checkins(event_id);
+CREATE INDEX IF NOT EXISTS idx_event_checkins_net_id ON event_checkins(net_id);
+
+-- ============================================
+-- ROW LEVEL SECURITY (RLS)
+-- ============================================
+-- Enable RLS on both tables
+ALTER TABLE events ENABLE ROW LEVEL SECURITY;
+ALTER TABLE event_checkins ENABLE ROW LEVEL SECURITY;
+
+-- ============================================
+-- EVENTS TABLE POLICIES
+-- ============================================
+
+-- Policy: Anyone can read events (public access for student viewing)
+CREATE POLICY "Events are viewable by everyone"
+ ON events FOR SELECT
+ USING (true);
+
+-- Policy: Allow all event mutations (admin auth handled in API layer)
+-- Admin authentication is handled via API endpoints with PRIVATE_EVENTS_MANAGE_KEY
+CREATE POLICY "Allow event mutations"
+ ON events FOR INSERT
+ USING (true)
+ WITH CHECK (true);
+
+CREATE POLICY "Allow event updates"
+ ON events FOR UPDATE
+ USING (true)
+ WITH CHECK (true);
+
+CREATE POLICY "Allow event deletions"
+ ON events FOR DELETE
+ USING (true);
+
+-- ============================================
+-- EVENT CHECK-INS TABLE POLICIES
+-- ============================================
+
+-- Policy: Anyone can read check-ins (for leaderboard/attendance display)
+CREATE POLICY "Check-ins are viewable by everyone"
+ ON event_checkins FOR SELECT
+ USING (true);
+
+-- Policy: Allow anonymous users to insert check-ins (student check-in via QR code)
+-- Duplicate prevention is handled by UNIQUE constraint and API validation
+CREATE POLICY "Allow check-in insertion"
+ ON event_checkins FOR INSERT
+ WITH CHECK (true);
+
+-- ============================================
+-- FUNCTIONS
+-- ============================================
+-- Function to automatically update updated_at timestamp
+CREATE OR REPLACE FUNCTION update_updated_at_column()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Trigger to update updated_at on events table
+CREATE TRIGGER update_events_updated_at
+ BEFORE UPDATE ON events
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
\ No newline at end of file