From 1a07b4d4f5befba73f526329821ded757c852906 Mon Sep 17 00:00:00 2001 From: jktieh Date: Thu, 26 Mar 2026 18:35:30 -0600 Subject: [PATCH 1/6] Add normalizeBingoGrid function to process bingo grid data in EventPage --- shatter-web/src/pages/EventPage.tsx | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/shatter-web/src/pages/EventPage.tsx b/shatter-web/src/pages/EventPage.tsx index eae2499..0c6f0e4 100644 --- a/shatter-web/src/pages/EventPage.tsx +++ b/shatter-web/src/pages/EventPage.tsx @@ -35,6 +35,25 @@ export default function EventPage() { grid: string[][]; } | null>(null); + const normalizeBingoGrid = (grid: unknown): string[][] => { + if (!Array.isArray(grid)) return []; + + return grid.map((row) => { + if (Array.isArray(row)) { + return row.map((cell) => String(cell ?? "")); + } + + if (row && typeof row === "object") { + return Object.keys(row as Record) + .filter((key) => /^\d+$/.test(key)) + .sort((a, b) => Number(a) - Number(b)) + .map((key) => String((row as Record)[key] ?? "")); + } + + return []; + }); + }; + // Fetch bingo game for this event useEffect(() => { if (!eventId) return; @@ -53,7 +72,10 @@ export default function EventPage() { .then((res) => (res.ok ? res.json() : null)) .then((data) => { if (data?.bingo) { - setBingoGame(data.bingo); + setBingoGame({ + ...data.bingo, + grid: normalizeBingoGrid(data.bingo.grid), + }); } }) .catch(() => { @@ -470,7 +492,7 @@ export default function EventPage() { font-body font-semibold text-sm text-white p-2" style={{ backgroundColor: "rgba(27, 37, 58, 0.6)" }} > - {cell} + {String(cell ?? "")} ))} From 47d159e523547c3c9aff9a2cf574ff21775ef0a8 Mon Sep 17 00:00:00 2001 From: Youssef Ibrahim Date: Thu, 2 Apr 2026 14:55:29 -0600 Subject: [PATCH 2/6] Completed modification of bingo table to have a short form question and long form question section. --- shatter-web/src/components/BingoTable.tsx | 46 ++++++--- shatter-web/src/pages/CreateEventPage.tsx | 33 ++++--- shatter-web/src/pages/DashboardPage.tsx | 71 ++++++++------ shatter-web/src/service/BingoGame.ts | 110 +++++++++++++++------- 4 files changed, 169 insertions(+), 91 deletions(-) diff --git a/shatter-web/src/components/BingoTable.tsx b/shatter-web/src/components/BingoTable.tsx index 9b33770..5fb19b5 100644 --- a/shatter-web/src/components/BingoTable.tsx +++ b/shatter-web/src/components/BingoTable.tsx @@ -1,6 +1,11 @@ +export interface BingoCell { + question: string; + shortQuestion: string; +} + interface BingoTableProps { - grid: string[][]; - onChange: (row: number, col: number, value: string) => void; + grid: BingoCell[][]; + onChange: (row: number, col: number, value: BingoCell) => void; } export default function BingoTable({ grid, onChange }: BingoTableProps) { @@ -10,27 +15,40 @@ export default function BingoTable({ grid, onChange }: BingoTableProps) {
-
+
{grid.map((row, rowIndex) => row.map((cell, colIndex) => ( -
+
+ + {/* LONG QUESTION */} + + onChange(rowIndex, colIndex, { + ...cell, + question: e.target.value, + }) + } + placeholder="Full question" + className="w-full mb-2 p-2 rounded bg-white/10 text-white text-sm resize-none" + /> + + {/* SHORT QUESTION */} - onChange(rowIndex, colIndex, e.target.value) + onChange(rowIndex, colIndex, { + ...cell, + shortQuestion: e.target.value, + }) } - placeholder={`${rowIndex + 1}-${colIndex + 1}`} - className="w-full h-24 p-2 rounded-lg bg-white/5 border border-white/20 text-white text-xs placeholder-white/30 focus:outline-none focus:border-[#4DC4FF] focus:ring-2 focus:ring-[#4DC4FF]/20 transition-all font-body" + placeholder="Short version" + className="w-full p-2 rounded bg-white/10 text-white text-xs" /> -
- {rowIndex * size + colIndex + 1} -
)) )} diff --git a/shatter-web/src/pages/CreateEventPage.tsx b/shatter-web/src/pages/CreateEventPage.tsx index 6cce9c6..7c5b9d8 100644 --- a/shatter-web/src/pages/CreateEventPage.tsx +++ b/shatter-web/src/pages/CreateEventPage.tsx @@ -1,11 +1,13 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import Navbar from "../components/Navbar"; import Footer from "../components/Footer"; import BingoTable from "../components/BingoTable"; import { CreateEvent } from "../service/CreateEvent"; -import { createBingoGame } from "../service/BingoGame"; // ✅ NEW import { useNavigate } from "react-router-dom"; - +export interface BingoCell { + question: string; + shortQuestion: string; +} function CreateEventPage() { const [name, setName] = useState(""); const [description, setDescription] = useState(""); @@ -16,9 +18,14 @@ function CreateEventPage() { ); // ✅ NEW: Name Bingo selection - const createEmptyGrid = (size: number) => Array.from({ length: size }, () => Array(size).fill("")); - const [nameBingoSelected, setNameBingoSelected] = useState(false); - const [bingoGrid, setBingoGrid] = useState(createEmptyGrid(3)); + const createEmptyGrid = (size: number): BingoCell[][] => + Array.from({ length: size }, () => + Array.from({ length: size }, () => ({ + question: "", + shortQuestion: "", + })) + ); const [nameBingoSelected, setNameBingoSelected] = useState(false); + const [bingoGrid, setBingoGrid] = useState(createEmptyGrid(3)); const [bingoDescription, setBingoDescription] = useState(""); const [loading, setLoading] = useState(false); @@ -39,7 +46,7 @@ function CreateEventPage() { // ✅ Validate bingo (if selected) if (nameBingoSelected) { const hasEmptyCells = bingoGrid.some(row => - row.some(cell => !cell.trim()) + row.some(cell => !cell.question.trim() || !cell.shortQuestion.trim()) ); if (hasEmptyCells) { @@ -115,10 +122,12 @@ function CreateEventPage() { maxParticipant > 0; const token = localStorage.getItem("token"); - if (!token) { - navigate("/login"); - return; - } + useEffect(() => { + if (!token) { + navigate("/login"); + return; + } + }, [token, navigate]) return (
{ - const newGrid = bingoGrid.map(r => [...r]); // ✅ deep copy + const newGrid = bingoGrid.map(r => [...r]); newGrid[row][col] = value; setBingoGrid(newGrid); }} diff --git a/shatter-web/src/pages/DashboardPage.tsx b/shatter-web/src/pages/DashboardPage.tsx index 67e9487..ebacc8a 100644 --- a/shatter-web/src/pages/DashboardPage.tsx +++ b/shatter-web/src/pages/DashboardPage.tsx @@ -4,6 +4,12 @@ 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 { CalendarIcon, ClipboardIcon, @@ -60,10 +66,15 @@ function DashboardPage() { currentState: "Upcoming", }); - const createEmptyGrid = (size: number) => Array.from({ length: size }, () => Array(size).fill("")); - const [selectedIcebreaker, setSelectedIcebreaker] = useState(null); +const createEmptyGrid = (size: number): BingoCell[][] => + Array.from({ length: size }, () => + Array.from({ length: size }, () => ({ + question: "", + shortQuestion: "", + })) + ); 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); @@ -146,27 +157,32 @@ function DashboardPage() { setSelectedEvent(event); setIsEditing(false); setSelectedIcebreaker(null); - - // Load bingo data if it exists - loadBingoData(event._id); }; - const loadBingoData = async (eventId: string) => { - try { - const bingo = await getBingo(eventId); - if (bingo) { - setBingoGrid(bingo.grid); - setBingoDescription(bingo.description ?? ""); - } else { - setBingoGrid(createEmptyGrid(GRID_SIZE)); - setBingoDescription(""); - } - } catch (err) { - console.error("Error loading bingo data:", err); +const loadBingoData = async (eventId: string) => { + try { + const bingo = await getBingo(eventId); + + if (bingo) { + const formattedGrid = bingo.grid.map(row => + row.map(cell => ({ + question: cell.question || "", + shortQuestion: cell.shortQuestion || "", + })) + ); + + setBingoGrid(formattedGrid); + setBingoDescription(bingo.description ?? ""); + } else { setBingoGrid(createEmptyGrid(GRID_SIZE)); setBingoDescription(""); } - }; + } catch (err) { + console.error("Error loading bingo data:", err); + setBingoGrid(createEmptyGrid(GRID_SIZE)); + setBingoDescription(""); + } +}; const handleEditClick = () => { if (!selectedEvent) return; @@ -231,11 +247,6 @@ function DashboardPage() { } }; - const handleBingoGridChange = (row: number, col: number, value: string) => { - const newGrid = bingoGrid.map(r => [...r]); - newGrid[row][col] = value; - setBingoGrid(newGrid); - }; const handleSaveBingo = async () => { if (!selectedEvent) return; @@ -247,7 +258,7 @@ function DashboardPage() { const token = localStorage.getItem("token"); // Check if all grid cells are filled - const hasEmptyCells = bingoGrid.some(row => row.some(cell => !cell.trim())); + const hasEmptyCells = bingoGrid.some(row => row.some(cell => !cell.question.trim() || !cell.shortQuestion.trim())); if (hasEmptyCells) { setBingoSaveMessage({ type: "error", text: "Please fill in all bingo grid cells before saving." }); setIsSavingBingo(false); @@ -705,11 +716,11 @@ function DashboardPage() {
{ - const newGrid = bingoGrid.map(r => [...r]); - newGrid[row][col] = value; - setBingoGrid(newGrid); - }} + onChange={(row, col, value) => { + const newGrid = bingoGrid.map(r => [...r]); + newGrid[row][col] = value; + setBingoGrid(newGrid); + }} />
diff --git a/shatter-web/src/service/BingoGame.ts b/shatter-web/src/service/BingoGame.ts index 3191de8..cb88736 100644 --- a/shatter-web/src/service/BingoGame.ts +++ b/shatter-web/src/service/BingoGame.ts @@ -1,47 +1,87 @@ // services/BingoGame.ts -const BASE_URL = import.meta.env.VITE_API_URL ?? "https://techstart-shatter-backend.vercel.app/api"; +const BASE_URL = + import.meta.env.VITE_API_URL ?? + "https://techstart-shatter-backend.vercel.app/api"; +// ✅ NEW TYPE +export interface BingoCell { + question: string; + shortQuestion: string; +} + +// ✅ UPDATED TYPE export interface BingoGame { - _id: string; - _eventId: string; - description: string; - grid: string[][]; + _id: string; + _eventId: string; + description: string; + grid: BingoCell[][]; } +// ✅ GET BINGO export async function getBingo(eventId: string): Promise { - const res = await fetch(`${BASE_URL}/bingo/getBingo/${eventId}`); - const data = await res.json(); - if (!res.ok || !data?.bingo) return null; - return data.bingo; + const res = await fetch(`${BASE_URL}/bingo/getBingo/${eventId}`); + const data = await res.json(); + + if (!res.ok || !data?.bingo) return null; + + return data.bingo; } +// ✅ CREATE BINGO export async function createBingoGame( - eventId: string, - token: string + eventId: string, + description: string, + grid: BingoCell[][], + token: string ): Promise { - const res = await fetch(`${BASE_URL}/bingo/createBingo`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - _eventId: eventId, // ✅ THIS IS REQUIRED - description: "Name Bingo", - grid: [ - ["A1", "B1", "C1"], - ["A2", "B2", "C2"], - ["A3", "B3", "C3"] - ], - }), - }); - - const data = await res.json(); - - if (!res.ok || !data?.bingo) { - throw new Error("Failed to create bingo game"); - } - - return data.bingo; + const res = await fetch(`${BASE_URL}/bingo/createBingo`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + _eventId: eventId, + description, + grid, + }), + }); + + const data = await res.json(); + + if (!res.ok || !data?.bingo) { + throw new Error(data?.message || "Failed to create bingo game"); + } + + return data.bingo; } + +// ✅ UPDATE BINGO +export async function updateBingoGame( + eventId: string, + description: string, + grid: BingoCell[][], + token: string +): Promise { + const res = await fetch(`${BASE_URL}/bingo/updateBingo`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + id: eventId, // ⚠️ backend expects this (based on your current code) + description, + grid, + }), + }); + + const data = await res.json(); + + if (!res.ok || !data?.bingo) { + throw new Error(data?.message || "Failed to update bingo game"); + } + + return data.bingo; +} \ No newline at end of file From c719579e03b451c530853edc2afd6d8d6a4e8e9f Mon Sep 17 00:00:00 2001 From: jktieh Date: Wed, 8 Apr 2026 11:36:54 -0600 Subject: [PATCH 3/6] Add event deletion functionality to DashboardPage Implemented the ability to delete events from the dashboard. Added state management for deletion status and messages, and integrated a confirmation prompt before deletion. Updated the UI to reflect deletion status and provide feedback to the user. --- shatter-web/src/pages/DashboardPage.tsx | 70 +++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/shatter-web/src/pages/DashboardPage.tsx b/shatter-web/src/pages/DashboardPage.tsx index ebacc8a..fd0282e 100644 --- a/shatter-web/src/pages/DashboardPage.tsx +++ b/shatter-web/src/pages/DashboardPage.tsx @@ -77,6 +77,8 @@ const createEmptyGrid = (size: number): BingoCell[][] => 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); + const [deleteEventMessage, setDeleteEventMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const navigate = useNavigate(); @@ -157,6 +159,7 @@ const createEmptyGrid = (size: number): BingoCell[][] => setSelectedEvent(event); setIsEditing(false); setSelectedIcebreaker(null); + setDeleteEventMessage(null); }; const loadBingoData = async (eventId: string) => { @@ -247,6 +250,45 @@ const loadBingoData = async (eventId: string) => { } }; + const handleDeleteEvent = async () => { + if (!selectedEvent || isDeletingEvent) return; + setDeleteEventMessage(null); + + const confirmed = window.confirm(`Delete "${selectedEvent.name}"? This cannot be undone.`); + if (!confirmed) return; + + try { + setIsDeletingEvent(true); + const token = localStorage.getItem("token"); + if (!token) { + navigate("/login"); + return; + } + + const response = await fetch(`${import.meta.env.VITE_API_URL}/events/${selectedEvent._id}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData?.msg || errorData?.error || "Failed to delete event"); + } + + await fetchUserEvents(); + setSelectedEvent(null); + setSelectedIcebreaker(null); + setIsEditing(false); + setDeleteEventMessage({ type: "success", text: "Event deleted successfully." }); + } catch (err: any) { + setDeleteEventMessage({ type: "error", text: err?.message || "Failed to delete event" }); + } finally { + setIsDeletingEvent(false); + } + }; + const handleSaveBingo = async () => { if (!selectedEvent) return; @@ -501,10 +543,38 @@ const loadBingoData = async (eventId: string) => { > Edit +
)}
+ {deleteEventMessage && ( +
+ {deleteEventMessage.text} + +
+ )} + {/* Edit Form */} {isEditing ? (
From 106175bc2a9b2453ff53e096c55b0a9d4080ebfa Mon Sep 17 00:00:00 2001 From: jktieh Date: Wed, 8 Apr 2026 11:39:22 -0600 Subject: [PATCH 4/6] Add confirmation dialog for event deletion in DashboardPage Enhanced the event deletion functionality by introducing a confirmation dialog before executing the delete action. Updated state management to handle the confirmation state and adjusted the UI to provide clear options for confirming or canceling the deletion. --- shatter-web/src/pages/DashboardPage.tsx | 35 +++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/shatter-web/src/pages/DashboardPage.tsx b/shatter-web/src/pages/DashboardPage.tsx index fd0282e..c4604dc 100644 --- a/shatter-web/src/pages/DashboardPage.tsx +++ b/shatter-web/src/pages/DashboardPage.tsx @@ -79,6 +79,7 @@ const createEmptyGrid = (size: number): BingoCell[][] => const [bingoSaveMessage, setBingoSaveMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const [isDeletingEvent, setIsDeletingEvent] = useState(false); const [deleteEventMessage, setDeleteEventMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); const navigate = useNavigate(); @@ -160,6 +161,7 @@ const createEmptyGrid = (size: number): BingoCell[][] => setIsEditing(false); setSelectedIcebreaker(null); setDeleteEventMessage(null); + setIsDeleteConfirmOpen(false); }; const loadBingoData = async (eventId: string) => { @@ -254,9 +256,6 @@ const loadBingoData = async (eventId: string) => { if (!selectedEvent || isDeletingEvent) return; setDeleteEventMessage(null); - const confirmed = window.confirm(`Delete "${selectedEvent.name}"? This cannot be undone.`); - if (!confirmed) return; - try { setIsDeletingEvent(true); const token = localStorage.getItem("token"); @@ -281,6 +280,7 @@ const loadBingoData = async (eventId: string) => { setSelectedEvent(null); setSelectedIcebreaker(null); setIsEditing(false); + setIsDeleteConfirmOpen(false); setDeleteEventMessage({ type: "success", text: "Event deleted successfully." }); } catch (err: any) { setDeleteEventMessage({ type: "error", text: err?.message || "Failed to delete event" }); @@ -544,17 +544,42 @@ const loadBingoData = async (eventId: string) => { Edit
)}
+ {isDeleteConfirmOpen && selectedEvent && ( +
+

+ Delete "{selectedEvent.name}"? This cannot be undone. +

+
+ + +
+
+ )} + {deleteEventMessage && (
Date: Thu, 9 Apr 2026 16:33:46 -0600 Subject: [PATCH 5/6] Add event editing functionality to DashboardPage and EventPage Implemented a new editing feature for events, allowing users to update event details with validation for name and description fields. Introduced a message system to provide feedback on the success or failure of updates. Enhanced the event display on the EventPage to show full prompts for bingo cells, improving user interaction and experience. --- shatter-web/src/pages/DashboardPage.tsx | 126 +++++++++++++------ shatter-web/src/pages/EventPage.tsx | 154 ++++++++++++++++++------ 2 files changed, 201 insertions(+), 79 deletions(-) diff --git a/shatter-web/src/pages/DashboardPage.tsx b/shatter-web/src/pages/DashboardPage.tsx index c4604dc..d23f06f 100644 --- a/shatter-web/src/pages/DashboardPage.tsx +++ b/shatter-web/src/pages/DashboardPage.tsx @@ -34,6 +34,18 @@ interface Event { createdBy?: string; } +async function readApiErrorMessage(res: Response): Promise { + const text = await res.text().catch(() => ""); + try { + const j = JSON.parse(text) as Record; + const msg = j.message ?? j.error ?? j.msg; + if (typeof msg === "string" && msg.trim()) return msg; + } catch { + /* ignore */ + } + return text.trim() || `Request failed (${res.status})`; +} + function getUserIdFromToken(token: string): string | null { try { const parts = token.split("."); @@ -63,7 +75,6 @@ function DashboardPage() { startDate: "", endDate: "", maxParticipant: 0, - currentState: "Upcoming", }); const createEmptyGrid = (size: number): BingoCell[][] => @@ -80,6 +91,7 @@ const createEmptyGrid = (size: number): BingoCell[][] => const [isDeletingEvent, setIsDeletingEvent] = useState(false); const [deleteEventMessage, setDeleteEventMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); + const [editFormMessage, setEditFormMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const navigate = useNavigate(); @@ -105,15 +117,18 @@ const createEmptyGrid = (size: number): BingoCell[][] => // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedEvent?._id]); - const fetchUserEvents = async (signal?: AbortSignal) => { + const fetchUserEvents = async ( + signal?: AbortSignal, + opts?: { skipLoading?: boolean } + ): Promise => { try { - setLoading(true); + if (!opts?.skipLoading) setLoading(true); setError(null); const token = localStorage.getItem("token"); if (!token) { navigate("/login"); - return; + return null; } const userId = getUserIdFromToken(token); @@ -121,7 +136,7 @@ const createEmptyGrid = (size: number): BingoCell[][] => localStorage.removeItem("token"); window.dispatchEvent(new Event("authChange")); navigate("/login"); - return; + return null; } const baseUrl = import.meta.env.VITE_API_URL; @@ -145,14 +160,17 @@ const createEmptyGrid = (size: number): BingoCell[][] => throw new Error(data?.message || "Backend did not return success."); } - setEvents(Array.isArray(data.events) ? data.events : []); + const list = Array.isArray(data.events) ? data.events : []; + setEvents(list); + return list; } catch (err: any) { - if (err?.name === "AbortError") return; + if (err?.name === "AbortError") return null; console.error("Error fetching events:", err); setError(err?.message || "Failed to load events"); setEvents([]); + return null; } finally { - setLoading(false); + if (!opts?.skipLoading) setLoading(false); } }; @@ -161,6 +179,7 @@ const createEmptyGrid = (size: number): BingoCell[][] => setIsEditing(false); setSelectedIcebreaker(null); setDeleteEventMessage(null); + setEditFormMessage(null); setIsDeleteConfirmOpen(false); }; @@ -199,17 +218,20 @@ const loadBingoData = async (eventId: string) => { startDate: selectedEvent.startDate?.substring(0, 16) || "", endDate: selectedEvent.endDate?.substring(0, 16) || "", maxParticipant: selectedEvent.maxParticipant || 0, - currentState: selectedEvent.currentState || "Upcoming", }); + setEditFormMessage(null); }; const handleCancelEdit = () => { setIsEditing(false); + setEditFormMessage(null); }; const handleSaveEdit = async () => { if (!selectedEvent) return; + setEditFormMessage(null); + try { const token = localStorage.getItem("token"); if (!token) { @@ -217,38 +239,55 @@ const loadBingoData = async (eventId: string) => { return; } - const response = await fetch(`${import.meta.env.VITE_API_URL}/events/${selectedEvent._id}`, { + const name = editForm.name.trim(); + const description = editForm.description.trim(); + if (!name) { + setEditFormMessage({ type: "error", text: "Name must be a non-empty string." }); + return; + } + if (!description) { + setEditFormMessage({ type: "error", text: "Description must be a non-empty string." }); + return; + } + + const eventId = selectedEvent._id; + const baseUrl = import.meta.env.VITE_API_URL; + + const updateBody: Record = { + name, + description, + startDate: new Date(editForm.startDate).toISOString(), + endDate: new Date(editForm.endDate).toISOString(), + maxParticipant: editForm.maxParticipant, + }; + + const response = await fetch(`${baseUrl}/events/${eventId}`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ - name: editForm.name, - description: editForm.description, - startDate: new Date(editForm.startDate).toISOString(), - endDate: new Date(editForm.endDate).toISOString(), - maxParticipant: editForm.maxParticipant, - currentState: editForm.currentState, - }), + body: JSON.stringify(updateBody), }); if (!response.ok) { - const txt = await response.text().catch(() => ""); - throw new Error(`Failed to update event (status ${response.status}). ${txt ? txt.slice(0, 120) : ""}`.trim()); + throw new Error(await readApiErrorMessage(response)); } - await fetchUserEvents(); + const list = await fetchUserEvents(undefined, { skipLoading: true }); setIsEditing(false); + if (list?.length) { + const updated = list.find((e) => e._id === eventId); + if (updated) setSelectedEvent(updated); + } - // reselect AFTER fetch (using fresh data) - setSelectedEvent(prev => { - if (!prev) return prev; - return events.find(e => e._id === prev._id) || prev; - }); + setEditFormMessage({ type: "success", text: "Event updated successfully." }); } catch (err: any) { console.error("Error updating event:", err); - alert(err?.message || "Failed to update event. The backend may not support updates yet."); + setEditFormMessage({ + type: "error", + text: err?.message || "Failed to update event.", + }); } }; @@ -600,6 +639,26 @@ const loadBingoData = async (eventId: string) => {
)} + {editFormMessage && ( +
+ {editFormMessage.text} + +
+ )} + {/* Edit Form */} {isEditing ? (
@@ -656,19 +715,6 @@ const loadBingoData = async (eventId: string) => { />
-
- - -
-
+ ); + })} +
+ + {selectedBingoCell && ( +
{ + if (e.target === e.currentTarget) setSelectedBingoCell(null); + }} + >
e.stopPropagation()} > - {String(cell ?? "")} + +

+ Full question +

+

+ {(selectedBingoCell.question && selectedBingoCell.question.trim()) || + selectedBingoCell.shortQuestion || + "No details for this square."} +

- ))} -
+
+ )}
)} From cd4ff10d2feaf0b605ceef9b1476de23a006b87f Mon Sep 17 00:00:00 2001 From: jktieh Date: Thu, 9 Apr 2026 19:00:10 -0600 Subject: [PATCH 6/6] Refactor EventSpotlight component to enhance connection handling Updated the EventSpotlight component to introduce a new GraphEdge interface for better graph representation. Enhanced connection fetching logic to handle multiset counts and added utility functions for building graph edges. Removed demo connection props for cleaner usage. Updated EventPage to reflect these changes. --- shatter-web/src/components/EventSpotlight.tsx | 267 +++++++++++------- shatter-web/src/pages/EventPage.tsx | 7 +- 2 files changed, 164 insertions(+), 110 deletions(-) diff --git a/shatter-web/src/components/EventSpotlight.tsx b/shatter-web/src/components/EventSpotlight.tsx index 6fe0be9..7f477b7 100644 --- a/shatter-web/src/components/EventSpotlight.tsx +++ b/shatter-web/src/components/EventSpotlight.tsx @@ -3,7 +3,9 @@ import type { Participant } from "../types/participant"; export interface Connection { from: string; // participantId - to: string; // participantId + to: string; // participantId + /** From GET /participantConnections/connected-users */ + connectionDescription?: string | null; } export interface ActivityItem { @@ -15,21 +17,95 @@ export interface ActivityItem { timestamp: number; } -interface ConnectedUserResponse { +/** Row from GET /api/participantConnections/connected-users */ +interface ConnectedUserRow { + user: { + _id?: string; + name?: string; + email?: string; + linkedinUrl?: string; + bio?: string; + profilePhoto?: string; + socialLinks?: Record; + } | null; participantId: string | { toString(): string }; participantName?: string | null; connectionDescription?: string | null; } +export interface GraphEdge extends Connection { + /** Stable key for React / hover */ + edgeId: string; + bundleIndex: number; + bundleSize: number; +} + +function canonConnectionKey(from: string, to: string, description: string | null): string { + const a = from <= to ? from : to; + const b = from <= to ? to : from; + return JSON.stringify([a, b, description]); +} + +/** Each API row is from perspective of `querier`. Symmetric fetches double-count the same DB connection. */ +function parallelCountFromMultisetCount(count: number): number { + if (count <= 0) return 0; + return count % 2 === 0 ? count / 2 : count; +} + +function buildGraphEdgesFromMultiset(multiset: Map): GraphEdge[] { + const edges: GraphEdge[] = []; + for (const [key, rawCount] of multiset) { + let a: string; + let b: string; + let description: string | null; + try { + const parsed = JSON.parse(key) as [string, string, string | null | undefined]; + a = parsed[0]; + b = parsed[1]; + description = parsed[2] ?? null; + } catch { + continue; + } + const n = parallelCountFromMultisetCount(rawCount); + for (let j = 0; j < n; j++) { + edges.push({ + from: a, + to: b, + connectionDescription: description, + edgeId: `${a}|${b}|${description ?? ""}|${j}`, + bundleIndex: j, + bundleSize: n, + }); + } + } + return edges; +} + +/** Quadratic path with perpendicular offset for bundled edges between the same pair */ +function edgeCurvePath( + x1: number, + y1: number, + x2: number, + y2: number, + bundleIndex: number, + bundleSize: number +): string { + const mx = (x1 + x2) / 2; + const my = (y1 + y2) / 2; + const dx = x2 - x1; + const dy = y2 - y1; + const len = Math.hypot(dx, dy) || 1; + const off = bundleSize > 1 ? (bundleIndex - (bundleSize - 1) / 2) * 14 : 0; + const cx = mx + (-dy / len) * off; + const cy = my + (dx / len) * off; + return `M ${x1} ${y1} Q ${cx} ${cy} ${x2} ${y2}`; +} + interface EventSpotlightProps { participants: Participant[]; eventId: string | null; connections?: Connection[]; activity?: ActivityItem[]; - /** Generate demo connections when API returns no connections */ - useDemoConnections?: boolean; - /** Generate demo activity when none provided */ - useDemoActivity?: boolean; } const BUBBLE_RADIUS = 32; @@ -66,60 +142,63 @@ export default function EventSpotlight({ eventId, connections: connectionsProp = [], activity = [], - useDemoConnections = false, - useDemoActivity = true, }: EventSpotlightProps) { const [hoveredId, setHoveredId] = useState(null); - const [fetchedConnections, setFetchedConnections] = useState([]); + const [hoveredEdgeId, setHoveredEdgeId] = useState(null); + const [fetchedGraphEdges, setFetchedGraphEdges] = useState([]); const [connectionsLoading, setConnectionsLoading] = useState(false); - // Fetch connections from GET /api/participantConnections/connected-users + // GET /api/participantConnections/connected-users — per participant; merge with multiset (handles multiples + symmetric dupes) useEffect(() => { if (!eventId || participants.length === 0) { - setFetchedConnections([]); + setFetchedGraphEdges([]); return; } const token = localStorage.getItem("token"); if (!token) { - setFetchedConnections([]); + setFetchedGraphEdges([]); return; } const apiUrl = import.meta.env.VITE_API_URL; - const connectionSet = new Set(); - const conns: Connection[] = []; const fetchAll = async () => { setConnectionsLoading(true); try { + const multiset = new Map(); + const results = await Promise.all( participants.map(async (p) => { const url = `${apiUrl}/participantConnections/connected-users?eventId=${encodeURIComponent(eventId)}&participantId=${encodeURIComponent(p.participantId)}`; const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, }); - if (!res.ok) return []; - const data: ConnectedUserResponse[] = await res.json(); - return data.map((item) => ({ - from: p.participantId, - to: normalizeId(item.participantId), - })); + if (!res.ok) return [] as ConnectedUserRow[]; + const data: unknown = await res.json(); + if (!Array.isArray(data)) return []; + return data as ConnectedUserRow[]; }) ); - for (const list of results) { - for (const c of list) { - const key = [c.from, c.to].sort().join("|"); - if (!connectionSet.has(key)) { - connectionSet.add(key); - conns.push(c); - } + for (let pi = 0; pi < results.length; pi++) { + const list = results[pi]; + const querier = participants[pi].participantId; + for (const item of list) { + const other = normalizeId(item.participantId); + if (!other || other === querier) continue; + const desc = + item.connectionDescription != null && item.connectionDescription !== "" + ? item.connectionDescription + : null; + const key = canonConnectionKey(querier, other, desc); + multiset.set(key, (multiset.get(key) ?? 0) + 1); } } - setFetchedConnections(conns); + + setFetchedGraphEdges(buildGraphEdgesFromMultiset(multiset)); } catch { - setFetchedConnections([]); + setFetchedGraphEdges([]); } finally { setConnectionsLoading(false); } @@ -128,8 +207,19 @@ export default function EventSpotlight({ fetchAll(); }, [eventId, participants]); - // Use prop connections if provided, else fetched connections - const connections = connectionsProp.length > 0 ? connectionsProp : fetchedConnections; + const propToGraphEdges = (conns: Connection[]): GraphEdge[] => + conns.map((c, i) => ({ + from: c.from, + to: c.to, + connectionDescription: c.connectionDescription ?? null, + edgeId: `prop-${c.from}-${c.to}-${i}`, + bundleIndex: 0, + bundleSize: 1, + })); + + /** Real edges only: optional props override, else GET /participantConnections/connected-users */ + const effectiveGraphEdges = + connectionsProp.length > 0 ? propToGraphEdges(connectionsProp) : fetchedGraphEdges; // Container dimensions const size = 400; @@ -150,65 +240,12 @@ export default function EventSpotlight({ return map; }, [participants, centerX, centerY, graphRadius]); - // Demo connections: connect participants in a fun pattern (every other, creating a star) - const effectiveConnections = useMemo(() => { - if (connections.length > 0) return connections; - if (!useDemoConnections || participants.length < 2) return []; - - const demo: Connection[] = []; - const n = participants.length; - for (let i = 0; i < n; i++) { - const j = (i + 2) % n; // Skip one for star pattern - if (i < j) { - demo.push({ from: participants[i].participantId, to: participants[j].participantId }); - } - } - return demo.slice(0, Math.min(demo.length, 8)); // Cap demo lines - }, [connections, participants, useDemoConnections]); - - // Demo activity: join events + fake connection events - const effectiveActivity = useMemo(() => { - if (activity.length > 0) return activity; - if (!useDemoActivity) return []; - - const items: ActivityItem[] = participants - .slice(0, 5) - .map((p, i) => ({ - id: `join-${p.participantId}`, - type: "joined" as const, - participantName: p.name, - timestamp: Date.now() - (participants.length - i) * 60000, - })); - - // Add some fake connection events - if (participants.length >= 2) { - items.push({ - id: "conn-1", - type: "connection", - fromName: participants[0].name, - toName: participants[1].name, - timestamp: Date.now() - 120000, - }); - if (participants.length >= 4) { - items.push({ - id: "conn-2", - type: "connection", - fromName: participants[2].name, - toName: participants[3].name, - timestamp: Date.now() - 90000, - }); - } - } - - return items.sort((a, b) => b.timestamp - a.timestamp).slice(0, 8); - }, [activity, participants, useDemoActivity]); - // Leaderboard: count connections per participant — show ALL participants const leaderboard = useMemo(() => { const scores = new Map(); participants.forEach((p) => scores.set(p.participantId, { name: p.name, count: 0 })); - effectiveConnections.forEach((c) => { + effectiveGraphEdges.forEach((c) => { const from = scores.get(c.from); const to = scores.get(c.to); if (from) from.count++; @@ -218,7 +255,7 @@ export default function EventSpotlight({ return Array.from(scores.entries()) .map(([id, { name, count }]) => ({ participantId: id, name, connections: count })) .sort((a, b) => b.connections - a.connections); - }, [participants, effectiveConnections]); + }, [participants, effectiveGraphEdges]); const formatTimeAgo = (ts: number) => { const sec = Math.floor((Date.now() - ts) / 1000); @@ -256,6 +293,9 @@ export default function EventSpotlight({

See who's here and who's connecting + {connectionsLoading && ( + Loading connections… + )}

@@ -266,27 +306,46 @@ export default function EventSpotlight({ viewBox={`0 0 ${size} ${size}`} className="overflow-visible w-full max-w-[400px] h-auto aspect-square" > - {/* Connection lines */} + {/* Connection lines (GET /participantConnections/connected-users) */} - {effectiveConnections.map((conn, i) => { + {effectiveGraphEdges.map((conn) => { const fromPos = positions.get(conn.from); const toPos = positions.get(conn.to); if (!fromPos || !toPos) return null; + const isHover = hoveredEdgeId === conn.edgeId; + const label = + conn.connectionDescription?.trim() || + "Connected (no description)"; + const d = edgeCurvePath( + fromPos.x, + fromPos.y, + toPos.x, + toPos.y, + conn.bundleIndex, + conn.bundleSize + ); return ( - + setHoveredEdgeId(conn.edgeId)} + onMouseLeave={() => setHoveredEdgeId(null)} + style={{ cursor: "pointer" }} + > + + {label} + ); })} @@ -376,10 +435,10 @@ export default function EventSpotlight({ Recent Activity
- {effectiveActivity.length === 0 ? ( + {activity.length === 0 ? (

No activity yet

) : ( - effectiveActivity.map((item) => ( + activity.map((item) => (
- +
{/* Main Content Grid */}