diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..ff9844f Binary files /dev/null and b/.DS_Store differ diff --git a/shatter-backend/src/.DS_Store b/shatter-backend/src/.DS_Store new file mode 100644 index 0000000..d83f85b Binary files /dev/null and b/shatter-backend/src/.DS_Store differ diff --git a/shatter-backend/src/app.ts b/shatter-backend/src/app.ts index 401d13a..7f5f7ea 100644 --- a/shatter-backend/src/app.ts +++ b/shatter-backend/src/app.ts @@ -1,10 +1,11 @@ import express from "express"; import cors from "cors"; -import userRoutes from './routes/user_route'; // these routes define how to handle requests to /api/users +import userRoutes from './routes/user_route'; import authRoutes from './routes/auth_routes'; import eventRoutes from './routes/event_routes'; import bingoRoutes from './routes/bingo_routes'; +import participantConnectionRoutes from "./routes/participant_connections_routes"; const app = express(); @@ -43,5 +44,6 @@ app.use('/api/users', userRoutes); app.use('/api/auth', authRoutes); app.use('/api/events', eventRoutes); app.use('/api/bingo', bingoRoutes); +app.use("/api/participantConnections", participantConnectionRoutes); -export default app; +export default app; \ No newline at end of file diff --git a/shatter-backend/src/controllers/participant_connections_controller.ts b/shatter-backend/src/controllers/participant_connections_controller.ts new file mode 100644 index 0000000..ebd786c --- /dev/null +++ b/shatter-backend/src/controllers/participant_connections_controller.ts @@ -0,0 +1,318 @@ +// controllers/participant_connections_controller.ts + +import { Request, Response } from "express"; +import { Types } from "mongoose"; + +import { check_req_fields } from "../utils/requests_utils"; +import { User } from "../models/user_model"; +import { Participant } from "../models/participant_model"; +import { ParticipantConnection } from "../models/participant_connection_model"; + +/** + * POST /api/participantConnections + * Create a new ParticipantConnection document. + * The server generates the ParticipantConnection `_id` automatically (pre-save hook). + * + * @param req.body._eventId - MongoDB ObjectId of the event (required) + * @param req.body.primaryParticipantId - MongoDB ObjectId of the primary participant (required) + * @param req.body.secondaryParticipantId - MongoDB ObjectId of the secondary participant (required) + * @param req.body.description - Optional description for the connection, can be the bingo question the participants connected with (optional) + * + * Behavior notes: + * - Validates ObjectId format before hitting the DB + * - Ensures BOTH participants exist AND belong to the given event + * - Prevents duplicates with exact same (_eventId, primaryParticipantId, secondaryParticipantId) + * + * @returns 201 - Created ParticipantConnection document + * @returns 400 - Missing required fields or invalid ObjectId format + * @returns 404 - Primary participant or secondary participant not found for this event + * @returns 409 - ParticipantConnection already exists for this event and participants + * @returns 500 - Internal server error + */ +export async function createParticipantConnection(req: Request, res: Response) { + try { + const requiredFields = ["_eventId", "primaryParticipantId", "secondaryParticipantId"]; + if (!check_req_fields(req, requiredFields)) { + return res.status(400).json({ error: "Missing required fields" }); + } + + const { _eventId, primaryParticipantId, secondaryParticipantId, description } = req.body; + + // Validate ObjectId format before hitting the DB + const idsToValidate = { _eventId, primaryParticipantId, secondaryParticipantId }; + for (const [key, value] of Object.entries(idsToValidate)) { + if (!Types.ObjectId.isValid(value)) { + return res.status(400).json({ error: `Invalid ${key}` }); + } + } + + // Ensure both participants exist AND belong to the event + const [primaryParticipant, secondaryParticipant] = await Promise.all([ + Participant.findOne({ _id: primaryParticipantId, eventId: _eventId }).select("_id"), + Participant.findOne({ _id: secondaryParticipantId, eventId: _eventId }).select("_id"), + ]); + + if (!primaryParticipant) { + return res.status(404).json({ error: "Primary participant not found for this event" }); + } + if (!secondaryParticipant) { + return res.status(404).json({ error: "Secondary participant not found for this event" }); + } + + // Prevent duplicates with exact same (_eventId, primaryParticipantId, secondaryParticipantId) + const existing = await ParticipantConnection.findOne({ + _eventId, + primaryParticipantId, + secondaryParticipantId, + }); + + if (existing) { + return res.status(409).json({ + error: "ParticipantConnection already exists for this event and participants", + existingConnection: existing, + }); + } + + const newConnection = await ParticipantConnection.create({ + _eventId, + primaryParticipantId, + secondaryParticipantId, + description, + }); + + return res.status(201).json(newConnection); + } catch (_error) { + return res.status(500).json({ error: "Internal server error" }); + } +} + +/** + * POST /api/participantConnections/by-emails + * Create a new ParticipantConnection by providing USER emails. + * + * Looks up users by email, then finds their Participant records for the given event, + * then saves the connection using the participants' MongoDB ObjectIds. + * The server generates the ParticipantConnection `_id` automatically (pre-save hook). + * + * @param req.body._eventId - MongoDB ObjectId of the event (required) + * @param req.body.primaryUserEmail - Email of the primary user (required) + * @param req.body.secondaryUserEmail - Email of the secondary user (required) + * @param req.body.description - Optional description for the connection (optional) + * + * Behavior notes: + * - Validates _eventId ObjectId format + * - Validates emails + prevents same email provided twice + * - Maps email -> User -> Participant (for that event) + * - Prevents duplicates with exact same (_eventId, primaryParticipantId, secondaryParticipantId) + * + * @returns 201 - Created ParticipantConnection document + * @returns 400 - Missing required fields, invalid ObjectId, invalid emails, or same email provided twice + * @returns 404 - Primary/secondary user not found OR participant not found for this event + * @returns 409 - ParticipantConnection already exists for this event and participants + * @returns 500 - Internal server error + */ +export async function createParticipantConnectionByEmails(req: Request, res: Response) { + try { + const requiredFields = ["_eventId", "primaryUserEmail", "secondaryUserEmail"]; + if (!check_req_fields(req, requiredFields)) { + return res.status(400).json({ error: "Missing required fields" }); + } + + const { _eventId, primaryUserEmail, secondaryUserEmail, description } = req.body; + + if (!Types.ObjectId.isValid(_eventId)) { + return res.status(400).json({ error: "Invalid _eventId" }); + } + + const primaryEmail = String(primaryUserEmail).trim().toLowerCase(); + const secondaryEmail = String(secondaryUserEmail).trim().toLowerCase(); + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; + if (!emailRegex.test(primaryEmail)) { + return res.status(400).json({ error: "Invalid primaryUserEmail" }); + } + if (!emailRegex.test(secondaryEmail)) { + return res.status(400).json({ error: "Invalid secondaryUserEmail" }); + } + + if (primaryEmail === secondaryEmail) { + return res.status(400).json({ error: "primaryUserEmail and secondaryUserEmail must be different" }); + } + + // Find users by email + const [primaryUser, secondaryUser] = await Promise.all([ + User.findOne({ email: primaryEmail }).select("_id"), + User.findOne({ email: secondaryEmail }).select("_id"), + ]); + + if (!primaryUser) return res.status(404).json({ error: "Primary user not found" }); + if (!secondaryUser) return res.status(404).json({ error: "Secondary user not found" }); + + // Map User -> Participant (for the event) + const [primaryParticipant, secondaryParticipant] = await Promise.all([ + Participant.findOne({ eventId: _eventId, userId: primaryUser._id }).select("_id"), + Participant.findOne({ eventId: _eventId, userId: secondaryUser._id }).select("_id"), + ]); + + if (!primaryParticipant) { + return res.status(404).json({ error: "Primary participant not found for this event (by user email)" }); + } + if (!secondaryParticipant) { + return res.status(404).json({ error: "Secondary participant not found for this event (by user email)" }); + } + + // Prevent duplicates with exact same (_eventId, primaryParticipantId, secondaryParticipantId) + const existing = await ParticipantConnection.findOne({ + _eventId, + primaryParticipantId: primaryParticipant._id, + secondaryParticipantId: secondaryParticipant._id, + }); + + if (existing) { + return res.status(409).json({ + error: "ParticipantConnection already exists for this event and participants", + existingConnection: existing, + }); + } + + const newConnection = await ParticipantConnection.create({ + _eventId, + primaryParticipantId: primaryParticipant._id, + secondaryParticipantId: secondaryParticipant._id, + description, + }); + + return res.status(201).json(newConnection); + } catch (_error) { + return res.status(500).json({ error: "Internal server error" }); + } +} + +/** + * DELETE /api/participantConnections/delete + * + * Deletes a participant connection only if it belongs to the given event. + * + * @param req.body.eventId - MongoDB ObjectId of the event (required) + * @param req.body.connectionId - ParticipantConnection string _id (required) + * + * @returns 200 - Deleted connection + * @returns 400 - Missing/invalid body params + * @returns 404 - ParticipantConnection not found for this event + * @returns 500 - Internal server error + */ +export async function deleteParticipantConnection(req: Request, res: Response) { + try { + const { eventId, connectionId } = req.body; + + if (!eventId || !Types.ObjectId.isValid(eventId)) { + return res.status(400).json({ error: "Invalid eventId" }); + } + + if (!connectionId || typeof connectionId !== "string") { + return res.status(400).json({ error: "Invalid connectionId" }); + } + + const deleted = await ParticipantConnection.findOneAndDelete({ + _id: connectionId, + _eventId: eventId, + }); + + if (!deleted) { + return res.status(404).json({ error: "ParticipantConnection not found for this event" }); + } + + return res.status(200).json({ + message: "ParticipantConnection deleted successfully", + deletedConnection: deleted, + }); + } catch (_error) { + return res.status(500).json({ error: "Internal server error" }); + } +} + +/** + * PUT /api/participantConnections/getByParticipantAndEvent + * + * Returns all ParticipantConnections for an event where the given participant is either: + * - primaryParticipantId OR + * - secondaryParticipantId + * + * @param req.body.eventId - MongoDB ObjectId of the event (required) + * @param req.body.participantId - MongoDB ObjectId of the participant (required) + * + * @returns 200 - Array of matching ParticipantConnections + * @returns 400 - Missing/invalid body params + * @returns 500 - Internal server error + */ +export async function getConnectionsByParticipantAndEvent(req: Request, res: Response) { + try { + const { eventId, participantId } = req.body; + + if (!eventId || !Types.ObjectId.isValid(eventId)) { + return res.status(400).json({ error: "Invalid eventId" }); + } + if (!participantId || !Types.ObjectId.isValid(participantId)) { + return res.status(400).json({ error: "Invalid participantId" }); + } + + const connections = await ParticipantConnection.find({ + _eventId: eventId, + $or: [{ primaryParticipantId: participantId }, { secondaryParticipantId: participantId }], + }); + + return res.status(200).json(connections); + } catch (_error) { + return res.status(500).json({ error: "Internal server error" }); + } +} + +/** + * PUT /api/participantConnections/getByUserEmailAndEvent + * + * Returns all ParticipantConnections for an event where the given user's PARTICIPANT is either: + * - primaryParticipantId OR + * - secondaryParticipantId + * + * @param req.body.eventId - MongoDB ObjectId of the event (required) + * @param req.body.userEmail - Email of the user (required) + * + * Behavior notes: + * - Maps userEmail -> User -> Participant (for that event) + * - Then returns ParticipantConnections where that participant appears + * + * @returns 200 - Array of matching ParticipantConnections + * @returns 400 - Missing/invalid body params or invalid userEmail (no matching user) + * @returns 404 - Participant not found for this event (even though user exists) + * @returns 500 - Internal server error + */ +export async function getConnectionsByUserEmailAndEvent(req: Request, res: Response) { + try { + const { eventId, userEmail } = req.body; + + if (!eventId || !Types.ObjectId.isValid(eventId)) { + return res.status(400).json({ error: "Invalid eventId" }); + } + + const email = String(userEmail).trim().toLowerCase(); + const user = await User.findOne({ email }).select("_id"); + + if (!user) { + return res.status(400).json({ error: "Invalid userEmail" }); + } + + const participant = await Participant.findOne({ eventId, userId: user._id }).select("_id"); + if (!participant) { + return res.status(404).json({ error: "Participant not found for this event (by user email)" }); + } + + const connections = await ParticipantConnection.find({ + _eventId: eventId, + $or: [{ primaryParticipantId: participant._id }, { secondaryParticipantId: participant._id }], + }); + + return res.status(200).json(connections); + } catch (_error) { + return res.status(500).json({ error: "Internal server error" }); + } +} \ No newline at end of file diff --git a/shatter-backend/src/models/participant_connection_model.ts b/shatter-backend/src/models/participant_connection_model.ts new file mode 100644 index 0000000..5f48106 --- /dev/null +++ b/shatter-backend/src/models/participant_connection_model.ts @@ -0,0 +1,46 @@ +import { Schema, model, Types, Document } from "mongoose"; + +export interface ParticipantConnection extends Document { + _id: string; + _eventId: Types.ObjectId; + primaryParticipantId: Types.ObjectId; + secondaryParticipantId: Types.ObjectId; + description?: string; +} + +const participantConnectionSchema = new Schema( + { + _id: { type: String }, + _eventId: { + type: Schema.Types.ObjectId, + ref: "Event", + required: true, + }, + primaryParticipantId: { + type: Schema.Types.ObjectId, + ref: "Participant", + required: true, + }, + secondaryParticipantId: { + type: Schema.Types.ObjectId, + ref: "Participant", + required: true, + }, + description: { type: String }, + }, + { + versionKey: false, + } +); + +participantConnectionSchema.pre("save", function (next) { + if (!this._id) { + this._id = `participantConnection_${Math.random().toString(36).slice(2, 10)}`; + } + next(); +}); + +export const ParticipantConnection = model( + "ParticipantConnection", + participantConnectionSchema +); \ No newline at end of file diff --git a/shatter-backend/src/routes/participant_connections_routes.ts b/shatter-backend/src/routes/participant_connections_routes.ts new file mode 100644 index 0000000..c38250d --- /dev/null +++ b/shatter-backend/src/routes/participant_connections_routes.ts @@ -0,0 +1,60 @@ +// routes/participant_connections_routes.ts + +import { Router } from "express"; +import { + createParticipantConnection, + createParticipantConnectionByEmails, + deleteParticipantConnection, + getConnectionsByParticipantAndEvent, + getConnectionsByUserEmailAndEvent, +} from "../controllers/participant_connections_controller"; +import { authMiddleware } from "../middleware/auth_middleware"; + +const router = Router(); + +/** + * Base path (mounted in app): /api/participantConnections + * + * Routes: + * - POST /api/participantConnections + * Create connection by participant ObjectIds + * + * - POST /api/participantConnections/by-emails + * Create connection by user emails (controller maps email -> User -> Participant -> ObjectIds) + * + * - DELETE /api/participantConnections/delete + * Delete connection (eventId + connectionId in request body) + * + * - PUT /api/participantConnections/getByParticipantAndEvent + * Get all connections for (eventId + participantId) where participant is primary or secondary + * + * - PUT /api/participantConnections/getByUserEmailAndEvent + * Get all connections for (eventId + userEmail) where the user's participant is primary or secondary + */ + + + + + + +// Create connection by participant ObjectIds +// POST /api/participantConnections +router.post("/", authMiddleware, createParticipantConnection); + +// Create connection by user emails (controller converts to participant ObjectIds) +// POST /api/participantConnections/by-emails +router.post("/by-emails", authMiddleware, createParticipantConnectionByEmails); + +// Delete connection (eventId + connectionId in request body) +// DELETE /api/participantConnections/delete +router.delete("/delete", authMiddleware, deleteParticipantConnection); + +// Get all connections for (eventId + participantId) where participant is primary or secondary +// PUT /api/participantConnections/getByParticipantAndEvent +router.put("/getByParticipantAndEvent", authMiddleware, getConnectionsByParticipantAndEvent); + +// Get all connections for (eventId + userEmail) where user's participant is primary or secondary +// PUT /api/participantConnections/getByUserEmailAndEvent +router.put("/getByUserEmailAndEvent", authMiddleware, getConnectionsByUserEmailAndEvent); + +export default router; \ No newline at end of file diff --git a/shatter-backend/src/utils/requests_utils.ts b/shatter-backend/src/utils/requests_utils.ts new file mode 100644 index 0000000..7eb8258 --- /dev/null +++ b/shatter-backend/src/utils/requests_utils.ts @@ -0,0 +1,16 @@ +import { Request } from "express"; + +/** + * Checks if all required fields are present in the request body. + * @param req - Express request object + * @param requiredFields - Array of required field names + * @returns boolean indicating if all required fields are present + */ +export function check_req_fields(req: Request, requiredFields: string[]): boolean { + for (const field of requiredFields) { + if (!req.body[field]) { + return false; + } + } + return true; +} \ No newline at end of file diff --git a/shatter-mobile/.gitignore b/shatter-mobile/.gitignore index f8c6c2e..b04db4d 100644 --- a/shatter-mobile/.gitignore +++ b/shatter-mobile/.gitignore @@ -41,3 +41,5 @@ app-example # generated native folders /ios /android + +.env \ No newline at end of file diff --git a/shatter-mobile/app/(tabs)/Events.tsx b/shatter-mobile/app/(tabs)/Events.tsx new file mode 100644 index 0000000..f04954f --- /dev/null +++ b/shatter-mobile/app/(tabs)/Events.tsx @@ -0,0 +1,101 @@ +import { getStoredAuth } from "@/src/components/general/AsyncStorage"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { router, useFocusEffect } from "expo-router"; +import { useCallback, useState } from "react"; +import { FlatList, StyleSheet, Text, View } from "react-native"; +import EventCard from "../../src/components/events/EventCard"; +import type Event from "../../src/interfaces/Event"; +import EventIB from "../../src/interfaces/Event"; +import { getUserEvents } from "../../src/services/event.service"; + +const NewEvents = () => { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedEventId, setExpandedEventId] = useState(null); + + //reload event list + const loadEvents = useCallback(async () => { + setLoading(true); + + try { + const stored = await getStoredAuth(); + + if (stored.isGuest) { + const local = await AsyncStorage.getItem("guestEvents"); + const events: EventIB[] = local ? JSON.parse(local) : []; + setEvents(events); + return; + } + + const data = await getUserEvents(stored.userId, stored.accessToken); + setEvents(data?.events || []); + } finally { + setLoading(false); + } + }, []); + + //load list on page mount + useFocusEffect( + useCallback(() => { + loadEvents(); + }, [loadEvents]), + ); + + //dropdown of event + const handlePress = (eventId: string) => { + setExpandedEventId((prev) => (prev === eventId ? null : eventId)); + }; + + if (loading) { + return ( + + Loading events... + + ); + } + + if (events.length === 0) { + return ( + + No events joined + + ); + } + + return ( + + item._id} + renderItem={({ item }) => ( + handlePress(item._id)} + onJoinGame={() => { + router.push({ + pathname: "/Game", + params: { eventId: item._id }, + }); + }} + /> + )} + /> + + ); +}; + +export default NewEvents; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#f8f8f8", + padding: 20, + }, +}); diff --git a/shatter-mobile/app/(tabs)/Game.tsx b/shatter-mobile/app/(tabs)/Game.tsx new file mode 100644 index 0000000..84f8a54 --- /dev/null +++ b/shatter-mobile/app/(tabs)/Game.tsx @@ -0,0 +1,86 @@ +import { useFocusEffect, useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useState } from "react"; +import { ActivityIndicator, StyleSheet, Text, View } from "react-native"; +import IcebreakerGame from "../../src/components/games/IcebreakerGame"; +import { getEventById } from "../../src/services/event.service"; + +const GamePage = () => { + const router = useRouter(); + const searchParams = useLocalSearchParams(); + const eventId = searchParams.eventId.toString(); + const [event, setEvent] = useState(null); + const [loading, setLoading] = useState(true); + + const loadGameData = useCallback(async () => { + setLoading(true); + + if (!eventId) { + router.replace("/Events"); + return; + } + + //fetch the event data + getEventById(eventId) + .then((data) => { + setEvent(data?.event); + }) + .finally(() => setLoading(false)); + }, []); + + useFocusEffect( + useCallback(() => { + loadGameData(); + }, [loadGameData]), + ); + + if (loading) { + return ( + + + Loading event... + + ); + } + + if (!event) { + return ( + + Event not found. + + ); + } + + return ( + + {event.name} + {event.description} + + {/* Game Rendering */} + + + ); +}; + +export default GamePage; + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 20, + backgroundColor: "#f8f8f8", + }, + center: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + title: { + fontSize: 24, + fontWeight: "bold", + marginBottom: 10, + }, + description: { + fontSize: 16, + marginBottom: 20, + }, +}); diff --git a/shatter-mobile/app/(tabs)/Guest.tsx b/shatter-mobile/app/(tabs)/Guest.tsx new file mode 100644 index 0000000..ec7f815 --- /dev/null +++ b/shatter-mobile/app/(tabs)/Guest.tsx @@ -0,0 +1,55 @@ +import { useRouter } from "expo-router"; +import { useState } from "react"; +import { Button, StyleSheet, Text, TextInput, View } from "react-native"; +import { useAuth } from "../../src/components/context/AuthContext"; + +export default function GuestPage() { + const { continueAsGuest } = useAuth(); + const [name, setName] = useState(""); + const [linkedin, setLinkedin] = useState(""); + const router = useRouter(); + + const handleContinue = async () => { + if (!name.trim()) return; + await continueAsGuest(name.trim(), linkedin); + router.replace("/JoinEvent"); + }; + + return ( + + Continue as Guest + + Enter your name so others can identify you. + + + + + + + + + + {/* Stats */} +
+
+
+ 100+ +
+
+ Events Hosted +
+
+
+
+ 5K+ +
+
+ Connections Made +
+
+
+
+ 98% +
+
+ Satisfaction Rate +
+
+
+ + + {/* Right Column - QR Card */} +
+ +
+ + + + {/* Scroll Indicator */} +
+ + + +
+ + {/* Custom Animations */} + + + ); +}; + +export default Hero; diff --git a/shatter-web/src/components/Navbar.tsx b/shatter-web/src/components/Navbar.tsx new file mode 100644 index 0000000..5d6e5b3 --- /dev/null +++ b/shatter-web/src/components/Navbar.tsx @@ -0,0 +1,301 @@ +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; + +import logo from "../assets/ShatterLogo_White.png"; + +export default function Navbar() { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const navigate = useNavigate(); + + // Check if user is logged in on component mount and when localStorage changes + useEffect(() => { + const checkAuthStatus = () => { + const token = localStorage.getItem('token'); + setIsLoggedIn(!!token); + }; + + checkAuthStatus(); + + // Listen for storage changes (logout from another tab) + window.addEventListener('storage', checkAuthStatus); + + // Custom event for login/logout within the same tab + window.addEventListener('authChange', checkAuthStatus); + + return () => { + window.removeEventListener('storage', checkAuthStatus); + window.removeEventListener('authChange', checkAuthStatus); + }; + }, []); + + const toggleMenu = () => { + setIsMenuOpen(!isMenuOpen); + }; + + const handleSignOut = () => { + // Remove token from localStorage + localStorage.removeItem('token'); + + // Update state + setIsLoggedIn(false); + + // Dispatch custom event for other components to react + window.dispatchEvent(new Event('authChange')); + + // Close mobile menu if open + setIsMenuOpen(false); + + // Redirect to home page + navigate('/'); + }; + + return ( + + ); +} diff --git a/shatter-web/src/components/QRCard.tsx b/shatter-web/src/components/QRCard.tsx new file mode 100644 index 0000000..ee139f3 --- /dev/null +++ b/shatter-web/src/components/QRCard.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { QRCodeSVG } from 'qrcode.react'; + +interface QrCardProps { + qrPayload: string; + size?: number; +} + +const QRCard: React.FC = ({ qrPayload, size = 220 }) => ( +
+
+
+

Join Event

+ Scan +
+ +
+ +
+ +

+ Scan the QR to join this creative networking event. +

+ + + Join Code: {qrPayload} + +
+
+); + +export default QRCard; diff --git a/shatter-web/src/components/QRCodeDisplay.tsx b/shatter-web/src/components/QRCodeDisplay.tsx deleted file mode 100644 index ddb9e08..0000000 --- a/shatter-web/src/components/QRCodeDisplay.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useState } from "react"; -import { QRCodeSVG } from 'qrcode.react'; - -function QRCodeDisplay() { - return ( - <> -
- -
- - ); -} - -export default QRCodeDisplay; diff --git a/shatter-web/src/components/TestComponent.tsx b/shatter-web/src/components/TestComponent.tsx deleted file mode 100644 index ab13f14..0000000 --- a/shatter-web/src/components/TestComponent.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useState } from "react"; - -function TestComponent() { - return ( - <> -
-

Hello!!!!!

-
- - ); -} - -export default TestComponent; diff --git a/shatter-web/src/hooks/useEventData.ts b/shatter-web/src/hooks/useEventData.ts new file mode 100644 index 0000000..0116d51 --- /dev/null +++ b/shatter-web/src/hooks/useEventData.ts @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; +import type { Participant } from "../types/participant"; + +interface EventDetails { + _id: string; + name: string; + description: string; + joinCode: string; + startDate: string; + endDate: string; + maxParticipant: number; + currentState: string; + participantIds: Participant[]; +} + +interface EventResponse { + success: boolean; + event: EventDetails; +} + +export function useEventData(joinCode: string | undefined) { + const [eventId, setEventId] = useState(null); + const [eventDetails, setEventDetails] = useState(null); + const [participants, setParticipants] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!joinCode) { + setLoading(false); + return; + } + + setLoading(true); + setError(null); + + fetch(`${import.meta.env.VITE_API_URL}/events/event/${joinCode}`, { + cache: "no-store", + }) + .then((res) => { + if (!res.ok) { + throw new Error("Event not found"); + } + return res.json(); + }) + .then((data: EventResponse) => { + if (data.success && data.event) { + setEventId(data.event._id); + setEventDetails(data.event); + setParticipants(data.event.participantIds); + } else { + setEventId(null); + setEventDetails(null); + setError("Event not found"); + } + }) + .catch((err) => { + console.error("Error fetching event:", err); + setEventId(null); + setEventDetails(null); + setError(err.message || "Failed to load event"); + }) + .finally(() => setLoading(false)); + }, [joinCode]); + + return { eventId, eventDetails, participants, loading, error }; +} diff --git a/shatter-web/src/hooks/useEventParticipants.ts b/shatter-web/src/hooks/useEventParticipants.ts new file mode 100644 index 0000000..a762b82 --- /dev/null +++ b/shatter-web/src/hooks/useEventParticipants.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import { pusher } from "../libs/pusher_websocket"; +import type { Participant } from "../types/participant"; + +export function useEventParticipants( + eventId: string | null, + initialParticipants: Participant[], +) { + const [participants, setParticipants] = useState([]); + + useEffect(() => { + if (initialParticipants.length > 0) { + setParticipants(initialParticipants); + } + }, [initialParticipants]); + + useEffect(() => { + if (!eventId) return; + + const channelName = `event-${eventId}`; + const channel = pusher.subscribe(channelName); + + const handleParticipantJoined = (p: Participant) => { + setParticipants((prev) => { + if (prev.some((x) => x.participantId === p.participantId)) { + return prev; + } + return [...prev, p]; + }); + }; + + channel.bind("participant-joined", handleParticipantJoined); + + return () => { + channel.unbind("participant-joined", handleParticipantJoined); + pusher.unsubscribe(channelName); + }; + }, [eventId]); + + return { participants }; +} diff --git a/shatter-web/src/libs/pusher_websocket.ts b/shatter-web/src/libs/pusher_websocket.ts new file mode 100644 index 0000000..5d12c19 --- /dev/null +++ b/shatter-web/src/libs/pusher_websocket.ts @@ -0,0 +1,9 @@ +import Pusher from "pusher-js"; + +export const pusher = new Pusher( + import.meta.env.VITE_PUSHER_KEY, + { + cluster: import.meta.env.VITE_PUSHER_CLUSTER, + forceTLS: true, + } +); diff --git a/shatter-web/src/main.tsx b/shatter-web/src/main.tsx index bef5202..85a16d5 100644 --- a/shatter-web/src/main.tsx +++ b/shatter-web/src/main.tsx @@ -1,10 +1,13 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import "./index.css"; +import App from "./App.tsx"; -createRoot(document.getElementById('root')!).render( +createRoot(document.getElementById("root")!).render( - - , -) + + + + +); diff --git a/shatter-web/src/pages/CreateEventPage.tsx b/shatter-web/src/pages/CreateEventPage.tsx new file mode 100644 index 0000000..effc0b2 --- /dev/null +++ b/shatter-web/src/pages/CreateEventPage.tsx @@ -0,0 +1,276 @@ +import React, { useState } from "react"; +import { useEffect } from "react"; +import Navbar from "../components/Navbar"; +import Footer from "../components/Footer"; +import { CreateEvent } from "../service/CreateEvent"; +import { createBingoGame } from "../service/BingoGame"; // ✅ NEW +import { useNavigate } from "react-router-dom"; + +function CreateEventPage() { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [maxParticipant, setMaxParticipant] = useState( + undefined + ); + + // ✅ NEW: Name Bingo selection + const [nameBingoSelected, setNameBingoSelected] = useState(false); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const navigate = useNavigate(); + + const handleCreateEvent = async () => { + try { + setLoading(true); + setError(null); + + // 1️⃣ Create event + const { eventId, joinCode } = await CreateEvent({ + name, + description, + startDate, + endDate, + maxParticipants: maxParticipant ?? 0, + }); + + // 2️⃣ If Name Bingo selected, create bingo game + if (nameBingoSelected) { + const token = localStorage.getItem("token"); + if (!token) { + throw new Error("Authentication required to create bingo game."); + } + + await createBingoGame(eventId, token); + } + + // 3️⃣ Navigate to the newly created event page + navigate(`/events/${joinCode}`); + } catch (err: any) { + setError(err.message || "Failed to create event. Please try again."); + setLoading(false); + } + }; + + const handleDiscard = () => { + setName(""); + setDescription(""); + setStartDate(""); + setEndDate(""); + setMaxParticipant(undefined); + setNameBingoSelected(false); + setError(null); + navigate("/"); + }; + + // Form validation + const isFormValid = + name.trim() !== "" && + description.trim() !== "" && + startDate.trim() !== "" && + maxParticipant !== undefined && + maxParticipant > 0; + + return ( +
+ + +
+

+ Create Event +

+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Form Grid */} +
+ {/* Event Name */} +
+ + setName(e.target.value)} + disabled={loading} + /> +
+ + {/* Description */} +
+ +