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/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) => (
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..d23f06f 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, @@ -28,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("."); @@ -57,15 +75,23 @@ function DashboardPage() { startDate: "", endDate: "", maxParticipant: 0, - 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); + 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(); @@ -91,15 +117,18 @@ function DashboardPage() { // 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); @@ -107,7 +136,7 @@ function DashboardPage() { localStorage.removeItem("token"); window.dispatchEvent(new Event("authChange")); navigate("/login"); - return; + return null; } const baseUrl = import.meta.env.VITE_API_URL; @@ -131,14 +160,17 @@ function DashboardPage() { 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); } }; @@ -146,27 +178,35 @@ function DashboardPage() { setSelectedEvent(event); setIsEditing(false); setSelectedIcebreaker(null); - - // Load bingo data if it exists - loadBingoData(event._id); + setDeleteEventMessage(null); + setEditFormMessage(null); + setIsDeleteConfirmOpen(false); }; - 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; @@ -178,17 +218,20 @@ function DashboardPage() { 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) { @@ -196,47 +239,96 @@ function DashboardPage() { 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.", + }); } }; - const handleBingoGridChange = (row: number, col: number, value: string) => { - const newGrid = bingoGrid.map(r => [...r]); - newGrid[row][col] = value; - setBingoGrid(newGrid); + const handleDeleteEvent = async () => { + if (!selectedEvent || isDeletingEvent) return; + setDeleteEventMessage(null); + + 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); + setIsDeleteConfirmOpen(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; @@ -247,7 +339,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); @@ -490,10 +582,83 @@ function DashboardPage() { > Edit +
)}
+ {isDeleteConfirmOpen && selectedEvent && ( +
+

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

+
+ + +
+
+ )} + + {deleteEventMessage && ( +
+ {deleteEventMessage.text} + +
+ )} + + {editFormMessage && ( +
+ {editFormMessage.text} + +
+ )} + {/* Edit Form */} {isEditing ? (
@@ -550,19 +715,6 @@ function DashboardPage() { />
-
- - -
-
diff --git a/shatter-web/src/pages/EventPage.tsx b/shatter-web/src/pages/EventPage.tsx index eae2499..c8202c2 100644 --- a/shatter-web/src/pages/EventPage.tsx +++ b/shatter-web/src/pages/EventPage.tsx @@ -6,7 +6,45 @@ import QRCard from "../components/QRCard"; import Navbar from "../components/Navbar"; import Footer from "../components/Footer"; import EventSpotlight from "../components/EventSpotlight"; -import { CalendarIcon, ClipboardCopyIcon } from "../components/icons"; +import { CalendarIcon, ClipboardCopyIcon, XIcon } from "../components/icons"; +import type { BingoCell } from "../service/BingoGame"; + +function normalizeBingoCell(cell: unknown): BingoCell { + if (cell == null) return { question: "", shortQuestion: "" }; + if (typeof cell === "string") { + const s = cell.trim(); + return { question: s, shortQuestion: s }; + } + if (typeof cell === "object") { + const o = cell as Record; + const q = typeof o.question === "string" ? o.question.trim() : ""; + const sq = typeof o.shortQuestion === "string" ? o.shortQuestion.trim() : ""; + if (q && sq) return { question: q, shortQuestion: sq }; + if (q) return { question: q, shortQuestion: q }; + if (sq) return { question: sq, shortQuestion: sq }; + } + const s = String(cell).trim(); + return { question: s, shortQuestion: s }; +} + +function normalizeBingoGrid(grid: unknown): BingoCell[][] { + if (!Array.isArray(grid)) return []; + + return grid.map((row) => { + if (Array.isArray(row)) { + return row.map((c) => normalizeBingoCell(c)); + } + + 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) => normalizeBingoCell((row as Record)[key])); + } + + return []; + }); +} export default function EventPage() { const { joinCode } = useParams<{ joinCode: string }>(); @@ -27,13 +65,22 @@ export default function EventPage() { initialParticipants ); - // Bingo state + // Bingo state const [bingoGame, setBingoGame] = useState<{ _id: string; _eventId: string; description: string; - grid: string[][]; + grid: BingoCell[][]; } | null>(null); + const [selectedBingoCell, setSelectedBingoCell] = useState(null); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setSelectedBingoCell(null); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); // Fetch bingo game for this event useEffect(() => { @@ -42,18 +89,20 @@ export default function EventPage() { const token = localStorage.getItem("token"); if (!token) return; - fetch( - `https://techstart-shatter-backend.vercel.app/api/bingo/getBingo/${eventId}`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - } - ) + const base = import.meta.env.VITE_API_URL ?? "https://techstart-shatter-backend.vercel.app/api"; + + fetch(`${base}/bingo/getBingo/${eventId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) .then((res) => (res.ok ? res.json() : null)) .then((data) => { if (data?.bingo) { - setBingoGame(data.bingo); + setBingoGame({ + ...data.bingo, + grid: normalizeBingoGrid(data.bingo.grid), + }); } }) .catch(() => { @@ -278,12 +327,7 @@ export default function EventPage() { {/* Live Activity Spotlight */}
- +
{/* Main Content Grid */} @@ -451,7 +495,7 @@ export default function EventPage() {

- Bingo game associated with this event. + Tap a square to see the full prompt. Squares show a short label.

- {bingoGame.grid.flat().map((cell, index) => ( + {bingoGame.grid.flat().map((cell, index) => { + const preview = + (cell.shortQuestion && cell.shortQuestion.trim()) || + (cell.question && cell.question.trim()) || + "—"; + const hasFull = + !!(cell.question && cell.question.trim()) && + cell.question.trim() !== preview.trim(); + return ( + + ); + })} +
+ + {selectedBingoCell && ( +
{ + if (e.target === e.currentTarget) setSelectedBingoCell(null); + }} + >
e.stopPropagation()} > - {cell} + +

+ Full question +

+

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

- ))} -
+
+ )}
)} 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