diff --git a/shatter-backend/docs/API_REFERENCE.md b/shatter-backend/docs/API_REFERENCE.md index d67b14b..bd2120f 100644 --- a/shatter-backend/docs/API_REFERENCE.md +++ b/shatter-backend/docs/API_REFERENCE.md @@ -18,6 +18,7 @@ - [POST `/api/auth/signup`](#post-apiauthsignup) - [POST `/api/auth/login`](#post-apiauthlogin) - [GET `/api/auth/linkedin`](#get-apiauthlinkedin) + - [GET `/api/auth/linkedin/link`](#get-apiauthlinkedinlink) - [GET `/api/auth/linkedin/callback`](#get-apiauthlinkedincallback) - [POST `/api/auth/exchange`](#post-apiauthexchange) - [Users (`/api/users`)](#users-apiusers) @@ -45,7 +46,8 @@ - [POST `/api/bingo/createBingo`](#post-apibingocreatebingo) - [GET `/api/bingo/getBingo/:eventId`](#get-apibingogetbingoeventid) - [PUT `/api/bingo/updateBingo`](#put-apibingoupdatebingo) - - [POST `/api/bingo/generate`](#post-apibingogenerate) + - [POST `/api/bingo/generateBingo`](#post-apibingogeneratebingo) + - [POST `/api/bingo/generate/individual`](#post-apibingogenerateindividual) - [Participant Connections (`/api/participantConnections`)](#participant-connections-apiparticipantconnections) - [POST `/api/participantConnections/`](#post-apiparticipantconnections) - [POST `/api/participantConnections/by-emails`](#post-apiparticipantconnectionsby-emails) @@ -74,6 +76,7 @@ Quick reference of all implemented endpoints. See detailed sections below for re | POST | `/api/auth/signup` | Public | Create new user account | | POST | `/api/auth/login` | Public | Log in with email + password | | GET | `/api/auth/linkedin` | Public | Initiate LinkedIn OAuth flow | +| GET | `/api/auth/linkedin/link` | Protected | Link LinkedIn to existing account (guest or local) | | GET | `/api/auth/linkedin/callback` | Public | LinkedIn OAuth callback (not called directly) | | POST | `/api/auth/exchange` | Public | Exchange OAuth auth code for JWT | | GET | `/api/users` | Public | List all users | @@ -243,12 +246,32 @@ Initiate LinkedIn OAuth flow. Redirects the browser to LinkedIn's authorization --- +### GET `/api/auth/linkedin/link` + +Initiate LinkedIn OAuth flow for linking a LinkedIn account to an existing user. Redirects to LinkedIn's authorization page with the user's identity encoded in the state token. + +- **Auth:** Protected (guest or local users only) +- **Response:** 302 redirect to LinkedIn + +**Error Responses:** + +| Status | Condition | +|--------|-----------| +| 401 | Missing or invalid JWT | +| 403 | Account is already a LinkedIn account | +| 404 | User not found | +| 409 | LinkedIn account already linked | + +**Flow:** After LinkedIn authorization, the callback detects the linking context from the state token and attaches the LinkedIn profile to the existing user. Guest users get upgraded to `authProvider: 'linkedin'`. Local users keep `authProvider: 'local'` (preserves password login) and gain LinkedIn profile data. + +--- + ### GET `/api/auth/linkedin/callback` -LinkedIn OAuth callback. Not called directly by frontend — LinkedIn redirects here after user authorization. +LinkedIn OAuth callback. Not called directly by frontend - LinkedIn redirects here after user authorization. - **Auth:** Public (called by LinkedIn) -- **Flow:** Verifies CSRF state → exchanges code for access token → fetches LinkedIn profile → upserts user → creates single-use auth code → redirects to frontend with `?code=` +- **Flow:** Verifies CSRF state -> exchanges code for access token -> fetches LinkedIn profile -> upserts user (or links to guest account) -> creates single-use auth code -> redirects to frontend with `?code=` **Redirect on success:** `{FRONTEND_URL}/auth/callback?code=` **Redirect on error:** `{FRONTEND_URL}/auth/error?message=` @@ -537,7 +560,7 @@ Update a user's profile. Users can only update their own profile. | `password` | string | Minimum 8 characters | | `bio` | string | | | `profilePhoto` | string | URL | -| `socialLinks` | object | `{ linkedin?, github?, other? }` | +| `socialLinks` | object | `{ linkedin?: string, github?: string, other?: Array<{ label: string, url: string }> }` — each `other` entry has a required `label` and `url`; the array fully replaces the previous one on update | | `organization` | string | Where the user works/studies | | `title` | string | Job title or role | @@ -831,7 +854,7 @@ Join an event as a guest (no account required). | `socialLinks` | object | No* | Social links | | `socialLinks.linkedin` | string | No | LinkedIn URL | | `socialLinks.github` | string | No | GitHub URL | -| `socialLinks.other` | string | No | Other URL | +| `socialLinks.other` | `Array<{label, url}>` | No | Additional labeled social/profile links. Each entry requires both `label` (display name) and `url`. | | `organization` | string | No* | Where the guest works/studies | | `title` | string | No | Job title or role | @@ -1287,30 +1310,33 @@ Update a bingo game. ### POST `/api/bingo/generateBingo` -Generate an AI-powered bingo grid based on a given context. +Generate an AI-powered bingo grid based on a given event description and attendee tags. -- **Auth:** Protected +- **Auth:** Not Protected **Request Body:** -| Field | Type | Required | Notes | -|-----------|--------|----------|-------| -| `context` | string | Yes | Context used to generate bingo content | -| `n_rows` | number | Yes | Number of rows (1–5) | -| `n_cols` | number | Yes | Number of columns (1–5) | +| Field | Type | Required | Notes | +|---|---|---|---| +| `event_description` | string | Yes | General information about what the event is about. Cannot be empty | +| `tags` | string[] | Yes | List of professional types, roles, job titles, specializations, or departments attending the event. Can be an empty array | +| `n_rows` | number | Yes | Number of rows (1–5) | +| `n_cols` | number | Yes | Number of columns (1–5) | **Example Request:** ```json { - "context": "Software engineer networking event where developers meet, discuss tech stacks, exchange ideas, talk about startups, open source, AI, and career opportunities", + "event_description": "Software engineer networking event where developers meet, discuss tech stacks, exchange ideas, talk about startups, open source, AI, and career opportunities", + "tags": ["software engineers", "frontend developers", "backend developers", "startup founders", "product managers"], "n_rows": 2, "n_cols": 2 } ``` **Example Response:** -``` + +```json { "status": true, "bingo_grid": [ @@ -1338,6 +1364,115 @@ Generate an AI-powered bingo grid based on a given context. } ``` +**Validation Errors:** + +Missing or invalid `event_description`: + +```json +{ + "status": false, + "msg": "event_description is required and must be a non-empty string" +} +``` + +Missing or invalid `tags`: + +```json +{ + "status": false, + "msg": "tags is required and must be an array of strings" +} +``` + +Missing or invalid `n_rows` or `n_cols`: + +```json +{ + "status": false, + "msg": "n_rows and n_cols must be numbers where 0 < value <= 5" +} +``` + +### POST `/api/bingo/generate/individual` + +Generate replacement AI-powered bingo questions for one or more target questions in an existing bingo grid. + +The newly generated questions should: +- Match the provided event context +- Be different from the existing questions in the bingo grid +- Be different from each other +- Include both the full question and a short version + +- **Auth:** Not Protected + +**Request Body:** + +| Field | Type | Required | Notes | +|---|---|---|---| +| `event_description` | string | Yes | Event context used to generate the new bingo questions. Cannot be empty | +| `tags` | string[] | Yes | Types or roles of people attending the event. Can be an empty array | +| `bingo_grid` | string[][] | Yes | Existing bingo grid containing the full question strings | +| `bingo_question_target` | string[] | Yes | List of questions intended to be regenerated/replaced. Must be a non-empty array of non-empty strings with no duplicates | + +**Example Request:** + +```json +{ + "event_description": "A networking event for software engineers, product managers, startup founders, designers, and AI researchers focused on building practical AI products.", + "tags": [ + "software engineer", + "product manager", + "startup founder", + "designer", + "AI researcher" + ], + "bingo_grid": [ + [ + "Shows a demo on their phone", + "Mentions their team is hiring", + "Explains a project they built" + ], + [ + "Has stickers all over their laptop", + "Recently switched jobs", + "Sketches an idea while talking" + ], + [ + "Arrives straight from work", + "Recognizes someone from LinkedIn", + "Talks about scaling an AI product" + ] + ], + "bingo_question_target": [ + "Mentions their team is hiring", + "Recently switched jobs", + "Talks about scaling an AI product" + ] +} +``` + +**Example Response:** + +```json +{ + "status": true, + "new_questions": [ + { + "question": "References a specific AI framework", + "shortQuestion": "AI Framework" + }, + { + "question": "Wears apparel from their startup", + "shortQuestion": "Startup Merch" + }, + { + "question": "Shows a code snippet on their phone", + "shortQuestion": "Code Snippet" + } + ] +} +``` + ## Participant Connections (`/api/participantConnections`) ### POST `/api/participantConnections/` diff --git a/shatter-backend/docs/DATABASE_SCHEMA.md b/shatter-backend/docs/DATABASE_SCHEMA.md index 4835720..4cc4a4d 100644 --- a/shatter-backend/docs/DATABASE_SCHEMA.md +++ b/shatter-backend/docs/DATABASE_SCHEMA.md @@ -67,7 +67,7 @@ | `title` | String | No | — | Trimmed | | `bio` | String | No | — | Trimmed | | `profilePhoto` | String | No | — | | -| `socialLinks` | Object | No | — | `{ linkedin?: String, github?: String, other?: String }` | +| `socialLinks` | Object | No | — | `{ linkedin?: String, github?: String, other?: Array<{ label: String, url: String }> }` — each `other` entry has a required `label` and `url` so users can add multiple labeled miscellaneous links | | `authProvider` | String (enum) | Yes | `'local'` | One of: `'local'`, `'linkedin'`, `'guest'` | | `lastLogin` | Date | No | `null` | | | `passwordChangedAt`| Date | No | `null` | | diff --git a/shatter-backend/src/controllers/auth_controller.ts b/shatter-backend/src/controllers/auth_controller.ts index 9303990..7aa3c11 100644 --- a/shatter-backend/src/controllers/auth_controller.ts +++ b/shatter-backend/src/controllers/auth_controller.ts @@ -212,6 +212,51 @@ export const linkedinAuth = async (req: Request, res: Response) => { }; +/** + * GET /api/auth/linkedin/link + * Initiates LinkedIn OAuth flow for linking a LinkedIn account to an existing user. + * Protected route - requires JWT auth (guest or local users only). + */ +export const linkedinLink = async (req: Request, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const user = await User.findById(userId); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + if (user.authProvider === 'linkedin') { + return res.status(403).json({ + error: 'Account is already a LinkedIn account', + }); + } + + if (user.linkedinId) { + return res.status(409).json({ + error: 'LinkedIn account already linked', + }); + } + + // Encode linking context into the state JWT (signed, tamper-proof) + const stateToken = jwt.sign( + { linking: true, userId: user._id.toString() }, + JWT_SECRET, + { expiresIn: '5m' } + ); + + const authUrl = getLinkedInAuthUrl(stateToken); + res.redirect(authUrl); + } catch (error) { + console.error('LinkedIn link initiation error:', error); + res.status(500).json({ error: 'Failed to initiate LinkedIn linking' }); + } +}; + + /** * GET /api/auth/linkedin/callback * LinkedIn redirects here after user authorization @@ -236,10 +281,16 @@ export const linkedinCallback = async (req: Request, res: Response) => { return res.status(400).json({ error: 'Missing code or state parameter' }); } - // Verify state token (CSRF protection) + // Verify state token (CSRF protection) and extract payload + let statePayload: { linking?: boolean; userId?: string }; try { - jwt.verify(state, JWT_SECRET); - } catch { + statePayload = jwt.verify(state, JWT_SECRET) as { linking?: boolean; userId?: string }; + } catch (e: any) { + console.error('LinkedIn state verify failed:', { + name: e?.name, + message: e?.message, + stateLen: state?.length, + }); return res.status(401).json({ error: 'Invalid state parameter' }); } @@ -257,34 +308,92 @@ export const linkedinCallback = async (req: Request, res: Response) => { }); } - // Find existing user by LinkedIn ID - let user = await User.findOne({ linkedinId: linkedinProfile.sub }); + let user; - if (!user) { - // Check if email already exists with password auth (email conflict) - const existingEmailUser = await User.findOne({ - email: linkedinProfile.email.toLowerCase().trim(), - }); + if (statePayload.linking && statePayload.userId) { + // --- LinkedIn linking flow (guest or local -> attach LinkedIn) --- + + // Check if this LinkedIn account is already linked to another user + const existingLinkedinUser = await User.findOne({ linkedinId: linkedinProfile.sub }); + if (existingLinkedinUser) { + return res.redirect( + `${frontendUrl}/auth/error?message=This LinkedIn account is already linked to another user` + ); + } - if (existingEmailUser) { + // Look up the user to determine their current authProvider + const existingUser = await User.findById(statePayload.userId); + if (!existingUser || existingUser.authProvider === 'linkedin') { return res.redirect( - `${frontendUrl}/auth/error?message=Email already registered with password&suggestion=Please login with your password` + `${frontendUrl}/auth/error?message=Account not found or already a LinkedIn account` ); } - // Create new user from LinkedIn data - user = await User.create({ - name: linkedinProfile.name, - email: linkedinProfile.email.toLowerCase().trim(), + const updateFields: Record = { linkedinId: linkedinProfile.sub, - profilePhoto: linkedinProfile.picture, - authProvider: 'linkedin', lastLogin: new Date(), - }); + }; + + // Guest users get fully upgraded to linkedin authProvider + // Local users keep their authProvider (preserves password login) + if (existingUser.authProvider === 'guest') { + updateFields.authProvider = 'linkedin'; + } + + // Only fill in fields that are currently empty + if (linkedinProfile.email && !existingUser.email) { + updateFields.email = linkedinProfile.email.toLowerCase().trim(); + } + if (linkedinProfile.picture && !existingUser.profilePhoto) { + updateFields.profilePhoto = linkedinProfile.picture; + } + if (linkedinProfile.name && existingUser.authProvider === 'guest') { + updateFields.name = linkedinProfile.name; + } + + user = await User.findByIdAndUpdate( + statePayload.userId, + { $set: updateFields }, + { new: true } + ); + + if (!user) { + return res.redirect( + `${frontendUrl}/auth/error?message=Account not found` + ); + } } else { - // Update existing user's last login - user.lastLogin = new Date(); - await user.save(); + // --- Normal LinkedIn signup/login flow --- + + // Find existing user by LinkedIn ID + user = await User.findOne({ linkedinId: linkedinProfile.sub }); + + if (!user) { + // Check if email already exists with password auth (email conflict) + const existingEmailUser = await User.findOne({ + email: linkedinProfile.email.toLowerCase().trim(), + }); + + if (existingEmailUser) { + return res.redirect( + `${frontendUrl}/auth/error?message=Email already registered with password&suggestion=Please login with your password` + ); + } + + // Create new user from LinkedIn data + user = await User.create({ + name: linkedinProfile.name, + email: linkedinProfile.email.toLowerCase().trim(), + linkedinId: linkedinProfile.sub, + profilePhoto: linkedinProfile.picture, + authProvider: 'linkedin', + lastLogin: new Date(), + }); + } else { + // Update existing user's last login + user.lastLogin = new Date(); + await user.save(); + } } // Generate single-use auth code and redirect to frontend diff --git a/shatter-backend/src/controllers/bingo_controller.ts b/shatter-backend/src/controllers/bingo_controller.ts index 0a96455..a07c7fd 100644 --- a/shatter-backend/src/controllers/bingo_controller.ts +++ b/shatter-backend/src/controllers/bingo_controller.ts @@ -342,37 +342,43 @@ function buildShapeExample(rows: number, cols: number): string { async function generateBingoGrid( n_rows: number, n_cols: number, - context: string, + event_description: string, + tags: string[], ): Promise> { const schema = buildSchema(n_rows, n_cols); const schemaJson = z.toJSONSchema(schema); const example = buildShapeExample(n_rows, n_cols); - const { GoogleGenAI } = await import("@google/genai"); - const basePrompt_structure = `Generate a ${n_rows}x${n_cols} bingo board. Return JSON exactly matching this structure: ${example} + Rules: - Keys must be row1, row2, row3, etc. - Each row must contain ${n_cols} strings. - Return ONLY valid JSON. - - You will be provided with additional context to inspire the content of the bingo squares. Use that context to generate relevant bingo square phrases. + - Return ONLY valid JSON. + - Each entry should be a full bingo question or bingo square phrase. + - Every entry must fit the provided event description. + - Use the tags as guidance for the types of professionals, roles, departments, or specializations attending the event. + - Tags can be empty. If no tags are provided, rely only on the event description. + - Avoid duplicate or near-duplicate entries. + - Avoid generic networking items unless they are made specific to the event context. `.trim(); - const userContext = `Additional context for bingo content:\n${context}`; - // const promptPath = new URL( - // "../ai/prompts/bingo_short_questions.txt", - // import.meta.url, - // ); - // const aiInstruction = fs.readFileSync(promptPath, "utf-8"); + const eventContext = ` +Event description: +${event_description} + +Tags / attendee professional types: +${tags.length > 0 ? tags.join(", ") : "No tags provided"} +`.trim(); const aiPrompt = new Prompt([ basePrompt_structure, - userContext, + eventContext, aiShortVersionInstruction, ]); + aiPrompt.generatePrompt(); const prompt = aiPrompt.getPrompt(); @@ -492,7 +498,8 @@ function combine2DArrays( * * Generate an AI bingo grid. * - * @param req.body.context - Context for bingo content (required) + * @param req.body.event_description - General event context for bingo content (required) + * @param req.body.tags - Types of professionals, roles, departments, or specializations attending the event (required, can be empty) * @param req.body.n_rows - Number of grid rows (1-5) * @param req.body.n_cols - Number of grid columns (1-5) * @@ -501,12 +508,27 @@ function combine2DArrays( */ export async function generateBingo(req: Request, res: Response) { try { - const { context, n_rows, n_cols } = req.body; + const { event_description, tags, n_rows, n_cols } = req.body; + + if ( + !event_description || + typeof event_description !== "string" || + event_description.trim().length === 0 + ) { + return res.status(400).json({ + status: false, + msg: "event_description is required and must be a non-empty string", + }); + } - if (!context) { + if ( + tags === undefined || + !Array.isArray(tags) || + !tags.every((tag: any) => typeof tag === "string") + ) { return res.status(400).json({ status: false, - msg: "context is required", + msg: "tags is required and must be an array of strings", }); } @@ -524,15 +546,26 @@ export async function generateBingo(req: Request, res: Response) { }); } - const bingo_questions = await generateBingoGrid(n_rows, n_cols, context); + const cleanedTags = tags.map((tag: string) => tag.trim()).filter(Boolean); + + const bingo_questions = await generateBingoGrid( + n_rows, + n_cols, + event_description.trim(), + cleanedTags, + ); + const bingo_short_versions = await generateBingoGrid_shortVersions( n_rows, n_cols, JSON.stringify(bingo_questions), ); + const bingo_grid_questions: string[][] = process_ai_result(bingo_questions); + const bingo_grid_short_versions: string[][] = process_ai_result(bingo_short_versions); + const bingo_grid = combine2DArrays( bingo_grid_questions, bingo_grid_short_versions, @@ -549,3 +582,272 @@ export async function generateBingo(req: Request, res: Response) { }); } } + +function buildIndividualBingoGameQuestionsSchema(questionCount: number) { + return z + .array( + z.object({ + question: z + .string() + .min(1) + .describe("The full generated bingo question"), + shortQuestion: z + .string() + .min(1) + .describe("A max 3 word short version of the question"), + }), + ) + .length(questionCount) + .describe("The regenerated bingo questions"); +} + +function normalizeQuestion(question: string): string { + return question.trim().toLowerCase().replace(/\s+/g, " "); +} + +function hasDuplicateQuestions( + generatedQuestions: { question: string; shortQuestion: string }[], +): boolean { + const seenQuestions = new Set(); + const seenShortQuestions = new Set(); + + for (const generatedQuestion of generatedQuestions) { + const question = normalizeQuestion(generatedQuestion.question); + const shortQuestion = normalizeQuestion(generatedQuestion.shortQuestion); + + if (seenQuestions.has(question) || seenShortQuestions.has(shortQuestion)) { + return true; + } + + seenQuestions.add(question); + seenShortQuestions.add(shortQuestion); + } + + return false; +} + +function hasSourceQuestionCollision( + generatedQuestions: { question: string; shortQuestion: string }[], + bingo_grid: string[][], + bingo_question_target: string[], +): boolean { + const existingQuestions = new Set( + [...bingo_grid.flat(), ...bingo_question_target].map((question) => + normalizeQuestion(question), + ), + ); + + return generatedQuestions.some((generatedQuestion) => + existingQuestions.has(normalizeQuestion(generatedQuestion.question)), + ); +} + +async function generateIndividualBingoGameQuestionsWithGemini({ + event_description, + tags, + bingo_grid, + bingo_question_target, +}: { + event_description: string; + tags: string[]; + bingo_grid: string[][]; + bingo_question_target: string[]; +}): Promise<{ question: string; shortQuestion: string }[]> { + const schema = buildIndividualBingoGameQuestionsSchema( + bingo_question_target.length, + ); + const schemaJson = z.toJSONSchema(schema); + + const basePrompt = ` +Generate ${bingo_question_target.length} new bingo questions for an event bingo game. + +Return JSON exactly matching this structure: +[ + { + "question": "Full bingo question here", + "shortQuestion": "Max 3 words" + } +] + +Rules: +- Return ONLY valid JSON. +- Generate exactly ${bingo_question_target.length} new question objects. +- The response must be a JSON array, not an object. +- Preserve the same order as the target questions list: one replacement per target question. +- Each new question must fit the event context. +- Every new question must be different from every existing question in the bingo grid. +- Every new question must be different from every other newly generated question. +- Avoid near-duplicates, reworded duplicates, or questions with the same meaning. +- The target questions are being regenerated, but each replacement does NOT need to be about the same topic as its target. +- Each replacement should be entirely new while still fitting the event. +- Each shortQuestion must be max 3 words. +- Each shortQuestion should describe what its full question is about. +- The shortQuestion values must also be different from each other. +- Avoid generic networking questions. +- Prefer observable, realistic, event-specific bingo moments. +`.trim(); + + const eventContext = ` +Event description: +${event_description} + +Tags / attendee roles: +${tags.length > 0 ? tags.join(", ") : "No tags provided"} + +Existing bingo grid questions: +${JSON.stringify(bingo_grid, null, 2)} + +Target questions to replace: +${JSON.stringify(bingo_question_target, null, 2)} +`.trim(); + + const aiPrompt = new Prompt([ + basePrompt, + eventContext, + aiShortVersionInstruction, + ]); + + aiPrompt.generatePrompt(); + const prompt = aiPrompt.getPrompt(); + + const response = await ai.models.generateContent({ + model: "gemini-2.5-flash", + contents: prompt, + config: { + responseMimeType: "application/json", + responseJsonSchema: schemaJson, + temperature: 0.8, + }, + }); + + if (!response.text) { + throw new Error("Gemini returned empty response"); + } + + const parsed = JSON.parse(response.text); + const validated = schema.parse(parsed); + + if (hasDuplicateQuestions(validated)) { + throw new Error("Gemini returned duplicate generated questions"); + } + + if (hasSourceQuestionCollision(validated, bingo_grid, bingo_question_target)) { + throw new Error( + "Gemini returned a generated question that already exists in the bingo grid or target questions", + ); + } + + return validated; +} + + +/** + * POST /api/bingo/generate/single + * + * Generate replacement AI bingo questions. + * + * @param req.body.event_description - Context for bingo content (required) - string + * @param req.body.tags - Tags for the type/roles of people attending the event - string[] can be empty + * @param req.body.bingo_grid - Existing bingo grid of full questions - string[][] + * @param req.body.bingo_question_target - Target questions to regenerate - string[] + * + * @returns 200 with a list of generated bingo questions and short versions + * @returns 400 if validation fails + */ +export async function generateIndividualBingoGameQuestions( + req: Request, + res: Response, +) { + try { + const { + event_description, + tags, + bingo_grid, + bingo_question_target, + } = req.body; + + if ( + !event_description || + typeof event_description !== "string" || + event_description.trim().length === 0 + ) { + return res.status(400).json({ + status: false, + msg: "event_description is required and must be a non-empty string", + }); + } + + if ( + tags === undefined || + !Array.isArray(tags) || + !tags.every((tag: any) => typeof tag === "string") + ) { + return res.status(400).json({ + status: false, + msg: "tags is required and must be an array of strings", + }); + } + + if ( + !Array.isArray(bingo_grid) || + bingo_grid.length === 0 || + !bingo_grid.every( + (row: any) => + Array.isArray(row) && + row.every((cell: any) => typeof cell === "string"), + ) + ) { + return res.status(400).json({ + status: false, + msg: "bingo_grid is required and must be a 2D array of strings", + }); + } + + if ( + !Array.isArray(bingo_question_target) || + bingo_question_target.length === 0 || + !bingo_question_target.every( + (question: any) => + typeof question === "string" && question.trim().length > 0, + ) + ) { + return res.status(400).json({ + status: false, + msg: "bingo_question_target is required and must be a non-empty array of non-empty strings", + }); + } + + const cleanedTags = tags.map((tag: string) => tag.trim()).filter(Boolean); + const cleanedTargetQuestions = bingo_question_target.map((question: string) => + question.trim(), + ); + const uniqueTargetQuestions = new Set( + cleanedTargetQuestions.map((question: string) => normalizeQuestion(question)), + ); + + if (uniqueTargetQuestions.size !== cleanedTargetQuestions.length) { + return res.status(400).json({ + status: false, + msg: "bingo_question_target must not contain duplicate questions", + }); + } + + const generatedQuestions = await generateIndividualBingoGameQuestionsWithGemini({ + event_description: event_description.trim(), + tags: cleanedTags, + bingo_grid, + bingo_question_target: cleanedTargetQuestions, + }); + + return res.status(200).json({ + status: true, + new_questions: generatedQuestions, + }); + + } catch (err: any) { + return res.status(500).json({ + status: false, + error: err.message, + }); + } +} diff --git a/shatter-backend/src/controllers/event_controller.ts b/shatter-backend/src/controllers/event_controller.ts index 561cc57..0031b82 100644 --- a/shatter-backend/src/controllers/event_controller.ts +++ b/shatter-backend/src/controllers/event_controller.ts @@ -277,7 +277,7 @@ export async function joinEventAsGuest(req: Request, res: Response) { const { name, email, socialLinks, organization, title } = req.body as { name?: string; email?: string; - socialLinks?: { linkedin?: string; github?: string; other?: string }; + socialLinks?: { linkedin?: string; github?: string; other?: { label: string; url: string }[] }; organization?: string; title?: string; }; @@ -295,7 +295,7 @@ export async function joinEventAsGuest(req: Request, res: Response) { const hasSocialLink = socialLinks && ( socialLinks.linkedin?.trim() || socialLinks.github?.trim() || - socialLinks.other?.trim() + socialLinks.other?.some((entry) => entry?.url?.trim()) ); const hasOrganization = organization && organization.trim(); @@ -498,8 +498,7 @@ export async function updateEventStatus(req: Request, res: Response) { const updatedEvent = await event.save(); // Emit Pusher events for real-time updates - const pusherEvent = status === 'In Progress' ? 'event-started' : 'event-ended'; - await pusher.trigger(`event-${eventId}`, pusherEvent, { + await pusher.trigger(`event-${eventId}`, 'event', { status, }); diff --git a/shatter-backend/src/controllers/leaderboard_controller.ts b/shatter-backend/src/controllers/leaderboard_controller.ts index 3305169..62e0f0c 100644 --- a/shatter-backend/src/controllers/leaderboard_controller.ts +++ b/shatter-backend/src/controllers/leaderboard_controller.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; import { Participant } from "../models/participant_model.js"; import { ParticipantConnection } from "../models/participant_connection_model.js"; -import { pusher } from "../utils/pusher_websocket.js"; +import { emitLeaderboardUpdate } from "../utils/leaderboard_pusher.js"; /** * GET /api/events/:eventId/leaderboard @@ -134,8 +134,10 @@ export async function updateScore(req: Request, res: Response) { }); } - // Compute connections count for the Pusher payload const participantId = (participant._id as Types.ObjectId).toString(); + + await emitLeaderboardUpdate(eventId, participant._id as Types.ObjectId); + const connections = await ParticipantConnection.find({ _eventId: new Types.ObjectId(eventId), $or: [ @@ -155,15 +157,6 @@ export async function updateScore(req: Request, res: Response) { uniquePartners.add(otherId); } - // Trigger live update - await pusher.trigger(`event-${eventId}`, "leaderboard-updated", { - participantId, - name: participant.name, - linesCompleted: participant.linesCompleted, - connectionsCount: uniquePartners.size, - completed: participant.completed, - }); - return res.status(200).json({ participantId, name: participant.name, diff --git a/shatter-backend/src/controllers/participant_connections_controller.ts b/shatter-backend/src/controllers/participant_connections_controller.ts index 08653b1..66b8bb1 100644 --- a/shatter-backend/src/controllers/participant_connections_controller.ts +++ b/shatter-backend/src/controllers/participant_connections_controller.ts @@ -7,7 +7,7 @@ import { check_req_fields } from "../utils/requests_utils.js"; import { User } from "../models/user_model.js"; import { Participant } from "../models/participant_model.js"; import { ParticipantConnection } from "../models/participant_connection_model.js"; -import { pusher } from "../utils/pusher_websocket.js"; +import { emitLeaderboardUpdate } from "../utils/leaderboard_pusher.js"; /** * POST /api/participantConnections @@ -105,7 +105,10 @@ export async function createParticipantConnection(req: Request, res: Response) { description, }); - await pusher.trigger(`event-${_eventId}`, "leaderboard-updated", {}); + await Promise.all([ + emitLeaderboardUpdate(_eventId, primaryParticipantId), + emitLeaderboardUpdate(_eventId, secondaryParticipantId), + ]); return res.status(201).json(newConnection); } catch (_error) { @@ -232,7 +235,10 @@ export async function createParticipantConnectionByEmails( description, }); - await pusher.trigger(`event-${_eventId}`, "leaderboard-updated", {}); + await Promise.all([ + emitLeaderboardUpdate(_eventId, primaryParticipant._id as Types.ObjectId), + emitLeaderboardUpdate(_eventId, secondaryParticipant._id as Types.ObjectId), + ]); return res.status(201).json(newConnection); } catch (_error) { @@ -276,7 +282,10 @@ export async function deleteParticipantConnection(req: Request, res: Response) { .json({ error: "ParticipantConnection not found for this event" }); } - await pusher.trigger(`event-${eventId}`, "leaderboard-updated", {}); + await Promise.all([ + emitLeaderboardUpdate(eventId, deleted.primaryParticipantId), + emitLeaderboardUpdate(eventId, deleted.secondaryParticipantId), + ]); return res.status(200).json({ message: "ParticipantConnection deleted successfully", @@ -441,7 +450,10 @@ export async function getConnectedUsersInfo(req: Request, res: Response) { _id: { $in: participantIds }, }) .select("userId name") - .populate("userId", "name email linkedinUrl bio profilePhoto socialLinks"); + .populate( + "userId", + "name email linkedinUrl bio profilePhoto socialLinks", + ); const participantMap = new Map( participants.map((p) => [(p._id as Types.ObjectId).toString(), p]), diff --git a/shatter-backend/src/controllers/user_controller.ts b/shatter-backend/src/controllers/user_controller.ts index 54a1ea9..12b29ae 100644 --- a/shatter-backend/src/controllers/user_controller.ts +++ b/shatter-backend/src/controllers/user_controller.ts @@ -121,7 +121,7 @@ export const updateUser = async (req: Request, res: Response) => { password?: string; bio?: string; profilePhoto?: string; - socialLinks?: { linkedin?: string; github?: string; other?: string }; + socialLinks?: { linkedin?: string; github?: string; other?: { label: string; url: string }[] }; organization?: string; title?: string; }; @@ -174,6 +174,7 @@ export const updateUser = async (req: Request, res: Response) => { const result = await User.updateOne( { _id: userId }, { $set: updateFields }, + { runValidators: true }, ); if (result.matchedCount === 0) { diff --git a/shatter-backend/src/models/user_model.ts b/shatter-backend/src/models/user_model.ts index 3d4bb1c..1eea569 100644 --- a/shatter-backend/src/models/user_model.ts +++ b/shatter-backend/src/models/user_model.ts @@ -19,7 +19,7 @@ export interface IUser { socialLinks?: { linkedin?: string; github?: string; - other?: string; + other?: { label: string; url: string }[]; }; authProvider: 'local' | 'linkedin' | 'guest'; lastLogin?: Date; @@ -85,7 +85,18 @@ const UserSchema = new Schema( socialLinks: { linkedin: { type: String }, github: { type: String }, - other: { type: String }, + other: { + type: [ + new Schema( + { + label: { type: String, required: true, trim: true }, + url: { type: String, required: true, trim: true }, + }, + { _id: false }, + ), + ], + default: undefined, + }, }, authProvider: { type: String, diff --git a/shatter-backend/src/routes/auth_routes.ts b/shatter-backend/src/routes/auth_routes.ts index 736250c..0eb4cd1 100644 --- a/shatter-backend/src/routes/auth_routes.ts +++ b/shatter-backend/src/routes/auth_routes.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; -import { signup, login, linkedinAuth, linkedinCallback, exchangeAuthCode } from '../controllers/auth_controller.js'; +import { signup, login, linkedinAuth, linkedinLink, linkedinCallback, exchangeAuthCode } from '../controllers/auth_controller.js'; +import { authMiddleware } from '../middleware/auth_middleware.js'; const router = Router(); @@ -11,9 +12,10 @@ router.post('/login', login); // LinkedIn OAuth routes router.get('/linkedin', linkedinAuth); +router.get('/linkedin/link', authMiddleware, linkedinLink); router.get('/linkedin/callback', linkedinCallback); -// Auth code exchange (OAuth callback → JWT) +// Auth code exchange (OAuth callback -> JWT) router.post('/exchange', exchangeAuthCode); export default router; diff --git a/shatter-backend/src/routes/bingo_routes.ts b/shatter-backend/src/routes/bingo_routes.ts index a811f78..4bc643f 100644 --- a/shatter-backend/src/routes/bingo_routes.ts +++ b/shatter-backend/src/routes/bingo_routes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { createBingo, getBingo, updateBingo, generateBingo} from '../controllers/bingo_controller.js'; +import { createBingo, getBingo, updateBingo, generateBingo, generateIndividualBingoGameQuestions} from '../controllers/bingo_controller.js'; import { authMiddleware } from '../middleware/auth_middleware.js'; const router = Router(); @@ -15,5 +15,8 @@ router.put("/updateBingo", authMiddleware, updateBingo); // POST /api/bingo/generateBingo - generate bingo using AI for an event router.post("/generateBingo", authMiddleware, generateBingo); +// POST /api/bingo/generate/individual - generate a single AI bingo question +router.post("/generateBingo/individual", authMiddleware, generateIndividualBingoGameQuestions); + export default router; diff --git a/shatter-backend/src/routes/participant_connections_routes.ts b/shatter-backend/src/routes/participant_connections_routes.ts index c05fe2c..45f4533 100644 --- a/shatter-backend/src/routes/participant_connections_routes.ts +++ b/shatter-backend/src/routes/participant_connections_routes.ts @@ -40,7 +40,7 @@ const router = Router(); // Create connection by participant ObjectIds // POST /api/participantConnections -router.post("/", authMiddleware, createParticipantConnection); +router.post("/", createParticipantConnection); // Create connection by user emails (controller converts to participant ObjectIds) // POST /api/participantConnections/by-emails diff --git a/shatter-backend/src/utils/event_utils.ts b/shatter-backend/src/utils/event_utils.ts index 58023d6..43206e5 100644 --- a/shatter-backend/src/utils/event_utils.ts +++ b/shatter-backend/src/utils/event_utils.ts @@ -9,10 +9,10 @@ export function generateEventId(): string { } /** - * Generates a random 8-digit number string for joinCode - * Example: "48392017" + * Generates a random 4-digit number string for joinCode + * Example: "4132" */ export function generateJoinCode(): string { - const code = Math.floor(10000000 + Math.random() * 90000000); + const code = Math.floor(1000 + Math.random() * 9000); return code.toString(); } diff --git a/shatter-backend/src/utils/leaderboard_pusher.ts b/shatter-backend/src/utils/leaderboard_pusher.ts new file mode 100644 index 0000000..064cf60 --- /dev/null +++ b/shatter-backend/src/utils/leaderboard_pusher.ts @@ -0,0 +1,70 @@ +import { Types } from "mongoose"; +import { Participant } from "../models/participant_model.js"; +import { ParticipantConnection } from "../models/participant_connection_model.js"; +import { pusher } from "./pusher_websocket.js"; + +/** + * Recomputes a participant's connectionsCount and emits a `leaderboard-updated` + * Pusher event on the `event-{eventId}` channel with the full payload shape + * the leaderboard frontend expects. + * + * Pusher errors are logged but never thrown — a flaky Pusher call must not + * fail the originating request. + */ +export async function emitLeaderboardUpdate( + eventId: string | Types.ObjectId, + participantId: string | Types.ObjectId, +): Promise { + try { + const eventObjectId = + typeof eventId === "string" ? new Types.ObjectId(eventId) : eventId; + const participantIdStr = participantId.toString(); + + const [participant, connections] = await Promise.all([ + Participant.findById(participantId) + .select("name linesCompleted completed userId") + .populate("userId", "name profilePhoto") + .lean(), + ParticipantConnection.find({ + _eventId: eventObjectId, + $or: [ + { primaryParticipantId: participantId }, + { secondaryParticipantId: participantId }, + ], + }) + .select("primaryParticipantId secondaryParticipantId") + .lean(), + ]); + + if (!participant) { + console.error( + `emitLeaderboardUpdate: participant ${participantIdStr} not found`, + ); + return; + } + + const uniquePartners = new Set(); + for (const c of connections) { + const otherId = + c.primaryParticipantId.toString() === participantIdStr + ? c.secondaryParticipantId.toString() + : c.primaryParticipantId.toString(); + uniquePartners.add(otherId); + } + + const user = participant.userId as + | { name?: string; profilePhoto?: string } + | null; + + await pusher.trigger(`event-${eventObjectId.toString()}`, "leaderboard-updated", { + participantId: participantIdStr, + name: user?.name || participant.name, + profilePhoto: user?.profilePhoto || null, + linesCompleted: participant.linesCompleted || 0, + completed: participant.completed || false, + connectionsCount: uniquePartners.size, + }); + } catch (error) { + console.error("emitLeaderboardUpdate failed:", error); + } +} diff --git a/shatter-mobile/app/(tabs)/JoinEventPage.tsx b/shatter-mobile/app/(tabs)/JoinEventPage.tsx index 11eb4e4..40ee5fc 100644 --- a/shatter-mobile/app/(tabs)/JoinEventPage.tsx +++ b/shatter-mobile/app/(tabs)/JoinEventPage.tsx @@ -6,6 +6,8 @@ import { useState } from "react"; import { ActivityIndicator, ImageBackground, + KeyboardAvoidingView, + Platform, Text, TextInput, TouchableOpacity, @@ -49,12 +51,19 @@ export default function JoinEventPage() { style={styles.background} resizeMode="cover" > - + + Start Shattering + + Hey {user?.name || "there"}, + - Hey {user?.name || "there"}, Ready to Start Shattering Some - Boundaries? + Ready to Start Shattering Some Boundaries? @@ -135,6 +144,7 @@ export default function JoinEventPage() { )} + diff --git a/shatter-mobile/app/(tabs)/ProfilePage.tsx b/shatter-mobile/app/(tabs)/ProfilePage.tsx index 9629efc..731a643 100644 --- a/shatter-mobile/app/(tabs)/ProfilePage.tsx +++ b/shatter-mobile/app/(tabs)/ProfilePage.tsx @@ -1,32 +1,25 @@ -import { useFocusEffect, useRouter } from "expo-router"; -import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "expo-router"; +import { useEffect } from "react"; import { - Image, - ImageBackground, - ScrollView, - Text, - TouchableOpacity, - View, + Image, + ImageBackground, + ScrollView, + Text, + TouchableOpacity, + View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { SvgUri } from "react-native-svg"; import { useAuth } from "../../src/components/context/AuthContext"; import AnimatedTab from "../../src/components/general/AnimatedTab"; import { ProfilePageStyling as styles } from "../../src/styling/ProfilePage.styles"; +import { LinkRow } from "@/src/components/general/LinkRow"; export default function Profile() { const { user, logout } = useAuth(); const router = useRouter(); - const [socialLinks, setSocialLinks] = useState(user?.socialLinks || []); - //update local form - useFocusEffect( - useCallback(() => { - setSocialLinks(user?.socialLinks || []); - }, [user]), - ); - - //not logged in + // not logged in useEffect(() => { if (!user) { router.replace("/UserPages/Login"); @@ -34,9 +27,14 @@ export default function Profile() { }, [user]); if (!user) { - return null; //don't render profile content while redirecting + return null; } + const social = user.socialLinks; + + const hasSocialLinks = + !!social?.linkedin || !!social?.github || (social?.other?.length ?? 0) > 0; + //logged in if (user && !user.isGuest) { return ( @@ -53,6 +51,7 @@ export default function Profile() { Welcome back, {user.name || "Networker"}! + + {user.email} - {socialLinks.length === 0 && ( + {/* Empty */} + {!hasSocialLinks && ( No social links added yet. )} - {socialLinks.map((link, index) => ( - - {link.label} - {link.url} + {/* Social Links */} + {user.socialLinks && ( + + {social?.linkedin && ( + + )} + + {social?.github && ( + + )} + + {social?.other?.map((link, index) => ( + + ))} - ))} + )} + + You are logged in as a guest. Some features may be limited. + {!user._id && ( To upgrade your account, join an event and then come back @@ -133,22 +149,37 @@ export default function Profile() { )} - {socialLinks.length === 0 && ( + {/* Empty */} + {!hasSocialLinks && ( No social links added yet. )} - {socialLinks.map((link, index) => ( + {/* LinkedIn */} + {social?.linkedin && ( + + LinkedIn + {social.linkedin} + + )} + + {/* GitHub */} + {social?.github && ( + + GitHub + {social.github} + + )} + + {/* Other Links */} + {social?.other?.map((link, index) => ( - <> - {link.label} - {link.url} - + {link.label} + {link.url} ))} - {/* Guest user who has joined event / has userId */} {user._id && ( (null); + const [linkedin, setLinkedin] = useState(""); + const [github, setGithub] = useState(""); + const [other, setOther] = useState(""); const [showConfirmModal, setShowConfirmModal] = useState(false); const [error, setError] = useState(""); const router = useRouter(); const handleContinue = async () => { - //need name and social link - if (!name.trim() || !contactLink.trim()) { - setError("Name and Social Link Cannot Be Empty"); + if (!name.trim()) { + setError("Name cannot be empty"); return; } - let socialLink: SocialLink | null = null; - - try { - const validUrl = new URL(contactLink); //throws if invalid - socialLink = { label: "Contact Link", url: validUrl.href }; - } catch { - console.log("Invalid URL:", contactLink); - setError("Please enter a valid contact link."); + //ensure at least one link exists + if (!linkedin.trim() && !github.trim() && !other.trim()) { + setError("Please provide at least one contact link."); return; } + const validateUrl = (url: string) => { + try { + return new URL(url).href; + } catch { + return null; + } + }; + + const socialLinks: SocialLinks = {}; + + if (linkedin.trim()) { + const valid = validateUrl(linkedin); + if (!valid) { + setError("Invalid LinkedIn URL"); + return; + } + socialLinks.linkedin = valid; + } + + if (github.trim()) { + const valid = validateUrl(github); + if (!valid) { + setError("Invalid GitHub URL"); + return; + } + socialLinks.github = valid; + } + + if (other.trim()) { + const valid = validateUrl(other); + if (!valid) { + setError("Invalid Other URL"); + return; + } + socialLinks.other?.push({ label: "Contact Link", url: valid }); + } + setError(""); - await continueAsGuest(name.trim(), socialLink, ""); //no organization on this page, handled on GuestConfirm + + await continueAsGuest(name.trim(), socialLinks, ""); router.replace("/JoinEventPage"); }; @@ -71,18 +107,80 @@ export default function GuestPage() { /> Contact Link - + + + setSelectedType("linkedin")} + style={{ + padding: 12, + borderRadius: 12, + backgroundColor: selectedType === "linkedin" ? "#0A66C2" : colors.lightGrey2, + flex: 1, + marginRight: 8, + alignItems: "center", + }} + > + + + + setSelectedType("github")} + style={{ + padding: 12, + borderRadius: 12, + backgroundColor: selectedType === "github" ? "#24292e" : colors.lightGrey2, + flex: 1, + marginRight: 8, + alignItems: "center", + }} + > + + + + setSelectedType("other")} + style={{ + padding: 12, + borderRadius: 12, + backgroundColor: selectedType === "other" ? "#6c63ff" : colors.lightGrey2, + flex: 1, + alignItems: "center", + }} + > + + + + + {selectedType && ( + { + if (selectedType === "linkedin") setLinkedin(text); + else if (selectedType === "github") setGithub(text); + else setOther(text); + }} + autoCapitalize="none" + keyboardType="url" + /> + )} + - Your contact link can be your LinkedIn profile URL, a portfolio - link, or another relevant personal link. + Select a platform above, then enter your profile link. {error ? {error} : null} diff --git a/shatter-mobile/app/UserPages/GuestNoLink.tsx b/shatter-mobile/app/UserPages/GuestNoLink.tsx index c0a55f9..3a45365 100644 --- a/shatter-mobile/app/UserPages/GuestNoLink.tsx +++ b/shatter-mobile/app/UserPages/GuestNoLink.tsx @@ -29,7 +29,7 @@ export default function GuestConfirm() { await continueAsGuest( name.trim(), - { label: "", url: "" }, + {}, organization.trim(), ); diff --git a/shatter-mobile/app/UserPages/UpdateProfile.tsx b/shatter-mobile/app/UserPages/UpdateProfile.tsx index d8b0c5b..a07b530 100644 --- a/shatter-mobile/app/UserPages/UpdateProfile.tsx +++ b/shatter-mobile/app/UserPages/UpdateProfile.tsx @@ -1,6 +1,7 @@ import { getStoredAuth } from "@/src/components/context/AsyncStorage"; import { SocialLinksModal } from "@/src/components/general/SocialLinksModal"; -import { userUpdate } from "@/src/services/user.service"; +import { SocialLinks } from "@/src/interfaces/User"; +import { UserLinkedInLink, userUpdate } from "@/src/services/user.service"; import { colors } from "@/src/styling/constants"; import { useRouter } from "expo-router"; import { useEffect, useState } from "react"; @@ -40,15 +41,30 @@ export default function UpdateProfile() { const [organization, setOrganization] = useState(user?.organization || ""); const [bio, setBio] = useState(user?.bio || ""); const [profilePhoto, setProfilePhoto] = useState(user?.profilePhoto || ""); - const [socialLinks, setSocialLinks] = useState< - { label: string; url: string }[] - >(user?.socialLinks || []); + const [socialLinks, setSocialLinks] = useState( + user?.socialLinks || {}, + ); const [socialModalVisible, setSocialModalVisible] = useState(false); useEffect(() => { if (!user) router.replace("/UserPages/Login"); }, [user]); + const handleLinkedInLink = async () => { + if (!user?._id) { + alert("Failed to link LinkedIn"); + return; + } + + try { + await UserLinkedInLink(user._id); + alert("LinkedIn link initiated"); + } catch (e) { + console.log(e); + alert("Failed to link LinkedIn"); + } + }; + const handleSave = async () => { if (!user || !user._id) return; if ((!email && password) || (email && !password && user.isGuest)) { @@ -155,7 +171,7 @@ export default function UpdateProfile() { )} - + {/* Name */} Name + /> )} + {/* TODO: Link LinkedIn to Account if Verified User and LinkedIn Not Set + {!user?.socialLinks?.linkedin && user?._id && ( + + Link LinkedIn + + )} + */} + {/* Social link modal */} { Asset.loadAsync([BG_IMAGE]).finally(() => setAssetReady(true)); @@ -97,7 +104,14 @@ export default function RootLayout() { return ( - + + {content} diff --git a/shatter-mobile/app/auth/callback.tsx b/shatter-mobile/app/auth/callback.tsx index 629dca8..bcfd73f 100644 --- a/shatter-mobile/app/auth/callback.tsx +++ b/shatter-mobile/app/auth/callback.tsx @@ -1,63 +1,71 @@ import { useAuth } from "@/src/components/context/AuthContext"; import { User } from "@/src/interfaces/User"; -import { exchangeLinkedInCode, userFetch } from "@/src/services/user.service"; +import { + exchangeLinkedInCode, + userFetch, + userUpdate, +} from "@/src/services/user.service"; import { router, useLocalSearchParams } from "expo-router"; import { useEffect, useState } from "react"; import { ActivityIndicator, Text, View } from "react-native"; export default function AuthCallback() { - const { code } = useLocalSearchParams<{ code: string }>(); - const { authenticate } = useAuth(); - const [error, setError] = useState(""); - - useEffect(() => { - if (!code) { - setError("No auth code received."); - return; - } - - const exchange = async () => { - try { - // Exchange single-use code for JWT — must happen within 60 seconds - const response = await exchangeLinkedInCode(code); - - // Fetch full user profile using returned userId + token - const userData = await userFetch(response.userId, response.token); - - const user: User = { - _id: response.userId, - name: userData.user.name, - email: userData.user.email, - socialLinks: userData.user.socialLinks ?? [], - profilePhoto: userData.user.profilePhoto, - isGuest: false, - }; - - // Store user + JWT in auth context - await authenticate(user, response.token, false); - router.replace("/JoinEventPage"); - } catch (err) { - setError((err as Error).message || "Authentication failed."); - } - }; - - exchange(); - }, [code]); - - if (error) { - return ( - - - {error} - - - ); - } - - return ( - - - Signing you in... - - ); -} \ No newline at end of file + const { code } = useLocalSearchParams<{ code: string }>(); + const { authenticate, authStorage } = useAuth(); + const [error, setError] = useState(""); + + useEffect(() => { + if (!code) { + setError("No auth code received."); + return; + } + + const exchange = async () => { + try { + // Exchange single-use code for JWT — must happen within 60 seconds + const response = await exchangeLinkedInCode(code); + + // Fetch full user profile using returned userId + token + const userData = await userFetch(response.userId, response.token); + + const user: User = { + _id: response.userId, + name: userData.user.name, + email: userData.user.email, + socialLinks: userData.user.socialLinks ?? {}, + profilePhoto: userData.user.profilePhoto, + isGuest: false, + }; + + // Store user + JWT in auth context + await authenticate(user, response.token, false); + + // Update stored user with LinkedIn data + const token = authStorage.accessToken; + userUpdate(response.userId, user, token); + router.replace("/JoinEventPage"); + } catch (err) { + setError((err as Error).message || "Authentication failed."); + } + }; + + exchange(); + }, [code]); + + if (error) { + return ( + + + {error} + + + ); + } + + return ( + + + Signing you in... + + ); +} diff --git a/shatter-mobile/assets/images/adaptive-icon.png b/shatter-mobile/assets/images/adaptive-icon.png new file mode 100644 index 0000000..29b49ad Binary files /dev/null and b/shatter-mobile/assets/images/adaptive-icon.png differ diff --git a/shatter-mobile/assets/images/icon.png b/shatter-mobile/assets/images/icon.png new file mode 100644 index 0000000..76149bd Binary files /dev/null and b/shatter-mobile/assets/images/icon.png differ diff --git a/shatter-mobile/assets/images/splash-icon.png b/shatter-mobile/assets/images/splash-icon.png new file mode 100644 index 0000000..9da6c74 Binary files /dev/null and b/shatter-mobile/assets/images/splash-icon.png differ diff --git a/shatter-mobile/package-lock.json b/shatter-mobile/package-lock.json index f0261d3..78ca5cf 100644 --- a/shatter-mobile/package-lock.json +++ b/shatter-mobile/package-lock.json @@ -13,6 +13,7 @@ "@expo/vector-icons": "^15.0.3", "@pusher/pusher-websocket-react-native": "^1.3.5", "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-community/netinfo": "11.4.1", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", @@ -30,6 +31,7 @@ "expo-symbols": "~1.0.8", "expo-system-ui": "~6.0.9", "expo-web-browser": "~15.0.10", + "pusher-js": "^8.5.0", "qr-scanner": "^1.4.2", "react": "^19.1.0", "react-dom": "19.1.0", @@ -92,6 +94,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3317,6 +3320,15 @@ "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, + "node_modules/@react-native-community/netinfo": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.4.1.tgz", + "integrity": "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==", + "license": "MIT", + "peerDependencies": { + "react-native": ">=0.59" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", @@ -3611,6 +3623,7 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.31.tgz", "integrity": "sha512-+YCUwtfDgsux59Q0LDHc3Zid9ih93ecUCFWZOH6/+eNoUGnWx77wjS6ZfvBO/7E+EiIup11IVShDzCHR4of8hw==", "license": "MIT", + "peer": true, "dependencies": { "@react-navigation/core": "^7.15.1", "escape-string-regexp": "^4.0.0", @@ -3815,6 +3828,7 @@ "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3886,6 +3900,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -4448,6 +4463,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5156,6 +5172,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6274,6 +6291,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6470,6 +6488,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6702,6 +6721,7 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz", "integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.23", @@ -6789,6 +6809,7 @@ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", "license": "MIT", + "peer": true, "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" @@ -6813,6 +6834,7 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz", "integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==", "license": "MIT", + "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -6863,6 +6885,7 @@ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz", "integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==", "license": "MIT", + "peer": true, "dependencies": { "expo-constants": "~18.0.12", "invariant": "^2.2.4" @@ -10708,6 +10731,15 @@ "node": ">=6" } }, + "node_modules/pusher-js": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.5.0.tgz", + "integrity": "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==", + "license": "MIT", + "dependencies": { + "tweetnacl": "^1.0.3" + } + }, "node_modules/qr-scanner": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/qr-scanner/-/qr-scanner-1.4.2.tgz", @@ -10811,6 +10843,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10830,6 +10863,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -10866,6 +10900,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz", "integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==", "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", @@ -10923,6 +10958,7 @@ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", "license": "MIT", + "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", @@ -10948,6 +10984,7 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.3.tgz", "integrity": "sha512-GP8wsi1u3nqvC1fMab/m8gfFwFyldawElCcUSBJQgfrXeLmsPPUOpDw44lbLeCpcwUuLa05WTVePdTEwCLTUZg==", "license": "MIT", + "peer": true, "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "7.7.2" @@ -10976,6 +11013,7 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -10986,6 +11024,7 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz", "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==", "license": "MIT", + "peer": true, "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.2.1", @@ -11016,6 +11055,7 @@ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", @@ -11048,6 +11088,7 @@ "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz", "integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==", "license": "MIT", + "peer": true, "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", @@ -11158,6 +11199,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12510,6 +12552,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12601,6 +12644,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -12716,6 +12765,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/shatter-mobile/package.json b/shatter-mobile/package.json index bc07df7..053b368 100644 --- a/shatter-mobile/package.json +++ b/shatter-mobile/package.json @@ -16,6 +16,7 @@ "@expo/vector-icons": "^15.0.3", "@pusher/pusher-websocket-react-native": "^1.3.5", "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-community/netinfo": "11.4.1", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", @@ -33,6 +34,7 @@ "expo-symbols": "~1.0.8", "expo-system-ui": "~6.0.9", "expo-web-browser": "~15.0.10", + "pusher-js": "^8.5.0", "qr-scanner": "^1.4.2", "react": "^19.1.0", "react-dom": "19.1.0", diff --git a/shatter-mobile/src/api/events/event.api.tsx b/shatter-mobile/src/api/events/event.api.tsx index 92fde38..be3ad34 100644 --- a/shatter-mobile/src/api/events/event.api.tsx +++ b/shatter-mobile/src/api/events/event.api.tsx @@ -2,6 +2,7 @@ import JoinEventByIdGuestRequest from "@/src/interfaces/requests/JoinEventByIdGu import JoinEventByIdUserRequest from "@/src/interfaces/requests/JoinEventByIdUserRequest"; import EventResponse from "@/src/interfaces/responses/GetEventResponse"; import EventJoinIdResponse from "@/src/interfaces/responses/JoinEventIdResponse"; +import { SocialLinks } from "@/src/interfaces/User"; import axios, { AxiosError, AxiosResponse } from "axios"; const API_BASE = process.env.EXPO_PUBLIC_API_BASE; @@ -103,9 +104,11 @@ export async function JoinEventByIdUserApi( export async function JoinEventByIdGuestApi( eventId: string, name: string, + socialLinks: SocialLinks, + organization: string, ): Promise { try { - const body: JoinEventByIdGuestRequest = { name }; + const body: JoinEventByIdGuestRequest = { name, socialLinks, organization }; const response: AxiosResponse = await axios.post( `${API_BASE_URL_EVENT}/${eventId}/join/guest`, body, diff --git a/shatter-mobile/src/api/games/game.api.tsx b/shatter-mobile/src/api/games/game.api.tsx index a6ac29e..a71738b 100644 --- a/shatter-mobile/src/api/games/game.api.tsx +++ b/shatter-mobile/src/api/games/game.api.tsx @@ -39,6 +39,18 @@ export async function GetParticipantsByEventIdApi( } } +export async function UpdateLeaderboardScoreApi( + eventId: string, + body: { linesCompleted?: number; completed?: boolean }, + token: string, +): Promise { + await axios.put( + `${API_BASE_URL_EVENTS}/${eventId}/leaderboard/score`, + body, + { headers: { Authorization: `Bearer ${token}` } }, + ); +} + export async function GetBingoCategoriesApi( eventId: string, ): Promise { diff --git a/shatter-mobile/src/api/users/user.api.tsx b/shatter-mobile/src/api/users/user.api.tsx index 082133e..859a784 100644 --- a/shatter-mobile/src/api/users/user.api.tsx +++ b/shatter-mobile/src/api/users/user.api.tsx @@ -348,3 +348,36 @@ export async function ExchangeLinkedInCodeApi( throw new Error("Network error. Check your connection."); } } + +export async function UserLinkedInLinkApi( + userId: string, +): Promise { + try { + const response: AxiosResponse = await axios.post( + `${API_BASE_URL_AUTH}/linkedin/link`, + { userId }, + ); + return response.data; + } catch (error) { + const err = error as AxiosError; + + if (err.response) { + switch (err.response.status) { + case 400: + throw new Error("Authentication required."); + case 401: + throw new Error("User not found. Please try again later."); + case 403: + throw new Error("This account is already a LinkedIn account."); + case 409: + throw new Error("This account is already linked to LinkedIn!"); + case 500: + throw new Error("Server error. Please try again later."); + default: + throw new Error("Authentication failed."); + } + } + + throw new Error("Network error. Check your connection."); + } +} diff --git a/shatter-mobile/src/components/context/AsyncStorage.tsx b/shatter-mobile/src/components/context/AsyncStorage.tsx index ba17218..ca831fe 100644 --- a/shatter-mobile/src/components/context/AsyncStorage.tsx +++ b/shatter-mobile/src/components/context/AsyncStorage.tsx @@ -1,4 +1,4 @@ -import { SocialLink } from "@/src/interfaces/User"; +import { SocialLinks } from "@/src/interfaces/User"; import AsyncStorage from "@react-native-async-storage/async-storage"; const STORAGE_KEY = "AUTH_DATA"; @@ -7,7 +7,7 @@ export type AuthDataStorage = { userId: string | null; accessToken: string; isGuest: boolean; - guestInfo: { name: string; socialLinks?: SocialLink[], organization?: string }; + guestInfo: { name: string; socialLinks?: SocialLinks, organization?: string }; }; export const getStoredAuth = async (): Promise => { @@ -18,7 +18,7 @@ export const getStoredAuth = async (): Promise => { userId: "", accessToken: "", isGuest: false, - guestInfo: { name: "", socialLinks: [] }, + guestInfo: { name: "", socialLinks: {} }, }; }; diff --git a/shatter-mobile/src/components/context/AuthContext.tsx b/shatter-mobile/src/components/context/AuthContext.tsx index 0ab5420..af99916 100644 --- a/shatter-mobile/src/components/context/AuthContext.tsx +++ b/shatter-mobile/src/components/context/AuthContext.tsx @@ -1,4 +1,4 @@ -import { SocialLink, User } from "@/src/interfaces/User"; +import { SocialLinks, User } from "@/src/interfaces/User"; import { userFetch } from "@/src/services/user.service"; import AsyncStorage from "@react-native-async-storage/async-storage"; import React, { createContext, useContext, useEffect, useState } from "react"; @@ -14,7 +14,7 @@ type AuthContextType = { ) => Promise; continueAsGuest: ( name: string, - socialLink: SocialLink, + socialLinks: SocialLinks, organization: string, ) => Promise; logout: () => Promise; @@ -28,7 +28,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { userId: "", accessToken: "", isGuest: true, - guestInfo: { name: "", socialLinks: [], organization: "" }, + guestInfo: { name: "", socialLinks: {}, organization: "" }, }); const [user, setUser] = useState(undefined); @@ -88,7 +88,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { isGuest: isGuest, guestInfo: { name: user.name, - socialLinks: user.socialLinks || [], + socialLinks: user.socialLinks || {}, organization: user.organization, }, }; @@ -99,14 +99,18 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { //when user initially creates a guest account const continueAsGuest = async ( name: string, - socialLink: SocialLink, + socialLink: SocialLinks, organization: string, ) => { + const encodedName = encodeURIComponent(name ?? "Unknown"); + const profilePhoto = `https://api.dicebear.com/9.x/initials/svg?seed=${encodedName}`; + const guestUser: User = { _id: null, name: name, - socialLinks: [{ label: socialLink.label, url: socialLink.url }], + socialLinks: { linkedin: socialLink.linkedin, github: socialLink.github, other: socialLink.other }, organization: organization, + profilePhoto: profilePhoto, isGuest: true, }; @@ -117,8 +121,8 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { accessToken: "", isGuest: true, guestInfo: { - name: guestUser.name, - socialLinks: guestUser.socialLinks || [], + name: name, + socialLinks: guestUser.socialLinks || {}, organization: organization || "", }, }; @@ -133,7 +137,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { userId: "", accessToken: "", isGuest: true, - guestInfo: { name: "", socialLinks: [] }, + guestInfo: { name: "", socialLinks: {}, organization: "" }, }); await AsyncStorage.clear(); }; diff --git a/shatter-mobile/src/components/context/GameContext.tsx b/shatter-mobile/src/components/context/GameContext.tsx index 9a854b0..7830e94 100644 --- a/shatter-mobile/src/components/context/GameContext.tsx +++ b/shatter-mobile/src/components/context/GameContext.tsx @@ -1,255 +1,247 @@ import { EventState, GameType, Participant } from "@/src/interfaces/Event"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { - createContext, - ReactNode, - useContext, - useEffect, - useState, + createContext, + ReactNode, + useContext, + useEffect, + useState, } from "react"; import { getPusherClient } from "./PusherClient"; export type GameState = { - gameType: GameType; //"Game Bingo" - eventId: string; - loading: boolean; - data: any; //generic, can hold cards, prompts, scores - status: string | null; //"Bingo!", "Completed" - progress: EventState; - participants: Participant[]; + gameType: GameType; //"Game Bingo" + eventId: string; + loading: boolean; + data: any; //generic, can hold cards, prompts, scores + status: string | null; //"Bingo!", "Completed" + progress: EventState; + participants: Participant[]; + viewingGame: boolean; }; type GameContextType = { - currentParticipantId: string; - setCurrentParticipantId: (id: string) => Promise; - gameState: GameState; - initializeGame: ( - gameType: GameType, - eventId: string, - eventProgress: EventState, - initialData?: any, - ) => void; - setGameData: (data: any) => void; - setGameStatus: (status: string | null) => void; - setGameProgress: (progress: EventState) => void; - resetGame: () => void; + currentParticipantId: string; + setCurrentParticipantId: (id: string) => Promise; + gameState: GameState; + initializeGame: ( + gameType: GameType, + eventId: string, + eventProgress: EventState, + initialData?: any, + ) => void; + setGameData: (data: any) => void; + setGameStatus: (status: string | null) => void; + setGameProgress: (progress: EventState) => void; + setGameParticipants: (participants: Participant[]) => void; + setGameViewing: (gameView: boolean) => void; + resetGame: () => void; }; const defaultGameState: GameState = { - gameType: GameType.NAME_BINGO, - eventId: "", - loading: true, - data: "", - status: null, - progress: EventState.UPCOMING, - participants: [], + gameType: GameType.NAME_BINGO, + eventId: "", + loading: true, + data: "", + status: null, + progress: EventState.UPCOMING, + participants: [], + viewingGame: false, }; const GameContext = createContext(undefined); export const GameProvider = ({ children }: { children: ReactNode }) => { - const [currentParticipantId, _setCurrentParticipantId] = useState(""); - const [gameState, setGameState] = useState(defaultGameState); - - const participantStorageKey = "current-participant-id"; - - const storageKey = (eventId: string, gameType: GameType) => - `game-${gameType}-${eventId}`; - - //create event hook for each event joined - useEffect(() => { - if (!gameState.eventId) return; - - let isActive = true; - - let channel: any = null; - let pusherClient: any = null; - - const setup = async () => { - pusherClient = await getPusherClient(); - - if (!isActive) return; - - channel = await pusherClient.subscribe({ - channelName: `event-${gameState.eventId}`, - onEvent: (event: any) => { - if (event.eventName === "event") { - try { - const data = JSON.parse(event.data); - - setGameProgress(data.status); - setGameParticipants(data.participantIds); - } catch (err) { - console.error("Failed to parse event data", err); - } - } - }, - }); - }; - - setup(); - - return () => { - isActive = false; - - if (channel) { - channel.unbind_all?.(); - } - - if (pusherClient && gameState.eventId) { - pusherClient.unsubscribe({ - channelName: `event-${gameState.eventId}`, - }); - } - }; - }, [gameState.eventId]); - - //load participantId on app start - useEffect(() => { - const loadParticipant = async () => { - try { - const storedId = await AsyncStorage.getItem(participantStorageKey); - if (storedId) _setCurrentParticipantId(storedId); - } catch (err) { - console.log("Failed to load participantId:", err); - } - }; - loadParticipant(); - }, []); - - const setCurrentParticipantId = async (id: string) => { - _setCurrentParticipantId(id); - try { - await AsyncStorage.setItem(participantStorageKey, id); - } catch (err) { - console.log("Failed to save participantId:", err); - } - }; - - const initializeGame = async ( - gameType: GameType, - eventId: string, - eventProgress: EventState, - eventParticipants: Participant[], - initialData: any = {}, - ): Promise => { - setGameState({ - gameType, - eventId, - loading: false, - data: initialData, - status: null, - progress: eventProgress, - participants: eventParticipants, - }); - }; - - const setGameData = async (data: any) => { - if (!gameState) return; - const newState = { ...gameState, data }; - setGameState(newState); - - try { - await AsyncStorage.setItem( - storageKey(gameState.eventId, gameState.gameType), - JSON.stringify(newState), - ); - } catch (err) { - console.log("Failed to save game data:", err); - } - }; - - const setGameStatus = async (status: string | null) => { - if (!gameState) return; - const newState = { ...gameState, status }; - setGameState(newState); - - try { - await AsyncStorage.setItem( - storageKey(gameState.eventId, gameState.gameType), - JSON.stringify(newState), - ); - } catch (err) { - console.log("Failed to save game status:", err); - } - }; - - const setGameProgress = async (progress: EventState) => { - if (!gameState) return; - const newState = { ...gameState, progress }; - setGameState(newState); - - try { - await AsyncStorage.setItem( - storageKey(gameState.eventId, gameState.gameType), - JSON.stringify(newState), - ); - } catch (err) { - console.log("Failed to save game progress:", err); - } - }; - - const setGameType = async (gameType: GameType) => { - if (!gameState) return; - const newState = { ...gameState, gameType }; - setGameState(newState); - - try { - await AsyncStorage.setItem( - storageKey(gameState.eventId, gameState.gameType), - JSON.stringify(newState), - ); - } catch (err) { - console.log("Failed to save game type:", err); - } - }; - - const setGameParticipants = async (participants: Participant[]) => { - if (!gameState) return; - const newState = { ...gameState, participants }; - setGameState(newState); - - try { - await AsyncStorage.setItem( - storageKey(gameState.eventId, gameState.gameType), - JSON.stringify(newState), - ); - } catch (err) { - console.log("Failed to save game participants:", err); - } - }; - - const resetGame = async () => { - if (!gameState) return; - const key = storageKey(gameState.eventId, gameState.gameType); - try { - await AsyncStorage.removeItem(key); - setGameState({ ...gameState, data: {}, status: null }); - } catch (err) { - console.log("Failed to reset game:", err); - } - }; - - return ( - - {children} - - ); + const [currentParticipantId, _setCurrentParticipantId] = useState(""); + const [gameState, setGameState] = useState(defaultGameState); + + const participantStorageKey = "current-participant-id"; + + const storageKey = (eventId: string, gameType: GameType) => + `game-${gameType}-${eventId}`; + + //create event hook for each event joined + useEffect(() => { + if (!gameState.eventId) return; + + let isActive = true; + + const pusher = getPusherClient(); + const channelName = `event-${gameState.eventId}`; + + const channel = pusher.subscribe(channelName); + + const handler = (data: any) => { + if (!isActive) return; + + try { + const parsed = typeof data === "string" ? JSON.parse(data) : data; + + setGameProgress(parsed.status); + } catch (err) { + console.error("Failed to parse event data", err); + } + }; + + channel.bind("event", handler); + + return () => { + isActive = false; + + channel.unbind("event", handler); + pusher.unsubscribe(channelName); + }; + }, [gameState.eventId]); + + //load participantId on app start + useEffect(() => { + const loadParticipant = async () => { + try { + const storedId = await AsyncStorage.getItem(participantStorageKey); + if (storedId) _setCurrentParticipantId(storedId); + } catch (err) { + console.log("Failed to load participantId:", err); + } + }; + loadParticipant(); + }, []); + + const setCurrentParticipantId = async (id: string) => { + _setCurrentParticipantId(id); + try { + await AsyncStorage.setItem(participantStorageKey, id); + } catch (err) { + console.log("Failed to save participantId:", err); + } + }; + + const initializeGame = async ( + gameType: GameType, + eventId: string, + eventProgress: EventState, + eventParticipants: Participant[], + initialData: any = {}, + ): Promise => { + setGameState({ + gameType, + eventId, + loading: false, + data: initialData, + status: null, + progress: eventProgress, + participants: eventParticipants, + viewingGame: false, + }); + }; + + const setGameData = async (data: any) => { + if (!gameState) return; + const newState = { ...gameState, data }; + setGameState(newState); + + try { + await AsyncStorage.setItem( + storageKey(gameState.eventId, gameState.gameType), + JSON.stringify(newState), + ); + } catch (err) { + console.log("Failed to save game data:", err); + } + }; + + const setGameStatus = async (status: string | null) => { + if (!gameState) return; + const newState = { ...gameState, status }; + setGameState(newState); + + try { + await AsyncStorage.setItem( + storageKey(gameState.eventId, gameState.gameType), + JSON.stringify(newState), + ); + } catch (err) { + console.log("Failed to save game status:", err); + } + }; + + const setGameProgress = async (progress: EventState) => { + if (!gameState) return; + const newState = { ...gameState, progress }; + setGameState(newState); + + try { + await AsyncStorage.setItem( + storageKey(gameState.eventId, gameState.gameType), + JSON.stringify(newState), + ); + } catch (err) { + console.log("Failed to save game progress:", err); + } + }; + + const setGameViewing = async (viewGame: boolean) => { + if (!gameState) return; + const newState = { ...gameState, viewGame }; + setGameState(newState); + + try { + await AsyncStorage.setItem( + storageKey(gameState.eventId, gameState.gameType), + JSON.stringify(newState), + ); + } catch (err) { + console.log("Failed to save game viewing status:", err); + } + }; + + const setGameParticipants = async (participants: Participant[]) => { + if (!gameState) return; + const newState = { ...gameState, participants }; + setGameState(newState); + + try { + await AsyncStorage.setItem( + storageKey(gameState.eventId, gameState.gameType), + JSON.stringify(newState), + ); + } catch (err) { + console.log("Failed to save game participants:", err); + } + }; + + const resetGame = async () => { + if (!gameState) return; + const key = storageKey(gameState.eventId, gameState.gameType); + try { + await AsyncStorage.removeItem(key); + setGameState({ ...gameState, data: {}, status: null }); + } catch (err) { + console.log("Failed to reset game:", err); + } + }; + + return ( + + {children} + + ); }; export const useGame = () => { - const context = useContext(GameContext); - if (!context) throw new Error("useGame must be used within a GameProvider"); - return context; + const context = useContext(GameContext); + if (!context) throw new Error("useGame must be used within a GameProvider"); + return context; }; diff --git a/shatter-mobile/src/components/context/PusherClient.tsx b/shatter-mobile/src/components/context/PusherClient.tsx index c9307d6..e910ecc 100644 --- a/shatter-mobile/src/components/context/PusherClient.tsx +++ b/shatter-mobile/src/components/context/PusherClient.tsx @@ -1,29 +1,16 @@ -import { Pusher } from "@pusher/pusher-websocket-react-native"; +const { Pusher } = require('pusher-js/react-native'); -let pusher: Pusher | null = null; -let initPromise: Promise | null = null; +let pusher: any = null; -const API_KEY = process.env.PUSHER_KEY!; -const API_CLUSTER = process.env.PUSHER_CLUSTER!; +const API_KEY = process.env.EXPO_PUBLIC_PUSHER_KEY!; +const API_CLUSTER = process.env.EXPO_PUBLIC_PUSHER_CLUSTER!; -export const getPusherClient = async (): Promise => { - if (pusher) return pusher; +export const getPusherClient = () => { + if (pusher) return pusher; - if (!initPromise) { - initPromise = (async () => { - const instance = Pusher.getInstance(); + pusher = new Pusher(API_KEY, { + cluster: API_CLUSTER, + }); - await instance.init({ - apiKey: API_KEY, - cluster: API_CLUSTER, - }); - - await instance.connect(); - - pusher = instance; - return pusher; - })(); - } - - return initPromise; -}; + return pusher; +}; \ No newline at end of file diff --git a/shatter-mobile/src/components/events/EventCard.tsx b/shatter-mobile/src/components/events/EventCard.tsx index d1df93e..53ff987 100644 --- a/shatter-mobile/src/components/events/EventCard.tsx +++ b/shatter-mobile/src/components/events/EventCard.tsx @@ -14,7 +14,7 @@ type EventCardProps = { const EventCard = ({ event, expanded, onPress }: EventCardProps) => { const router = useRouter(); - const { initializeGame } = useGame(); + const { initializeGame, setGameViewing } = useGame(); const [imageLoaded, setImageLoaded] = useState(false); const [modalVisible, setModalVisible] = useState(false); @@ -123,6 +123,7 @@ const EventCard = ({ event, expanded, onPress }: EventCardProps) => { event.gameType = GameType.NAME_BINGO; } initializeGame(event.gameType, event._id, event.currentState, event.participantIds); + setGameViewing(true); //update viewing game flag in GameContext router.push({ pathname: "/GamePages/Game", params: { eventId: event._id }, diff --git a/shatter-mobile/src/components/events/UserModal.tsx b/shatter-mobile/src/components/events/UserModal.tsx index e0e4c7d..5ba0923 100644 --- a/shatter-mobile/src/components/events/UserModal.tsx +++ b/shatter-mobile/src/components/events/UserModal.tsx @@ -1,7 +1,9 @@ import { User } from "@/src/interfaces/User"; import { Linking, Modal, Pressable, Text, View } from "react-native"; +import { Feather } from "@expo/vector-icons"; import { SvgUri } from "react-native-svg"; import { UserModalStyling as styles } from "../../styling/UserModal.styles"; +import { LinkRow } from "../general/LinkRow"; type UserModalProps = { user: User; @@ -40,23 +42,18 @@ const UserModal = ({ user, onRequestClose }: UserModalProps) => { {user.bio && {user.bio}} - {user.socialLinks && user.socialLinks?.length > 0 && ( + {user.socialLinks && ( - {user.socialLinks?.map((link, index) => ( - { - if (link.url) { - Linking.openURL(link.url).catch((err) => - console.log("Failed to open URL:", err), - ); - } - }} - style={{ marginBottom: 8 }} - > - {link.label} - {link.url} - + {user.socialLinks.linkedin && ( + + )} + + {user.socialLinks.github && ( + + )} + + {user.socialLinks.other?.map((link, index) => ( + ))} )} diff --git a/shatter-mobile/src/components/games/IcebreakerGame.tsx b/shatter-mobile/src/components/games/IcebreakerGame.tsx index a27fa83..19d0d5b 100644 --- a/shatter-mobile/src/components/games/IcebreakerGame.tsx +++ b/shatter-mobile/src/components/games/IcebreakerGame.tsx @@ -10,8 +10,6 @@ import { useAuth } from "../context/AuthContext"; import { useGame } from "../context/GameContext"; import NameBingo from "./NameBingo"; -const POLL_INTERVAL = 4000; //4 seconds - type IcebreakerGameProps = { event: EventIB; }; @@ -24,6 +22,7 @@ const IcebreakerGame = ({ event }: IcebreakerGameProps) => { useEffect(() => { if (!event._id) return; if (gameState.progress !== EventState.COMPLETED) return; + if (!gameState.viewingGame) return; //if user is looking at game from Events page router.push("/EventPages/EventComplete"); }, [gameState.progress, event._id]); diff --git a/shatter-mobile/src/components/games/NameBingo.tsx b/shatter-mobile/src/components/games/NameBingo.tsx index 3793f77..81ec8a2 100644 --- a/shatter-mobile/src/components/games/NameBingo.tsx +++ b/shatter-mobile/src/components/games/NameBingo.tsx @@ -1,19 +1,18 @@ +import { UpdateLeaderboardScoreApi } from "@/src/api/games/game.api"; +import { getStoredAuth } from "@/src/components/context/AsyncStorage"; import { useGame } from "@/src/components/context/GameContext"; import { EventState, Participant } from "@/src/interfaces/Event"; import { BingoTile } from "@/src/interfaces/Game"; -import { - getBingoCategories, - getParticipantsByEventId, -} from "@/src/services/game.service"; +import { getBingoCategories } from "@/src/services/game.service"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useEffect, useState } from "react"; import { - DimensionValue, - ScrollView, - Text, - TextInput, - TouchableOpacity, - View, + DimensionValue, + ScrollView, + Text, + TextInput, + TouchableOpacity, + View, } from "react-native"; import { NameBingoStyling as styles } from "../../styling/NameBingo.styles"; import FullPageLoader from "../general/FullPageLoader"; @@ -241,6 +240,26 @@ const NameBingo = ({ eventId, onConnect }: NameBingoProps) => { setWinningLines([]); setBingoStatus(null); } + + const isBlackout = result === "Blackout"; + const totalLines = + categories.length + (categories[0]?.length || 0) + 2; + const linesCompleted = isBlackout + ? totalLines + : Array.isArray(result) + ? result.length + : 0; + + getStoredAuth() + .then((auth) => { + if (!auth.accessToken) return; + return UpdateLeaderboardScoreApi( + eventId, + { linesCompleted, completed: isBlackout }, + auth.accessToken, + ); + }) + .catch((e) => console.warn("score update failed", e)); }; const filteredParticipants = participants.filter( @@ -267,34 +286,37 @@ const NameBingo = ({ eventId, onConnect }: NameBingoProps) => { )} {/* Hint */} - {!selectedCardId && gameState.progress !== EventState.COMPLETED && ( - Select a square first - )} + {!selectedCardId && + gameState.progress !== EventState.COMPLETED && + !gameState.viewingGame && ( + Select a square first + )} {/* Search */} - {gameState.progress !== EventState.COMPLETED && ( - - - {search.length > 0 && filteredParticipants.length > 0 && ( - - {filteredParticipants.map((p) => ( - handleAssign(p)} - > - {p.name} - - ))} - - )} - - )} + {gameState.progress !== EventState.COMPLETED && + !gameState.viewingGame && ( + + + {search.length > 0 && filteredParticipants.length > 0 && ( + + {filteredParticipants.map((p) => ( + handleAssign(p)} + > + {p.name} + + ))} + + )} + + )} {/* Card Grid */} diff --git a/shatter-mobile/src/components/general/LinkRow.tsx b/shatter-mobile/src/components/general/LinkRow.tsx new file mode 100644 index 0000000..b162abf --- /dev/null +++ b/shatter-mobile/src/components/general/LinkRow.tsx @@ -0,0 +1,59 @@ +import { colors, fonts } from "@/src/styling/constants"; +import Feather from "@expo/vector-icons/build/Feather"; +import { Linking, Pressable, StyleSheet, Text, View } from "react-native"; + +type LinkRowProps = { + label: string; + url: string; +}; + +export const LinkRow = ({ label, url }: LinkRowProps) => { + return ( + { + Linking.openURL(url).catch((err) => + console.log("Failed to open URL:", err), + ); + }} + style={styles.linkRow} + > + {label} + + + + {url.replace(/^https?:\/\//, "")} + + + + + + ); +}; + +const styles = StyleSheet.create({ + linkRow: { + marginBottom: 12, + paddingVertical: 8, + }, + + linkRight: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + width: "70%", + }, + + linkLabel: { + fontFamily: fonts.title, + fontSize: 14, + color: colors.darkNavy, + fontWeight: "bold", + marginTop: 6, + }, + + link: { + flex: 1, + marginRight: 8, + color: "#666", + }, +}); diff --git a/shatter-mobile/src/components/general/SocialLinksModal.tsx b/shatter-mobile/src/components/general/SocialLinksModal.tsx index ef80336..898b2d1 100644 --- a/shatter-mobile/src/components/general/SocialLinksModal.tsx +++ b/shatter-mobile/src/components/general/SocialLinksModal.tsx @@ -8,12 +8,13 @@ import { View, } from "react-native"; import { UpdateProfileStyling as styles } from "../../styling/UpdateProfile.styles"; +import { OtherLink, SocialLinks } from "@/src/interfaces/User"; type SocialLinkModalProps = { socialModalVisible: boolean; setSocialModalVisible: (visible: boolean) => void; - socialLinks: { label: string; url: string }[]; - setSocialLinks: (links: { label: string; url: string }[]) => void; + socialLinks: SocialLinks; + setSocialLinks: (links: SocialLinks) => void; }; export function SocialLinksModal({ @@ -22,93 +23,170 @@ export function SocialLinksModal({ socialLinks, setSocialLinks, }: SocialLinkModalProps) { - const handleLinkChange = ( + //fixed fields + const updateField = ( + field: "linkedin" | "github", + value: string + ) => { + setSocialLinks({ + ...socialLinks, + [field]: value, + }); + }; + + //other links + const updateOtherLink = ( index: number, - field: "label" | "url", - value: string, + field: keyof OtherLink, + value: string ) => { - const updated = [...socialLinks]; - updated[index] = { ...updated[index], [field]: value }; - setSocialLinks(updated); + const updated = [...(socialLinks.other || [])]; + updated[index] = { + ...updated[index], + [field]: value, + }; + + setSocialLinks({ + ...socialLinks, + other: updated, + }); + }; + + const addOtherLink = () => { + setSocialLinks({ + ...socialLinks, + other: [ + ...(socialLinks.other || []), + { label: "", url: "" }, + ], + }); }; - const addNewLink = () => - setSocialLinks([...socialLinks, { label: "", url: "" }]); + const removeOtherLink = (index: number) => { + const updated = (socialLinks.other || []).filter( + (_, i) => i !== index + ); + + setSocialLinks({ + ...socialLinks, + other: updated, + }); + }; - const removeLink = (index: number) => - setSocialLinks(socialLinks.filter((_, i) => i !== index)); return ( - <> - setSocialModalVisible(false)} + setSocialModalVisible(false)} + > + - - - Manage Social Links + + Manage Social Links + + + + + {/* LinkedIn */} + LinkedIn + + updateField("linkedin", text) + } + autoCapitalize="none" + keyboardType="url" + /> + + {/* GitHub */} + GitHub + + updateField("github", text) + } + autoCapitalize="none" + keyboardType="url" + /> + + {/* Other */} + + Other Links - - {socialLinks.map((link, index) => ( - - - handleLinkChange(index, "label", text) - } - /> - - handleLinkChange(index, "url", text) - } - /> - removeLink(index)} - > - Remove - - - ))} - - + Add Social Link - - + + {(socialLinks.other || []).map((link, index) => ( + + + updateOtherLink(index, "label", text) + } + /> + + + updateOtherLink(index, "url", text) + } + autoCapitalize="none" + keyboardType="url" + /> + + removeOtherLink(index)} + > + + Remove + + + + ))} setSocialModalVisible(false)} + style={styles.addButton} + onPress={addOtherLink} > - Done + + + Add Other Link + - + + + + setSocialModalVisible(false)} + > + Done + - - + + ); } diff --git a/shatter-mobile/src/components/login-signup/LoginForm.tsx b/shatter-mobile/src/components/login-signup/LoginForm.tsx index d506354..9822282 100644 --- a/shatter-mobile/src/components/login-signup/LoginForm.tsx +++ b/shatter-mobile/src/components/login-signup/LoginForm.tsx @@ -60,7 +60,7 @@ export default function LoginForm() { _id: userResponse.userId, name: userData.user.name, email, - socialLinks: userData.user.socialLinks ?? [], + socialLinks: userData.user.socialLinks ?? {}, profilePhoto: userData.user.profilePhoto, organization: userData.user.organization, title: userData.user.title, diff --git a/shatter-mobile/src/components/login-signup/SignupForm.tsx b/shatter-mobile/src/components/login-signup/SignupForm.tsx index d4130c0..fe9c5de 100644 --- a/shatter-mobile/src/components/login-signup/SignupForm.tsx +++ b/shatter-mobile/src/components/login-signup/SignupForm.tsx @@ -80,7 +80,7 @@ export default function SignUpForm() { _id: userResponse.userId, name, email, - socialLinks: [], + socialLinks: {}, profilePhoto: profilePhoto, isGuest: false, }; diff --git a/shatter-mobile/src/components/new-events/JoinEvent.tsx b/shatter-mobile/src/components/new-events/JoinEvent.tsx index 8b95f06..d066a3a 100644 --- a/shatter-mobile/src/components/new-events/JoinEvent.tsx +++ b/shatter-mobile/src/components/new-events/JoinEvent.tsx @@ -46,7 +46,7 @@ export function useJoinEvent() { //guest joining event if (!user._id) { //first time joining event - const guestInfo = await JoinEventIdGuest(event._id, user.name); + const guestInfo = await JoinEventIdGuest(event._id, user.name, user.socialLinks ?? {}, user.organization ?? ""); user._id = guestInfo.userId; setCurrentParticipantId(guestInfo.participant._id); diff --git a/shatter-mobile/src/interfaces/User.tsx b/shatter-mobile/src/interfaces/User.tsx index 6678566..8755697 100644 --- a/shatter-mobile/src/interfaces/User.tsx +++ b/shatter-mobile/src/interfaces/User.tsx @@ -1,8 +1,14 @@ -export type SocialLink = { - label: string; - url: string; +export type SocialLinks = { + linkedin?: string; + github?: string; + other?: OtherLink[]; }; +export type OtherLink = { + label: string, + url: string, +} + export type Connection = { _id: string; _eventId: string; @@ -19,7 +25,7 @@ export type User = { password?: string; bio?: string; profilePhoto?: string; - socialLinks?: SocialLink[]; + socialLinks?: SocialLinks; title?: string; organization?: string; isGuest: boolean; diff --git a/shatter-mobile/src/interfaces/requests/JoinEventByIdGuestRequest.tsx b/shatter-mobile/src/interfaces/requests/JoinEventByIdGuestRequest.tsx index faee289..f1ba90b 100644 --- a/shatter-mobile/src/interfaces/requests/JoinEventByIdGuestRequest.tsx +++ b/shatter-mobile/src/interfaces/requests/JoinEventByIdGuestRequest.tsx @@ -1,3 +1,7 @@ +import { SocialLinks } from "../User" + export default interface EventJoinIdGuestRequest { name: string + socialLinks: SocialLinks + organization: string, } \ No newline at end of file diff --git a/shatter-mobile/src/services/event.service.tsx b/shatter-mobile/src/services/event.service.tsx index fbeaad5..4bafc0f 100644 --- a/shatter-mobile/src/services/event.service.tsx +++ b/shatter-mobile/src/services/event.service.tsx @@ -8,6 +8,7 @@ import { GetUserEventsApi } from "../api/users/user.api"; import EventResponse from "../interfaces/responses/GetEventResponse"; import UserEventsResponse from "../interfaces/responses/GetUserEventsResponse"; import EventJoinIdResponse from "../interfaces/responses/JoinEventIdResponse"; +import { SocialLinks } from "../interfaces/User"; export async function getEventByCode( joinCode: string, @@ -40,6 +41,8 @@ export async function JoinEventIdUser( export async function JoinEventIdGuest( eventId: string, name: string, + socialLinks: SocialLinks, + organization: string, ): Promise { - return await JoinEventByIdGuestApi(eventId, name); + return await JoinEventByIdGuestApi(eventId, name, socialLinks, organization); } diff --git a/shatter-mobile/src/services/linkedin_auth.service.ts b/shatter-mobile/src/services/linkedin_auth.service.ts new file mode 100644 index 0000000..1bb6891 --- /dev/null +++ b/shatter-mobile/src/services/linkedin_auth.service.ts @@ -0,0 +1,40 @@ +import * as Linking from "expo-linking"; +import * as WebBrowser from "expo-web-browser"; +import { User } from "../interfaces/User"; +import { exchangeLinkedInCode, userFetch } from "./user.service"; + +const REDIRECT_SCHEME = "shattermobile://auth"; + +export async function loginWithLinkedIn(): Promise< + { user: User; token: string } | null +> { + const result = await WebBrowser.openAuthSessionAsync( + `${process.env.EXPO_PUBLIC_API_BASE}/api/auth/linkedin?platform=mobile`, + REDIRECT_SCHEME, + ); + + if (result.type !== "success") return null; + + const { queryParams } = Linking.parse(result.url); + const errorMessage = queryParams?.message as string | undefined; + if (errorMessage) throw new Error(errorMessage); + + const code = queryParams?.code as string | undefined; + if (!code) return null; + + const { userId, token } = await exchangeLinkedInCode(code); + const userData = await userFetch(userId, token); + + const user: User = { + _id: userId, + name: userData.user.name, + email: userData.user.email, + socialLinks: userData.user.socialLinks ?? {}, + profilePhoto: userData.user.profilePhoto, + organization: userData.user.organization, + title: userData.user.title, + isGuest: false, + }; + + return { user, token }; +} diff --git a/shatter-mobile/src/services/user.service.tsx b/shatter-mobile/src/services/user.service.tsx index 7713035..7be8e09 100644 --- a/shatter-mobile/src/services/user.service.tsx +++ b/shatter-mobile/src/services/user.service.tsx @@ -1,12 +1,13 @@ import { CreateUserConnectionsApi, + ExchangeLinkedInCodeApi, GetParticipantApi, GetUserConnectionsApi, UserFetchApi, + UserLinkedInLinkApi, UserLoginApi, UserSignupApi, UserUpdateApi, - ExchangeLinkedInCodeApi } from "../api/users/user.api"; import CreateUserConnectionResponse from "../interfaces/responses/CreateUserConnectionResponse"; import { ConnectedUser } from "../interfaces/responses/GetParticipantInfoResponse"; @@ -17,7 +18,6 @@ import UserLoginResponse from "../interfaces/responses/UserLoginResponse"; import UserSignupResponse from "../interfaces/responses/UserSignupResponse"; import { User } from "../interfaces/User"; - export async function userLogin( email: string, password: string, @@ -79,6 +79,15 @@ export async function userUpdate( ): Promise { return await UserUpdateApi(userId, updates, token); } -export async function exchangeLinkedInCode(code: string): Promise { - return await ExchangeLinkedInCodeApi(code); + +export async function exchangeLinkedInCode( + code: string, +): Promise { + return await ExchangeLinkedInCodeApi(code); +} + +export async function UserLinkedInLink( + userId: string, +): Promise { + return await UserLinkedInLinkApi(userId); } diff --git a/shatter-mobile/src/styling/EventPage.styles.ts b/shatter-mobile/src/styling/EventPage.styles.ts index c5e6a10..7a86a28 100644 --- a/shatter-mobile/src/styling/EventPage.styles.ts +++ b/shatter-mobile/src/styling/EventPage.styles.ts @@ -19,8 +19,10 @@ export const EventPageStyling = StyleSheet.create({ alignItems: "center", }, header: { - height: vh(7), + height: vh(20), justifyContent: "center", + alignItems: "center", + marginTop: 5, }, pageTitle: { fontSize: vw(8), @@ -38,7 +40,7 @@ export const EventPageStyling = StyleSheet.create({ }, container: { position: "absolute", - top: vh(15), + top: vh(22), width: "100%", bottom: 0, backgroundColor: colors.lightGrey, diff --git a/shatter-mobile/src/styling/GamePage.styles.ts b/shatter-mobile/src/styling/GamePage.styles.ts index 52c93de..f4d2d3b 100644 --- a/shatter-mobile/src/styling/GamePage.styles.ts +++ b/shatter-mobile/src/styling/GamePage.styles.ts @@ -15,8 +15,6 @@ export const GamePageStyling = StyleSheet.create({ }, safe: { flex: 1, - flexDirection: "column", - alignItems: "center", }, page: { flex: 1, diff --git a/shatter-mobile/src/styling/JoinEventPage.styles.ts b/shatter-mobile/src/styling/JoinEventPage.styles.ts index dec183f..0d2a6ca 100644 --- a/shatter-mobile/src/styling/JoinEventPage.styles.ts +++ b/shatter-mobile/src/styling/JoinEventPage.styles.ts @@ -8,21 +8,20 @@ const vh = (percent: number) => (height * percent) / 100; export const JoinEventStyling = StyleSheet.create({ background: { - flex: 1, - width: "100%", - height: "100%", + width, + height, backgroundColor: colors.lightGrey, }, safe: { flex: 1, flexDirection: "column", - alignItems: "center", }, header: { - height: vh(10), + height: vh(28), justifyContent: "center", alignItems: "center", marginTop: 5, + paddingHorizontal: 20, }, pageTitle: { fontSize: vw(8), @@ -31,31 +30,39 @@ export const JoinEventStyling = StyleSheet.create({ color: "#A8C8E8", textAlign: "center", }, - subtitle: { + subtitleName: { marginTop: 6, - fontSize: vw(4), + fontSize: vw(4.2), color: "#ffffff", - opacity: 0.9, + fontWeight: "700", letterSpacing: 1, textAlign: "center", - paddingHorizontal: 10, + }, + subtitle: { + marginTop: 2, + fontSize: vw(3.8), + color: "#ffffff", + opacity: 0.85, + letterSpacing: 0.5, + textAlign: "center", }, container: { position: "absolute", - top: vh(20), - width: "100%", - bottom: 0, + top: vh(26), + left: 0, + right: 0, + bottom: -50, backgroundColor: colors.lightGrey, - borderTopLeftRadius: 12, - borderTopRightRadius: 12, - padding: 20, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: 16, }, section: { backgroundColor: colors.white, borderRadius: 10, - padding: 15, - marginBottom: 20, + padding: 12, + marginBottom: 12, }, label: { @@ -127,7 +134,7 @@ export const JoinEventStyling = StyleSheet.create({ fontSize: 16, }, divider: { - marginVertical: 15, + marginVertical: 8, alignItems: "center", }, dividerText: { diff --git a/shatter-mobile/src/styling/PageStyles.styles.ts b/shatter-mobile/src/styling/PageStyles.styles.ts index 4229194..d6da674 100644 --- a/shatter-mobile/src/styling/PageStyles.styles.ts +++ b/shatter-mobile/src/styling/PageStyles.styles.ts @@ -4,7 +4,7 @@ export const barStyles = { //bottom tab bar tabBarStyle: { backgroundColor: colors.darkNavy, - height: 80, + height: 50, borderTopWidth: 0, overflow: "hidden", shadowColor: "#000", @@ -12,6 +12,7 @@ export const barStyles = { shadowOpacity: 0.2, shadowRadius: 4, }, + tabBarHideOnKeyboard: true, tabBarActiveTintColor: colors.white, tabBarInactiveTintColor: colors.lightNavy, }; diff --git a/shatter-mobile/src/styling/ProfilePage.styles.ts b/shatter-mobile/src/styling/ProfilePage.styles.ts index c94877d..e205326 100644 --- a/shatter-mobile/src/styling/ProfilePage.styles.ts +++ b/shatter-mobile/src/styling/ProfilePage.styles.ts @@ -19,9 +19,10 @@ export const ProfilePageStyling = StyleSheet.create({ alignItems: "center", }, header: { - height: vh(7), + height: vh(20), justifyContent: "center", alignItems: "center", + marginTop: 5, }, pageTitle: { fontSize: vw(8), @@ -50,14 +51,13 @@ export const ProfilePageStyling = StyleSheet.create({ }, container: { position: "absolute", - top: vh(15), + top: vh(22), width: "100%", bottom: 0, backgroundColor: colors.lightGrey, - borderTopLeftRadius: 12, - borderTopRightRadius: 12, - padding: 20, - alignItems: "center", + borderTopLeftRadius: 8, + borderTopRightRadius: 8, + padding: 5, }, avatar: { width: vw(25), diff --git a/shatter-mobile/src/styling/UserModal.styles.ts b/shatter-mobile/src/styling/UserModal.styles.ts index 1747efc..62056ac 100644 --- a/shatter-mobile/src/styling/UserModal.styles.ts +++ b/shatter-mobile/src/styling/UserModal.styles.ts @@ -60,21 +60,6 @@ export const UserModalStyling = StyleSheet.create({ lineHeight: 22, }, - linkLabel: { - fontFamily: fonts.title, - fontSize: 14, - color: colors.darkNavy, - fontWeight: "bold", - marginTop: 6, - }, - - link: { - fontFamily: fonts.body, - fontSize: 14, - color: colors.darkBlue, - marginBottom: 10, - }, - leaveUserButton: { backgroundColor: colors.darkNavy, paddingVertical: 12, diff --git a/shatter-web/package-lock.json b/shatter-web/package-lock.json index 9d5d8e0..cfd9b2b 100644 --- a/shatter-web/package-lock.json +++ b/shatter-web/package-lock.json @@ -64,7 +64,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1644,7 +1643,6 @@ "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1655,7 +1653,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1716,7 +1713,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -1969,7 +1965,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2088,7 +2083,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -2383,7 +2377,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3490,7 +3483,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3500,7 +3492,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3841,7 +3832,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3900,7 +3890,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3986,7 +3975,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4078,7 +4066,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/shatter-web/src/components/BingoTable.tsx b/shatter-web/src/components/BingoTable.tsx index 5fb19b5..6c34248 100644 --- a/shatter-web/src/components/BingoTable.tsx +++ b/shatter-web/src/components/BingoTable.tsx @@ -1,37 +1,175 @@ +import { useState } from "react"; +import { GenerateQuestions } from "../service/GenerateQuestions"; +import { GenerateIndividualQuestions } from "../service/GenerateIndividualQuestions"; +import useTagInput from "../hooks/useTag"; +import { TagField } from "./TagField"; + +// Displays the bingo table and handles input/AI generation export interface BingoCell { question: string; shortQuestion: string; } interface BingoTableProps { - grid: BingoCell[][]; + bingoGrid: BingoCell[][]; onChange: (row: number, col: number, value: BingoCell) => void; + bingosize: number; // 3 or 5 for now, but will be dynamic in the future + setBingoGrid: React.Dispatch>; + bingoDescription: string; } -export default function BingoTable({ grid, onChange }: BingoTableProps) { - const size = grid.length; +//This should probably use "UseState" for the columns and rows in the future when it becomes resizable. For now, it is just hard coded to 3x3/5x5 + +export default function BingoTable({ bingoGrid, onChange, bingosize, setBingoGrid, bingoDescription }: BingoTableProps) { + const size = bingosize; + //const [bingoDescription, setBingoDescription] = useState(""); + const [fetching, setFetching] = useState(false); + const [selectedCells, setSelectedCells] = useState>(new Set()); + + const MAX_TAGS = 5; + const { tags, handleAddTag, handleRemoveTag } = useTagInput(MAX_TAGS); + + + //Handling cell selection + const toggleCellSelection = (row: number, col: number) => { + const key = `${row}-${col}`; + setSelectedCells(prev => { + const newSet = new Set(prev); + if (newSet.has(key)) { + newSet.delete(key); + } else { + newSet.add(key); + } + return newSet; + }); + }; + + const generateBingoQuestions = async () => { + try { + setFetching(true); + + const selected = Array.from(selectedCells).map(key => { + const [row, col] = key.split("-").map(Number); + return { row, col }; + }); + + if (selected.length === 0) { + const result = await GenerateQuestions({ + event_description: bingoDescription, + n_rows: size, + n_cols: size, + tags: tags, + }); + if (result?.bingoGrid) { + setBingoGrid(result.bingoGrid); + } + } else { + const bingo_grid = bingoGrid.map(row => row.map(cell => cell.question)); + const bingo_question_target = selected.map(({ row, col }) => bingoGrid[row][col].question); + + const results = await GenerateIndividualQuestions({ + event_description: bingoDescription, + tags, + bingo_grid, + bingo_question_target, + }); + + setBingoGrid(prevGrid => { + const newGrid = prevGrid.map(row => [...row]); + selected.forEach(({ row, col }, index) => { + const newCell = results[index]; + if (newCell?.question && newCell?.shortQuestion) { + newGrid[row][col] = { + question: newCell.question, + shortQuestion: newCell.shortQuestion, + }; + } + }); + return newGrid; + }); + + setSelectedCells(new Set()); + } + + } catch (error) { + console.error("Error generating bingo questions: ", error); + } finally { + setFetching(false); + } + }; return ( -
+ +
+
+ {/* //old description input + + setBingoDescription(e.target.value)} + placeholder="e.g., Software engineering event..." + className="w-full p-3 rounded-lg bg-white/5 border border-white/20 text-white placeholder-white/40 focus:outline-none focus:border-[#4DC4FF] transition-colors font-body" + /> + */} + + + +
+ + +
- {grid.map((row, rowIndex) => + {bingoGrid.map((row, rowIndex) => row.map((cell, colIndex) => ( -
+
toggleCellSelection(rowIndex, colIndex)} + className={`bg-white/5 p-4 rounded-lg border cursor-pointer ${selectedCells.has(`${rowIndex}-${colIndex}`) + ? "border-[#4DC4FF] bg-[#4DC4FF]/20" + : "border-white/20" + }`} + > + {/* LONG QUESTION */} - onChange(rowIndex, colIndex, { + onClick={(e) => e.stopPropagation()} + onChange={(e) => { + const newGrid = bingoGrid.map(r => [...r]); + newGrid[rowIndex][colIndex] = { ...cell, question: e.target.value, - }) - } + }; + setBingoGrid(newGrid); + }} placeholder="Full question" className="w-full mb-2 p-2 rounded bg-white/10 text-white text-sm resize-none" /> @@ -40,15 +178,28 @@ export default function BingoTable({ grid, onChange }: BingoTableProps) { - onChange(rowIndex, colIndex, { + onClick={(e) => e.stopPropagation()} // Prevent cell selection when clicking on short question input + onChange={(e) => { + const newGrid = bingoGrid.map(r => [...r]); + newGrid[rowIndex][colIndex] = { ...cell, shortQuestion: e.target.value, - }) - } + }; + setBingoGrid(newGrid); + }} placeholder="Short version" className="w-full p-2 rounded bg-white/10 text-white text-xs" /> + + +
toggleCellSelection(rowIndex, colIndex)} + className={`bg-white/5 mt-2 mx-auto px-3 py-1 rounded-full border border-[#4DC4FF] text-[#4DC4FF] bg-[#4DC4FF]/20 text-sm text-center cursor-pointer w-fit + ${selectedCells.has(`${rowIndex}-${colIndex}`) ? "visible" : "invisible"}`} + > + Regenerate +
)) )} diff --git a/shatter-web/src/components/EventSpotlight.tsx b/shatter-web/src/components/EventSpotlight.tsx index 7f477b7..2f0fb11 100644 --- a/shatter-web/src/components/EventSpotlight.tsx +++ b/shatter-web/src/components/EventSpotlight.tsx @@ -17,6 +17,15 @@ export interface ActivityItem { timestamp: number; } +export interface LeaderboardEntry { + participantId: string; + name: string; + profilePhoto: string | null; + connectionsCount: number; + linesCompleted: number; + completed: boolean; +} + /** Row from GET /api/participantConnections/connected-users */ interface ConnectedUserRow { user: { @@ -106,6 +115,7 @@ interface EventSpotlightProps { eventId: string | null; connections?: Connection[]; activity?: ActivityItem[]; + leaderboardEntries?: LeaderboardEntry[]; } const BUBBLE_RADIUS = 32; @@ -142,6 +152,7 @@ export default function EventSpotlight({ eventId, connections: connectionsProp = [], activity = [], + leaderboardEntries = [], }: EventSpotlightProps) { const [hoveredId, setHoveredId] = useState(null); const [hoveredEdgeId, setHoveredEdgeId] = useState(null); @@ -240,8 +251,8 @@ export default function EventSpotlight({ return map; }, [participants, centerX, centerY, graphRadius]); - // Leaderboard: count connections per participant — show ALL participants - const leaderboard = useMemo(() => { + // Fallback leaderboard from connections if explicit leaderboard is not provided + const connectionLeaderboard = useMemo(() => { const scores = new Map(); participants.forEach((p) => scores.set(p.participantId, { name: p.name, count: 0 })); @@ -476,41 +487,95 @@ export default function EventSpotlight({ {/* Leaderboard */}

- Connection Leaderboard + Leaderboard

- {leaderboard.map((entry, i) => ( -
- No leaderboard data yet

+ ) : leaderboardEntries.length > 0 ? ( + leaderboardEntries.map((entry, i) => ( +
- {i + 1} - - - {entry.name} - - + {i + 1} + + {entry.profilePhoto ? ( + {entry.name} + ) : ( +
+ {entry.name.charAt(0).toUpperCase()} +
+ )} +
+
+ + {entry.name} + + {entry.completed && ( + + Completed + + )} +
+
+ Lines: {entry.linesCompleted} • Connections: {entry.connectionsCount} +
+
+
+ )) + ) : ( + connectionLeaderboard.map((entry, i) => ( +
- {entry.connections} {entry.connections === 1 ? "link" : "links"} - -
- ))} + + {i + 1} + + + {entry.name} + + + {entry.connections} {entry.connections === 1 ? "link" : "links"} + +
+ )) + )}
diff --git a/shatter-web/src/components/Leaderboard.tsx b/shatter-web/src/components/Leaderboard.tsx new file mode 100644 index 0000000..59c6b46 --- /dev/null +++ b/shatter-web/src/components/Leaderboard.tsx @@ -0,0 +1,147 @@ +import { useEffect, useRef, useState } from "react"; +import { pusher } from "../libs/pusher_websocket"; + +interface LeaderboardEntry { + participantId: string; + name: string; + profilePhoto?: string | null; + connectionsCount: number; + linesCompleted: number; + completed: boolean; +} + +interface Props { + eventId: string; +} + +const API_URL = import.meta.env.VITE_API_URL as string; + +function sortLeaderboard(entries: LeaderboardEntry[]): LeaderboardEntry[] { + return [...entries].sort((a, b) => { + if (a.completed !== b.completed) return a.completed ? -1 : 1; + if (a.linesCompleted !== b.linesCompleted) + return b.linesCompleted - a.linesCompleted; + return b.connectionsCount - a.connectionsCount; + }); +} + +export default function Leaderboard({ eventId }: Props) { + const [leaderboard, setLeaderboard] = useState([]); + const leaderboardRef = useRef([]); + + // Initial fetch — only keep entries that have at least one connection + useEffect(() => { + const token = localStorage.getItem("token"); + + fetch(`${API_URL}/events/${eventId}/leaderboard`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((res) => { + if (!res.ok) throw new Error("Failed to fetch leaderboard"); + return res.json() as Promise; + }) + .then((data) => { + const withConnections = sortLeaderboard( + data.filter((e) => e.connectionsCount > 0) + ); + leaderboardRef.current = withConnections; + setLeaderboard(withConnections); + }) + .catch(() => {}); + }, [eventId]); + + // Pusher subscription + useEffect(() => { + const channel = pusher.subscribe(`event-${eventId}`); + + channel.bind("leaderboard-updated", (payload: LeaderboardEntry) => { + const current = leaderboardRef.current; + const exists = current.some((e) => e.participantId === payload.participantId); + + let updated: LeaderboardEntry[]; + if (exists) { + updated = current.map((e) => + e.participantId === payload.participantId ? { ...e, ...payload } : e + ); + } else if (payload.connectionsCount > 0) { + // Only add if they actually have a connection + updated = [...current, payload]; + } else { + return; + } + + const sorted = sortLeaderboard(updated); + leaderboardRef.current = sorted; + setLeaderboard(sorted); + }); + + return () => { + channel.unbind("leaderboard-updated"); + pusher.unsubscribe(`event-${eventId}`); + }; + }, [eventId]); + + if (leaderboard.length === 0) { + return ( + <> +
LEADERBOARD
+

Waiting for first connection...

+ + ); + } + + return ( +
+
LEADERBOARD
+ {leaderboard.map((entry, i) => ( +
+ + {i + 1} + + + {entry.profilePhoto && ( + {entry.name} + )} + + + {entry.name} + + + + {entry.linesCompleted} line{entry.linesCompleted !== 1 ? "s" : ""} + + + + {entry.connectionsCount} {entry.connectionsCount === 1 ? "link" : "links"} + + + {entry.completed && ( + + ✓ + + )} +
+ ))} +
+ ); +} \ No newline at end of file diff --git a/shatter-web/src/components/TagField.tsx b/shatter-web/src/components/TagField.tsx new file mode 100644 index 0000000..9611c5e --- /dev/null +++ b/shatter-web/src/components/TagField.tsx @@ -0,0 +1,76 @@ +import { useState, type ChangeEvent } from "react"; + +interface iTag { + tags: string[]; + addTag: (tag: string) => void; + removeTag: (tag: string) => void; + maxTags: number; +} + +export const TagField = ({ tags, addTag, removeTag, maxTags }: iTag) => { + // track the use input + + const [userInput, setUserInput] = useState(""); + + // Handle input onChange + + const handleInputChange = (e: ChangeEvent) => { + setUserInput(e.target.value); + }; + + // handle Enter key press + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); // Prevent form submission or new line creation + + if ( + userInput.trim() !== "" && + userInput.trim().length <= 18 && + tags.length < maxTags + ) { + addTag(userInput.trim()); + setUserInput(""); // Clear the input after adding a tag + } + } + }; + + return ( +
+ + + {/* Render tags */} + +
+ {tags.map((tag: string, index: number) => ( + + {tag} + + + ))} +
+
+ ); +}; \ No newline at end of file diff --git a/shatter-web/src/hooks/useTag.tsx b/shatter-web/src/hooks/useTag.tsx new file mode 100644 index 0000000..aead91c --- /dev/null +++ b/shatter-web/src/hooks/useTag.tsx @@ -0,0 +1,20 @@ +import React, { useState } from "react"; + +const useTagInput = (maxTags = 6) => { + const [tags, setTags] = useState([]); + + const handleAddTag = (newTag: string) => { + if (newTag && !tags.includes(newTag) && tags.length < maxTags) { + setTags([...tags, newTag]); + } + }; + + const handleRemoveTag = (tag: string) => { + setTags(tags.filter((t) => t !== tag)); + }; + + return { tags, handleAddTag, handleRemoveTag }; +} + + +export default useTagInput; \ No newline at end of file diff --git a/shatter-web/src/pages/CreateEventPage.tsx b/shatter-web/src/pages/CreateEventPage.tsx index 7c5b9d8..06fcf0a 100644 --- a/shatter-web/src/pages/CreateEventPage.tsx +++ b/shatter-web/src/pages/CreateEventPage.tsx @@ -4,10 +4,7 @@ import Footer from "../components/Footer"; import BingoTable from "../components/BingoTable"; import { CreateEvent } from "../service/CreateEvent"; import { useNavigate } from "react-router-dom"; -export interface BingoCell { - question: string; - shortQuestion: string; -} +import type { BingoCell } from "../types/BingoCell"; function CreateEventPage() { const [name, setName] = useState(""); const [description, setDescription] = useState(""); @@ -26,7 +23,7 @@ function CreateEventPage() { })) ); const [nameBingoSelected, setNameBingoSelected] = useState(false); const [bingoGrid, setBingoGrid] = useState(createEmptyGrid(3)); - const [bingoDescription, setBingoDescription] = useState(""); + //const [bingoDescription, setBingoDescription] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -43,6 +40,8 @@ function CreateEventPage() { throw new Error("Please fill in all required fields."); } + + // ✅ Validate bingo (if selected) if (nameBingoSelected) { const hasEmptyCells = bingoGrid.some(row => @@ -53,7 +52,9 @@ function CreateEventPage() { throw new Error("Please fill in all bingo grid cells."); } - if (!bingoDescription.trim()) { + console.log(description) + + if (!description.trim()) { throw new Error("Please add a bingo description."); } } @@ -80,7 +81,7 @@ function CreateEventPage() { }, body: JSON.stringify({ _eventId: eventId, - description: bingoDescription, + description: description, grid: bingoGrid, }), }); @@ -299,6 +300,7 @@ function CreateEventPage() { {/*Icebreaker Properties*/} {nameBingoSelected && (
+ {/*
- + */} { - const newGrid = bingoGrid.map(r => [...r]); - newGrid[row][col] = value; - setBingoGrid(newGrid); - }} + bingoGrid={bingoGrid} + onChange={(row, col, value) => { + const newGrid = bingoGrid.map(r => [...r]); + newGrid[row][col] = value; + setBingoGrid(newGrid); + }} + setBingoGrid={setBingoGrid} + bingosize={3} + bingoDescription={description} />
)} diff --git a/shatter-web/src/pages/DashboardPage.tsx b/shatter-web/src/pages/DashboardPage.tsx index d23f06f..646fc1d 100644 --- a/shatter-web/src/pages/DashboardPage.tsx +++ b/shatter-web/src/pages/DashboardPage.tsx @@ -4,11 +4,7 @@ import Navbar from "../components/Navbar"; import Footer from "../components/Footer"; import BingoTable from "../components/BingoTable"; import { getBingo } from "../service/BingoGame"; - -export interface BingoCell { - question: string; - shortQuestion: string; -} +import type { BingoCell } from "../types/BingoCell"; import { CalendarIcon, @@ -83,9 +79,11 @@ const createEmptyGrid = (size: number): BingoCell[][] => question: "", shortQuestion: "", })) - ); const [selectedIcebreaker, setSelectedIcebreaker] = useState(null); + ); + const [selectedIcebreaker, setSelectedIcebreaker] = useState(null); const GRID_SIZE = 3; - const [bingoGrid, setBingoGrid] = useState(createEmptyGrid(GRID_SIZE)); const [bingoDescription, setBingoDescription] = useState(""); + const [bingoGrid, setBingoGrid] = useState(createEmptyGrid(GRID_SIZE)); + const [bingoDescription, setBingoDescription] = useState(""); const [isSavingBingo, setIsSavingBingo] = useState(false); const [bingoSaveMessage, setBingoSaveMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const [isDeletingEvent, setIsDeletingEvent] = useState(false); @@ -110,8 +108,9 @@ const createEmptyGrid = (size: number): BingoCell[][] => }, [events, selectedEvent]); // Load bingo data whenever selected event changes (incl. auto-select) - useEffect(() => { - if (selectedEvent) { + + useEffect(() => { + if (selectedEvent && selectedIcebreaker === "bingo") { loadBingoData(selectedEvent._id); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -147,7 +146,7 @@ const createEmptyGrid = (size: number): BingoCell[][] => signal, }); - if (!response.ok) { + if (!response.ok && response.status != 404) { const text = await response.text().catch(() => ""); throw new Error( `Failed to fetch events (status ${response.status}). ${text ? text.slice(0, 120) : ""}`.trim() @@ -157,7 +156,7 @@ const createEmptyGrid = (size: number): BingoCell[][] => const data = await response.json(); if (!data?.success) { - throw new Error(data?.message || "Backend did not return success."); + return [] } const list = Array.isArray(data.events) ? data.events : []; @@ -188,12 +187,21 @@ const loadBingoData = async (eventId: string) => { const bingo = await getBingo(eventId); if (bingo) { + + /* const formattedGrid = bingo.grid.map(row => row.map(cell => ({ question: cell.question || "", shortQuestion: cell.shortQuestion || "", })) ); + */ + const formattedGrid = createEmptyGrid(GRID_SIZE).map((row, i) => + row.map((_, j) => ({ + question: bingo.grid?.[i]?.[j]?.question || "", + shortQuestion: bingo.grid?.[i]?.[j]?.shortQuestion || "", + })) + ); setBingoGrid(formattedGrid); setBingoDescription(bingo.description ?? ""); @@ -843,25 +851,18 @@ const loadBingoData = async (eventId: string) => {
-
- - setBingoDescription(e.target.value)} - placeholder="e.g., Find someone who..." - className="w-full p-3 rounded-lg bg-white/5 border border-white/20 text-white placeholder-white/40 focus:outline-none focus:border-[#4DC4FF] transition-colors font-body" - /> -
{ const newGrid = bingoGrid.map(r => [...r]); newGrid[row][col] = value; setBingoGrid(newGrid); }} + bingosize={GRID_SIZE} + setBingoGrid={setBingoGrid} + bingoDescription={selectedEvent.description} />
diff --git a/shatter-web/src/pages/EventPage.tsx b/shatter-web/src/pages/EventPage.tsx index c8202c2..959b01e 100644 --- a/shatter-web/src/pages/EventPage.tsx +++ b/shatter-web/src/pages/EventPage.tsx @@ -5,9 +5,11 @@ import { useState, useEffect } from "react"; import QRCard from "../components/QRCard"; import Navbar from "../components/Navbar"; import Footer from "../components/Footer"; -import EventSpotlight from "../components/EventSpotlight"; +import EventSpotlight, { type LeaderboardEntry } from "../components/EventSpotlight"; import { CalendarIcon, ClipboardCopyIcon, XIcon } from "../components/icons"; import type { BingoCell } from "../service/BingoGame"; +import { pusher } from "../libs/pusher_websocket"; +import Leaderboard from "../components/Leaderboard"; function normalizeBingoCell(cell: unknown): BingoCell { if (cell == null) return { question: "", shortQuestion: "" }; @@ -73,6 +75,7 @@ export default function EventPage() { grid: BingoCell[][]; } | null>(null); const [selectedBingoCell, setSelectedBingoCell] = useState(null); + const [leaderboard, setLeaderboard] = useState([]); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -110,6 +113,60 @@ export default function EventPage() { }); }, [eventId]); + // Fetch initial leaderboard (already sorted by backend). + useEffect(() => { + if (!eventId) return; + const authToken = localStorage.getItem("token"); + if (!authToken) { + setLeaderboard([]); + return; + } + + let isCancelled = false; + + const fetchLeaderboard = async () => { + try { + const res = await fetch(`${import.meta.env.VITE_API_URL}/events/${eventId}/leaderboard`, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + if (!res.ok) return; + const data: unknown = await res.json(); + if (isCancelled || !Array.isArray(data)) return; + setLeaderboard(data as LeaderboardEntry[]); + } catch { + if (!isCancelled) setLeaderboard([]); + } + }; + + fetchLeaderboard(); + return () => { + isCancelled = true; + }; + }, [eventId]); + + // Realtime leaderboard updates: backend sends full list. + useEffect(() => { + if (!eventId) return; + + const channelName = `event-${eventId}`; + const channel = pusher.subscribe(channelName); + + const handleLeaderboardUpdated = (data: { participants?: LeaderboardEntry[] }) => { + if (Array.isArray(data?.participants)) { + setLeaderboard(data.participants); + } + }; + + channel.bind("leaderboard-updated", handleLeaderboardUpdated); + + return () => { + channel.unbind("leaderboard-updated", handleLeaderboardUpdated); + pusher.unsubscribe(channelName); + }; + }, [eventId]); + // loading event state if (loading) { return ( @@ -327,7 +384,7 @@ export default function EventPage() { {/* Live Activity Spotlight */}
- +
{/* Main Content Grid */} diff --git a/shatter-web/src/pages/LoginPage.tsx b/shatter-web/src/pages/LoginPage.tsx index 7caf7ef..36f5d13 100644 --- a/shatter-web/src/pages/LoginPage.tsx +++ b/shatter-web/src/pages/LoginPage.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { GoogleIcon } from '../components/icons'; +import logo from "../assets/ShatterLogo_White.png"; + export default function LoginPage() { const navigate = useNavigate(); const [isLogin, setIsLogin] = useState(true); @@ -116,7 +118,7 @@ export default function LoginPage() {
{/* Logo */} Shatter Logo diff --git a/shatter-web/src/service/GenerateIndividualQuestions.ts b/shatter-web/src/service/GenerateIndividualQuestions.ts new file mode 100644 index 0000000..d233851 --- /dev/null +++ b/shatter-web/src/service/GenerateIndividualQuestions.ts @@ -0,0 +1,37 @@ +export async function GenerateIndividualQuestions(Data: { + event_description: string; + bingo_grid: string[][]; + tags: string[]; + bingo_question_target: string[]; +}) { + try { + const apiUrl = `${import.meta.env.VITE_API_URL}/bingo/generateBingo/individual`; + const token = localStorage.getItem('token'); + + const response = await fetch(apiUrl, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': token ? `Bearer ${token}` : '', + }, + body: JSON.stringify(Data), + }); + + if (!response.ok) { + throw new Error(`Server responded with status ${response.status}`); + } + + const responseData = await response.json(); + + if (responseData.status === false) { + throw new Error(`API responded with an error: ${responseData.msg}`); + } + // The bingo grid is filled with 2d arrays, where each entry is an array of objects. Each object represents a row + return responseData.new_questions; + + } catch (error) { + console.error("Generating questions failed with error ", error); + throw error; + } +} diff --git a/shatter-web/src/service/GenerateQuestions.ts b/shatter-web/src/service/GenerateQuestions.ts new file mode 100644 index 0000000..c468208 --- /dev/null +++ b/shatter-web/src/service/GenerateQuestions.ts @@ -0,0 +1,39 @@ +export async function GenerateQuestions(Data: { + event_description: string; + n_rows: number; + n_cols: number; + tags: string[]; +}) { + try { + const apiUrl = `${import.meta.env.VITE_API_URL}/bingo/generateBingo`; + const token = localStorage.getItem('token'); + + const response = await fetch(apiUrl, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': token ? `Bearer ${token}` : '', + }, + body: JSON.stringify(Data), + }); + + if (!response.ok) { + throw new Error(`Server responded with status ${response.status}`); + } + + const responseData = await response.json(); + + if (responseData.status === false) { + throw new Error(`API responded with an error: ${responseData.msg}`); + } + // The bingo grid is filled with 2d arrays, where each entry is an array of objects. Each object represents a row + return { + bingoGrid: responseData.bingo_grid + }; + + } catch (error) { + console.error("Generating questions failed with error ", error); + throw error; + } +} diff --git a/shatter-web/src/types/BingoCell.ts b/shatter-web/src/types/BingoCell.ts new file mode 100644 index 0000000..e930ad6 --- /dev/null +++ b/shatter-web/src/types/BingoCell.ts @@ -0,0 +1,4 @@ +export interface BingoCell { + question: string; + shortQuestion: string; +} \ No newline at end of file