From 10672a06ce612bf82af56b0be7b394fdf214a485 Mon Sep 17 00:00:00 2001 From: jktieh Date: Thu, 12 Mar 2026 15:11:58 -0600 Subject: [PATCH 1/9] Add icon components and update DashboardPage with new icons --- shatter-web/src/components/Hero.tsx | 15 +---- shatter-web/src/components/Navbar.tsx | 31 +-------- shatter-web/src/components/icons/BarsIcon.tsx | 20 ++++++ .../src/components/icons/CalendarIcon.tsx | 20 ++++++ .../src/components/icons/ChevronDownIcon.tsx | 20 ++++++ .../components/icons/ClipboardCopyIcon.tsx | 20 ++++++ .../src/components/icons/ClipboardIcon.tsx | 20 ++++++ .../src/components/icons/ClockIcon.tsx | 20 ++++++ .../src/components/icons/GoogleIcon.tsx | 24 +++++++ .../icons/InformationCircleIcon.tsx | 20 ++++++ shatter-web/src/components/icons/KeyIcon.tsx | 20 ++++++ shatter-web/src/components/icons/PlusIcon.tsx | 20 ++++++ .../src/components/icons/SearchIcon.tsx | 20 ++++++ .../src/components/icons/UsersIcon.tsx | 20 ++++++ shatter-web/src/components/icons/XIcon.tsx | 20 ++++++ shatter-web/src/components/icons/index.ts | 13 ++++ shatter-web/src/components/icons/types.ts | 6 ++ shatter-web/src/pages/DashboardPage.tsx | 63 +++++++------------ shatter-web/src/pages/EventPage.tsx | 45 ++----------- shatter-web/src/pages/LoginPage.tsx | 8 +-- 20 files changed, 318 insertions(+), 127 deletions(-) create mode 100644 shatter-web/src/components/icons/BarsIcon.tsx create mode 100644 shatter-web/src/components/icons/CalendarIcon.tsx create mode 100644 shatter-web/src/components/icons/ChevronDownIcon.tsx create mode 100644 shatter-web/src/components/icons/ClipboardCopyIcon.tsx create mode 100644 shatter-web/src/components/icons/ClipboardIcon.tsx create mode 100644 shatter-web/src/components/icons/ClockIcon.tsx create mode 100644 shatter-web/src/components/icons/GoogleIcon.tsx create mode 100644 shatter-web/src/components/icons/InformationCircleIcon.tsx create mode 100644 shatter-web/src/components/icons/KeyIcon.tsx create mode 100644 shatter-web/src/components/icons/PlusIcon.tsx create mode 100644 shatter-web/src/components/icons/SearchIcon.tsx create mode 100644 shatter-web/src/components/icons/UsersIcon.tsx create mode 100644 shatter-web/src/components/icons/XIcon.tsx create mode 100644 shatter-web/src/components/icons/index.ts create mode 100644 shatter-web/src/components/icons/types.ts diff --git a/shatter-web/src/components/Hero.tsx b/shatter-web/src/components/Hero.tsx index e581684..cbb921c 100644 --- a/shatter-web/src/components/Hero.tsx +++ b/shatter-web/src/components/Hero.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import QRCard from './QRCard'; +import { ChevronDownIcon } from './icons'; interface HeroProps { qrPayload?: string; @@ -229,19 +230,7 @@ const Hero: React.FC = ({ qrPayload = "hello" }) => { {/* Scroll Indicator */}
- - - +
{/* Custom Animations */} diff --git a/shatter-web/src/components/Navbar.tsx b/shatter-web/src/components/Navbar.tsx index 5d6e5b3..38e634c 100644 --- a/shatter-web/src/components/Navbar.tsx +++ b/shatter-web/src/components/Navbar.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import logo from "../assets/ShatterLogo_White.png"; +import { BarsIcon, XIcon } from "./icons"; export default function Navbar() { const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -194,35 +195,9 @@ export default function Navbar() { aria-label="Toggle menu" > {isMenuOpen ? ( - /* Close Icon */ - - - + ) : ( - /* Hamburger Icon */ - - - + )} diff --git a/shatter-web/src/components/icons/BarsIcon.tsx b/shatter-web/src/components/icons/BarsIcon.tsx new file mode 100644 index 0000000..12787cc --- /dev/null +++ b/shatter-web/src/components/icons/BarsIcon.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./types"; + +export function BarsIcon({ className = "w-4 h-4", ...props }: IconProps) { + return ( + + + + ); +} diff --git a/shatter-web/src/components/icons/CalendarIcon.tsx b/shatter-web/src/components/icons/CalendarIcon.tsx new file mode 100644 index 0000000..ed1b0f9 --- /dev/null +++ b/shatter-web/src/components/icons/CalendarIcon.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./types"; + +export function CalendarIcon({ className = "w-4 h-4", ...props }: IconProps) { + return ( + + + + ); +} diff --git a/shatter-web/src/components/icons/ChevronDownIcon.tsx b/shatter-web/src/components/icons/ChevronDownIcon.tsx new file mode 100644 index 0000000..53ef640 --- /dev/null +++ b/shatter-web/src/components/icons/ChevronDownIcon.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./types"; + +export function ChevronDownIcon({ className = "w-4 h-4", ...props }: IconProps) { + return ( + + + + ); +} diff --git a/shatter-web/src/components/icons/ClipboardCopyIcon.tsx b/shatter-web/src/components/icons/ClipboardCopyIcon.tsx new file mode 100644 index 0000000..9f625c0 --- /dev/null +++ b/shatter-web/src/components/icons/ClipboardCopyIcon.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./types"; + +export function ClipboardCopyIcon({ className = "w-4 h-4", ...props }: IconProps) { + return ( + + + + ); +} diff --git a/shatter-web/src/components/icons/ClipboardIcon.tsx b/shatter-web/src/components/icons/ClipboardIcon.tsx new file mode 100644 index 0000000..cc94921 --- /dev/null +++ b/shatter-web/src/components/icons/ClipboardIcon.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./types"; + +export function ClipboardIcon({ className = "w-4 h-4", ...props }: IconProps) { + return ( + + + + ); +} diff --git a/shatter-web/src/components/icons/ClockIcon.tsx b/shatter-web/src/components/icons/ClockIcon.tsx new file mode 100644 index 0000000..da14c3b --- /dev/null +++ b/shatter-web/src/components/icons/ClockIcon.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./types"; + +export function ClockIcon({ className = "w-4 h-4", ...props }: IconProps) { + return ( + + + + ); +} diff --git a/shatter-web/src/components/icons/GoogleIcon.tsx b/shatter-web/src/components/icons/GoogleIcon.tsx new file mode 100644 index 0000000..1927800 --- /dev/null +++ b/shatter-web/src/components/icons/GoogleIcon.tsx @@ -0,0 +1,24 @@ +import type { IconProps } from "./types"; + +export function GoogleIcon({ className = "w-5 h-5", ...props }: IconProps) { + return ( + + + + + + + ); +} diff --git a/shatter-web/src/components/icons/InformationCircleIcon.tsx b/shatter-web/src/components/icons/InformationCircleIcon.tsx new file mode 100644 index 0000000..4d17882 --- /dev/null +++ b/shatter-web/src/components/icons/InformationCircleIcon.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./types"; + +export function InformationCircleIcon({ className = "w-4 h-4", ...props }: IconProps) { + return ( + + + + ); +} diff --git a/shatter-web/src/components/icons/KeyIcon.tsx b/shatter-web/src/components/icons/KeyIcon.tsx new file mode 100644 index 0000000..da05a75 --- /dev/null +++ b/shatter-web/src/components/icons/KeyIcon.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./types"; + +export function KeyIcon({ className = "w-4 h-4", ...props }: IconProps) { + return ( + + + + ); +} diff --git a/shatter-web/src/components/icons/PlusIcon.tsx b/shatter-web/src/components/icons/PlusIcon.tsx new file mode 100644 index 0000000..0ebfdeb --- /dev/null +++ b/shatter-web/src/components/icons/PlusIcon.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./types"; + +export function PlusIcon({ className = "w-4 h-4", ...props }: IconProps) { + return ( + + + + ); +} diff --git a/shatter-web/src/components/icons/SearchIcon.tsx b/shatter-web/src/components/icons/SearchIcon.tsx new file mode 100644 index 0000000..c1a2cd6 --- /dev/null +++ b/shatter-web/src/components/icons/SearchIcon.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./types"; + +export function SearchIcon({ className = "w-4 h-4", ...props }: IconProps) { + return ( + + + + ); +} diff --git a/shatter-web/src/components/icons/UsersIcon.tsx b/shatter-web/src/components/icons/UsersIcon.tsx new file mode 100644 index 0000000..f506fa5 --- /dev/null +++ b/shatter-web/src/components/icons/UsersIcon.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./types"; + +export function UsersIcon({ className = "w-4 h-4", ...props }: IconProps) { + return ( + + + + ); +} diff --git a/shatter-web/src/components/icons/XIcon.tsx b/shatter-web/src/components/icons/XIcon.tsx new file mode 100644 index 0000000..05fe53b --- /dev/null +++ b/shatter-web/src/components/icons/XIcon.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./types"; + +export function XIcon({ className = "w-4 h-4", ...props }: IconProps) { + return ( + + + + ); +} diff --git a/shatter-web/src/components/icons/index.ts b/shatter-web/src/components/icons/index.ts new file mode 100644 index 0000000..ffc696b --- /dev/null +++ b/shatter-web/src/components/icons/index.ts @@ -0,0 +1,13 @@ +export { BarsIcon } from "./BarsIcon"; +export { CalendarIcon } from "./CalendarIcon"; +export { ChevronDownIcon } from "./ChevronDownIcon"; +export { ClipboardCopyIcon } from "./ClipboardCopyIcon"; +export { ClipboardIcon } from "./ClipboardIcon"; +export { ClockIcon } from "./ClockIcon"; +export { GoogleIcon } from "./GoogleIcon"; +export { InformationCircleIcon } from "./InformationCircleIcon"; +export { KeyIcon } from "./KeyIcon"; +export { PlusIcon } from "./PlusIcon"; +export { SearchIcon } from "./SearchIcon"; +export { UsersIcon } from "./UsersIcon"; +export { XIcon } from "./XIcon"; diff --git a/shatter-web/src/components/icons/types.ts b/shatter-web/src/components/icons/types.ts new file mode 100644 index 0000000..a580585 --- /dev/null +++ b/shatter-web/src/components/icons/types.ts @@ -0,0 +1,6 @@ +import type { SVGProps } from "react"; + +export interface IconProps extends SVGProps { + className?: string; + size?: number; +} diff --git a/shatter-web/src/pages/DashboardPage.tsx b/shatter-web/src/pages/DashboardPage.tsx index 2aa0be0..71b99ae 100644 --- a/shatter-web/src/pages/DashboardPage.tsx +++ b/shatter-web/src/pages/DashboardPage.tsx @@ -2,6 +2,17 @@ import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import Navbar from "../components/Navbar"; import Footer from "../components/Footer"; +import { + CalendarIcon, + ClipboardIcon, + ClockIcon, + InformationCircleIcon, + KeyIcon, + PlusIcon, + SearchIcon, + UsersIcon, + XIcon, +} from "../components/icons"; interface Event { _id: string; @@ -383,9 +394,7 @@ function DashboardPage() { {!loading && events.length === 0 && (
- - - +

No Events Yet

You haven't created any events. Start by creating your first event!

@@ -415,9 +424,7 @@ function DashboardPage() { className="p-2 rounded-lg hover:bg-white/10 transition-colors" title="Create new event" > - - - +
@@ -447,9 +454,7 @@ function DashboardPage() {
- - - + {formatDate(event.startDate)}
@@ -606,9 +611,7 @@ function DashboardPage() {
- - - + Start Date

{formatDate(selectedEvent.startDate)}

@@ -617,9 +620,7 @@ function DashboardPage() {
- - - + End Date

{formatDate(selectedEvent.endDate)}

@@ -628,9 +629,7 @@ function DashboardPage() {
- - - + Participants

@@ -640,9 +639,7 @@ function DashboardPage() {

- - - + Join Code

{selectedEvent.joinCode}

@@ -662,9 +659,7 @@ function DashboardPage() { >
- - - +

Name Bingo

@@ -674,9 +669,7 @@ function DashboardPage() {
@@ -762,9 +749,7 @@ function DashboardPage() {
- - - +

Tips for creating good bingo questions:

    diff --git a/shatter-web/src/pages/EventPage.tsx b/shatter-web/src/pages/EventPage.tsx index 93c350f..d2486f4 100644 --- a/shatter-web/src/pages/EventPage.tsx +++ b/shatter-web/src/pages/EventPage.tsx @@ -5,6 +5,7 @@ import { useState, useEffect } from "react"; import QRCard from "../components/QRCard"; import Navbar from "../components/Navbar"; import Footer from "../components/Footer"; +import { CalendarIcon, ClipboardCopyIcon } from "../components/icons"; export default function EventPage() { const { joinCode } = useParams<{ joinCode: string }>(); @@ -188,20 +189,7 @@ export default function EventPage() { {/* Start Date */}
    - {/* Calendar Icon */} - - - +

    Start

    @@ -217,20 +205,7 @@ export default function EventPage() { {/* End Date */}
    - {/* Calendar Icon */} - - - +

    End

    @@ -328,19 +303,7 @@ export default function EventPage() { className="w-full px-4 py-2.5 rounded-full font-semibold text-white hover:opacity-90 transition-opacity shadow-lg font-body flex items-center justify-center gap-2" style={{ backgroundColor: "#4DC4FF" }} > - - - + Copy Join Code
    diff --git a/shatter-web/src/pages/LoginPage.tsx b/shatter-web/src/pages/LoginPage.tsx index fefc6ec..7caf7ef 100644 --- a/shatter-web/src/pages/LoginPage.tsx +++ b/shatter-web/src/pages/LoginPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { GoogleIcon } from '../components/icons'; export default function LoginPage() { const navigate = useNavigate(); @@ -280,12 +281,7 @@ export default function LoginPage() { type="button" className="w-full py-3 rounded-lg border border-white/20 bg-white/5 text-white hover:bg-white/10 transition-colors font-body flex items-center justify-center gap-3" > - - - - - - + Continue with Google
    From a754b4731a09a3dff5f3c73c5207ac710434e53d Mon Sep 17 00:00:00 2001 From: jktieh Date: Thu, 12 Mar 2026 16:13:21 -0600 Subject: [PATCH 2/9] create EventSpotlight --- shatter-web/src/components/EventSpotlight.tsx | 461 ++++++++++++++++++ shatter-web/src/pages/EventPage.tsx | 11 + 2 files changed, 472 insertions(+) create mode 100644 shatter-web/src/components/EventSpotlight.tsx diff --git a/shatter-web/src/components/EventSpotlight.tsx b/shatter-web/src/components/EventSpotlight.tsx new file mode 100644 index 0000000..6fe0be9 --- /dev/null +++ b/shatter-web/src/components/EventSpotlight.tsx @@ -0,0 +1,461 @@ +import { useEffect, useMemo, useState } from "react"; +import type { Participant } from "../types/participant"; + +export interface Connection { + from: string; // participantId + to: string; // participantId +} + +export interface ActivityItem { + id: string; + type: "joined" | "connection"; + participantName?: string; + fromName?: string; + toName?: string; + timestamp: number; +} + +interface ConnectedUserResponse { + participantId: string | { toString(): string }; + participantName?: string | null; + connectionDescription?: string | null; +} + +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; +const CONTAINER_PADDING = 60; + +function getBubblePosition( + index: number, + total: number, + centerX: number, + centerY: number, + radius: number +): { x: number; y: number } { + if (total <= 0) return { x: centerX, y: centerY }; + if (total === 1) return { x: centerX, y: centerY }; + const angle = (index / total) * 2 * Math.PI - Math.PI / 2; // Start from top + return { + x: centerX + radius * Math.cos(angle), + y: centerY + radius * Math.sin(angle), + }; +} + +function getParticipantColor(index: number): string { + const hues = [200, 160, 280, 40, 320, 180, 260, 60]; // Cyan, teal, purple, orange, pink, etc. + const hue = hues[index % hues.length]; + return `hsl(${hue}, 70%, 55%)`; +} + +function normalizeId(id: string | { toString(): string }): string { + return typeof id === "string" ? id : id.toString(); +} + +export default function EventSpotlight({ + participants, + eventId, + connections: connectionsProp = [], + activity = [], + useDemoConnections = false, + useDemoActivity = true, +}: EventSpotlightProps) { + const [hoveredId, setHoveredId] = useState(null); + const [fetchedConnections, setFetchedConnections] = useState([]); + const [connectionsLoading, setConnectionsLoading] = useState(false); + + // Fetch connections from GET /api/participantConnections/connected-users + useEffect(() => { + if (!eventId || participants.length === 0) { + setFetchedConnections([]); + return; + } + + const token = localStorage.getItem("token"); + if (!token) { + setFetchedConnections([]); + return; + } + + const apiUrl = import.meta.env.VITE_API_URL; + const connectionSet = new Set(); + const conns: Connection[] = []; + + const fetchAll = async () => { + setConnectionsLoading(true); + try { + 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), + })); + }) + ); + + 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); + } + } + } + setFetchedConnections(conns); + } catch { + setFetchedConnections([]); + } finally { + setConnectionsLoading(false); + } + }; + + fetchAll(); + }, [eventId, participants]); + + // Use prop connections if provided, else fetched connections + const connections = connectionsProp.length > 0 ? connectionsProp : fetchedConnections; + + // Container dimensions + const size = 400; + const centerX = size / 2; + const centerY = size / 2; + const graphRadius = Math.min(size - CONTAINER_PADDING * 2, 280) / 2; + + // Participant positions + const positions = useMemo(() => { + const map = new Map(); + participants.forEach((p, i) => { + const pos = getBubblePosition(i, participants.length, centerX, centerY, graphRadius); + map.set(p.participantId, { + ...pos, + color: getParticipantColor(i), + }); + }); + 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) => { + const from = scores.get(c.from); + const to = scores.get(c.to); + if (from) from.count++; + if (to) to.count++; + }); + + return Array.from(scores.entries()) + .map(([id, { name, count }]) => ({ participantId: id, name, connections: count })) + .sort((a, b) => b.connections - a.connections); + }, [participants, effectiveConnections]); + + const formatTimeAgo = (ts: number) => { + const sec = Math.floor((Date.now() - ts) / 1000); + if (sec < 60) return "just now"; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.floor(min / 60); + return `${hr}h ago`; + }; + + if (participants.length === 0) { + return ( +
    +

    + Live Activity Spotlight +

    +

    + When participants join and make connections, you'll see them here as bubbles with lines between connected people. +

    +
    + ); + } + + return ( +
    +
    +

    + Live Activity Spotlight +

    +

    + See who's here and who's connecting +

    +
    + +
    + {/* Network Graph */} +
    + + {/* Connection lines */} + + {effectiveConnections.map((conn, i) => { + const fromPos = positions.get(conn.from); + const toPos = positions.get(conn.to); + if (!fromPos || !toPos) return null; + return ( + + ); + })} + + {/* Participant bubbles */} + {participants.map((p, i) => { + const pos = positions.get(p.participantId); + if (!pos) return null; + const isHovered = hoveredId === p.participantId; + return ( + setHoveredId(p.participantId)} + onMouseLeave={() => setHoveredId(null)} + style={{ cursor: "pointer" }} + > + {/* Glow on hover */} + {isHovered && ( + + )} + + + {p.name.charAt(0).toUpperCase()} + + {/* Tooltip */} + {isHovered && ( + + + + {p.name} + + + )} + + ); + })} + + +
    + + {/* Right: Activity + Leaderboard */} +
    + {/* Activity Feed */} +
    +

    + Recent Activity +

    +
    + {effectiveActivity.length === 0 ? ( +

    No activity yet

    + ) : ( + effectiveActivity.map((item) => ( +
    + +
    + {item.type === "joined" ? ( + + {item.participantName} joined + + ) : ( + + {item.fromName} connected with{" "} + {item.toName} + + )} +

    + {formatTimeAgo(item.timestamp)} +

    +
    +
    + )) + )} +
    +
    + + {/* Leaderboard */} +
    +

    + Connection Leaderboard +

    +
    + {leaderboard.map((entry, i) => ( +
    + + {i + 1} + + + {entry.name} + + + {entry.connections} {entry.connections === 1 ? "link" : "links"} + +
    + ))} +
    +
    +
    +
    +
    + ); +} diff --git a/shatter-web/src/pages/EventPage.tsx b/shatter-web/src/pages/EventPage.tsx index 6f28287..0e244f6 100644 --- a/shatter-web/src/pages/EventPage.tsx +++ b/shatter-web/src/pages/EventPage.tsx @@ -5,6 +5,7 @@ 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 { CalendarIcon, ClipboardCopyIcon } from "../components/icons"; export default function EventPage() { @@ -159,6 +160,16 @@ export default function EventPage() {
    + {/* Live Activity Spotlight */} +
    + +
    + {/* Main Content Grid */}
    {/* Left Column - Event Details */} From 577f045c41fa29b4917f147d6dd4fe3085a5f917 Mon Sep 17 00:00:00 2001 From: jktieh Date: Thu, 19 Mar 2026 15:02:26 -0600 Subject: [PATCH 3/9] update eventPage.tsx to include start and end button --- shatter-web/src/hooks/useEventData.ts | 27 ++++-- shatter-web/src/pages/EventPage.tsx | 134 ++++++++++++++++++++++++-- 2 files changed, 144 insertions(+), 17 deletions(-) diff --git a/shatter-web/src/hooks/useEventData.ts b/shatter-web/src/hooks/useEventData.ts index 79d696e..c583998 100644 --- a/shatter-web/src/hooks/useEventData.ts +++ b/shatter-web/src/hooks/useEventData.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { Participant } from "../types/participant"; interface EventDetails { @@ -11,6 +11,7 @@ interface EventDetails { maxParticipant: number; currentState: string; participantIds: Participant[]; + createdBy?: string; } interface EventResponse { @@ -25,17 +26,16 @@ export function useEventData(joinCode: string | undefined) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - useEffect(() => { + const fetchEvent = useCallback((signal?: AbortSignal) => { if (!joinCode) { setLoading(false); return; } - setLoading(true); setError(""); - fetch(`${import.meta.env.VITE_API_URL}/events/event/${joinCode}`, { cache: "no-store", + signal, }) .then((res) => { if (!res.ok) { @@ -45,9 +45,10 @@ export function useEventData(joinCode: string | undefined) { }) .then((data: EventResponse) => { if (data.success && data.event) { - setEventId(data.event._id); - setEventDetails(data.event); - setParticipants(data.event.participantIds); + const ev = data.event; + setEventId(ev._id); + setEventDetails(ev); + setParticipants(ev.participantIds || []); } else { setEventId(null); setEventDetails(null); @@ -55,6 +56,7 @@ export function useEventData(joinCode: string | undefined) { } }) .catch((err) => { + if (err?.name === "AbortError") return; console.error("Error fetching event:", err); setEventId(null); setEventDetails(null); @@ -63,5 +65,14 @@ export function useEventData(joinCode: string | undefined) { .finally(() => setLoading(false)); }, [joinCode]); - return { eventId, eventDetails, participants, loading, error }; + useEffect(() => { + if (!joinCode) return; + const controller = new AbortController(); + fetchEvent(controller.signal); + return () => controller.abort(); + }, [joinCode, fetchEvent]); + + const refetch = () => fetchEvent(); + + return { eventId, eventDetails, participants, loading, error, refetch }; } diff --git a/shatter-web/src/pages/EventPage.tsx b/shatter-web/src/pages/EventPage.tsx index 0e244f6..6332f75 100644 --- a/shatter-web/src/pages/EventPage.tsx +++ b/shatter-web/src/pages/EventPage.tsx @@ -17,8 +17,11 @@ export default function EventPage() { participants: initialParticipants, loading, error, + refetch, } = useEventData(joinCode); + const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); + const { participants } = useEventParticipants( eventId, initialParticipants @@ -113,6 +116,83 @@ export default function EventPage() { // TODO: In the future, we could encode a URL or more complex data here const qrPayload = joinCode || ""; + function getUserIdFromToken(token: string): string | null { + try { + const parts = token.split("."); + if (parts.length < 2) return null; + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); + const payload = JSON.parse(atob(padded)); + return payload.userId || payload.id || payload.sub || null; + } catch { + return null; + } + } + + const token = typeof window !== "undefined" ? localStorage.getItem("token") : null; + const userId = token ? getUserIdFromToken(token) : null; + const createdBy = eventDetails?.createdBy; + const createdByStr = + typeof createdBy === "string" + ? createdBy + : createdBy != null + ? String(createdBy) + : ""; + const isHost = !!userId && !!createdByStr && userId === createdByStr; + + const canStart = eventDetails?.currentState === "Upcoming" || eventDetails?.currentState === "upcoming"; + const canEnd = + eventDetails?.currentState === "In Progress" || + eventDetails?.currentState === "ongoing"; + + const handleStartEvent = async () => { + if (!eventId || !token) return; + setIsUpdatingStatus(true); + try { + const res = await fetch(`${import.meta.env.VITE_API_URL}/events/${eventId}/status`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ status: "In Progress" }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `Failed to start event (${res.status})`); + } + await refetch(); + } catch (err: unknown) { + alert(err instanceof Error ? err.message : "Failed to start event"); + } finally { + setIsUpdatingStatus(false); + } + }; + + const handleEndEvent = async () => { + if (!eventId || !token) return; + setIsUpdatingStatus(true); + try { + const res = await fetch(`${import.meta.env.VITE_API_URL}/events/${eventId}/status`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ status: "Completed" }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `Failed to end event (${res.status})`); + } + await refetch(); + } catch (err: unknown) { + alert(err instanceof Error ? err.message : "Failed to end event"); + } finally { + setIsUpdatingStatus(false); + } + }; + // Main render return (
    - {/* Event Status Badge */} -
    + {/* Event Status Badge and Host Actions */} +
    - {eventDetails.currentState.charAt(0).toUpperCase() + - eventDetails.currentState.slice(1)} + {eventDetails.currentState === "In Progress" + ? "In Progress" + : eventDetails.currentState === "ongoing" + ? "In Progress" + : eventDetails.currentState.charAt(0).toUpperCase() + + eventDetails.currentState.slice(1)} - + {/* Join Code Display */} Join Code: {joinCode} + + {/* Start / End Event Buttons (host only) */} + {isHost && ( +
    + + +
    + )}
    From 027acc8adf926acb9877ebf40353d7af15327818 Mon Sep 17 00:00:00 2001 From: Youssef Ibrahim Date: Thu, 19 Mar 2026 15:24:24 -0600 Subject: [PATCH 4/9] Changed bingo to 3x3, fixed bingo, and added bingo creation in the event creation screen. --- shatter-web/src/components/BingoTable.tsx | 40 ++++++++++ shatter-web/src/pages/CreateEventPage.tsx | 89 +++++++++++++++++++++-- shatter-web/src/pages/DashboardPage.tsx | 80 ++++++-------------- shatter-web/src/service/BingoGame.ts | 8 +- 4 files changed, 146 insertions(+), 71 deletions(-) create mode 100644 shatter-web/src/components/BingoTable.tsx diff --git a/shatter-web/src/components/BingoTable.tsx b/shatter-web/src/components/BingoTable.tsx new file mode 100644 index 0000000..9b33770 --- /dev/null +++ b/shatter-web/src/components/BingoTable.tsx @@ -0,0 +1,40 @@ +interface BingoTableProps { + grid: string[][]; + onChange: (row: number, col: number, value: string) => void; +} + +export default function BingoTable({ grid, onChange }: BingoTableProps) { + const size = grid.length; + + return ( +
    + + +
    + {grid.map((row, rowIndex) => + row.map((cell, colIndex) => ( +
    + + onChange(rowIndex, colIndex, 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" + /> +
    + {rowIndex * size + colIndex + 1} +
    +
    + )) + )} +
    +
    + ); +} \ No newline at end of file diff --git a/shatter-web/src/pages/CreateEventPage.tsx b/shatter-web/src/pages/CreateEventPage.tsx index effc0b2..6cce9c6 100644 --- a/shatter-web/src/pages/CreateEventPage.tsx +++ b/shatter-web/src/pages/CreateEventPage.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; -import { 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"; @@ -16,7 +16,10 @@ 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 [bingoDescription, setBingoDescription] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -28,6 +31,26 @@ function CreateEventPage() { setLoading(true); setError(null); + // ✅ Validate main form + if (!name.trim() || !description.trim() || !startDate || !maxParticipant || maxParticipant <= 0) { + throw new Error("Please fill in all required fields."); + } + + // ✅ Validate bingo (if selected) + if (nameBingoSelected) { + const hasEmptyCells = bingoGrid.some(row => + row.some(cell => !cell.trim()) + ); + + if (hasEmptyCells) { + throw new Error("Please fill in all bingo grid cells."); + } + + if (!bingoDescription.trim()) { + throw new Error("Please add a bingo description."); + } + } + // 1️⃣ Create event const { eventId, joinCode } = await CreateEvent({ name, @@ -37,19 +60,36 @@ function CreateEventPage() { maxParticipants: maxParticipant ?? 0, }); - // 2️⃣ If Name Bingo selected, create bingo game + // 2️⃣ Create bingo (ONLY ONE CALL) if (nameBingoSelected) { const token = localStorage.getItem("token"); - if (!token) { - throw new Error("Authentication required to create bingo game."); - } + if (!token) throw new Error("Authentication required."); - await createBingoGame(eventId, token); + const response = await fetch(`${import.meta.env.VITE_API_URL}/bingo/createBingo`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + _eventId: eventId, + description: bingoDescription, + grid: bingoGrid, + }), + }); + + if (!response.ok) { + const errData = await response.json().catch(() => ({})); + throw new Error(errData.message || "Failed to create bingo game."); + } } - // 3️⃣ Navigate to the newly created event page + // 3️⃣ Navigate + setLoading(false); navigate(`/events/${joinCode}`); + } catch (err: any) { + console.error("Create event error:", err); setError(err.message || "Failed to create event. Please try again."); setLoading(false); } @@ -74,6 +114,11 @@ function CreateEventPage() { maxParticipant !== undefined && maxParticipant > 0; + const token = localStorage.getItem("token"); + if (!token) { + navigate("/login"); + return; + } return (
    setMaxParticipant(Number(e.target.value))} + onChange={(e) => { + const val = e.target.value; + setMaxParticipant(val === "" ? undefined : Number(val)); + }} min="1" disabled={loading} /> @@ -239,6 +287,31 @@ function CreateEventPage() {
    + {/*Icebreaker Properties*/} + {nameBingoSelected && ( +
    +
    + + setBingoDescription(e.target.value)} + className="w-full p-3 rounded bg-white border text-black" + /> +
    + + { + const newGrid = bingoGrid.map(r => [...r]); // ✅ deep copy + newGrid[row][col] = value; + setBingoGrid(newGrid); + }} + /> +
    + )} + {/* Action Buttons */}
    diff --git a/shatter-web/src/service/BingoGame.ts b/shatter-web/src/service/BingoGame.ts index 63226c3..abafa16 100644 --- a/shatter-web/src/service/BingoGame.ts +++ b/shatter-web/src/service/BingoGame.ts @@ -24,11 +24,9 @@ export async function createBingoGame( _eventId: eventId, // ✅ THIS IS REQUIRED description: "Name Bingo", grid: [ - ["A1", "B1", "C1", "D1", "E1"], - ["A2", "B2", "C2", "D2", "E2"], - ["A3", "B3", "C3", "D3", "E3"], - ["A4", "B4", "C4", "D4", "E4"], - ["A5", "B5", "C5", "D5", "E5"], + ["A1", "B1", "C1"], + ["A2", "B2", "C2"], + ["A3", "B3", "C3"] ], }), }); From db6d447e2146082c3e7097bb5bf56052870b1f99 Mon Sep 17 00:00:00 2001 From: Youssef Ibrahim Date: Thu, 19 Mar 2026 15:29:07 -0600 Subject: [PATCH 5/9] fixed dashboard --- shatter-web/src/pages/DashboardPage.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/shatter-web/src/pages/DashboardPage.tsx b/shatter-web/src/pages/DashboardPage.tsx index 0e8d9c2..1b7b208 100644 --- a/shatter-web/src/pages/DashboardPage.tsx +++ b/shatter-web/src/pages/DashboardPage.tsx @@ -4,6 +4,17 @@ import Navbar from "../components/Navbar"; import Footer from "../components/Footer"; import BingoTable from "../components/BingoTable"; +import { + CalendarIcon, + ClipboardIcon, + ClockIcon, + InformationCircleIcon, + KeyIcon, + PlusIcon, + SearchIcon, + UsersIcon, + XIcon, +} from "../components/icons"; interface Event { _id: string; name: string; From a913fd14e3e3a28bada15c2adcbec57986aeb5bc Mon Sep 17 00:00:00 2001 From: jktieh Date: Thu, 19 Mar 2026 15:49:28 -0600 Subject: [PATCH 6/9] update DashboardPage.tsx --- shatter-web/src/pages/DashboardPage.tsx | 46 +++++++++++-------------- shatter-web/src/service/BingoGame.ts | 12 +++++-- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/shatter-web/src/pages/DashboardPage.tsx b/shatter-web/src/pages/DashboardPage.tsx index b46a8e5..13fb5ba 100644 --- a/shatter-web/src/pages/DashboardPage.tsx +++ b/shatter-web/src/pages/DashboardPage.tsx @@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom"; import Navbar from "../components/Navbar"; import Footer from "../components/Footer"; import BingoTable from "../components/BingoTable"; - +import { getBingo } from "../service/BingoGame"; import { CalendarIcon, ClipboardIcon, @@ -82,6 +82,14 @@ function DashboardPage() { } }, [events, selectedEvent]); + // Load bingo data whenever selected event changes (incl. auto-select) + useEffect(() => { + if (selectedEvent) { + loadBingoData(selectedEvent._id); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedEvent?._id]); + const fetchUserEvents = async (signal?: AbortSignal) => { try { setLoading(true); @@ -144,29 +152,17 @@ function DashboardPage() { const loadBingoData = async (eventId: string) => { try { - const response = await fetch(`${import.meta.env.VITE_API_URL}/bingo/getBingo`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ id: eventId }), - }); - - if (response.ok) { - const data = await response.json(); - if (data.success && data.bingo) { - setBingoGrid(data.bingo.grid || createEmptyGrid(3)); - setBingoDescription(data.bingo.description || ""); - } + const bingo = await getBingo(eventId); + if (bingo) { + setBingoGrid(bingo.grid); + setBingoDescription(bingo.description ?? ""); } else { - // No bingo exists yet, reset to empty - setBingoGrid(createEmptyGrid(3)); + setBingoGrid(createEmptyGrid(GRID_SIZE)); setBingoDescription(""); } } catch (err) { console.error("Error loading bingo data:", err); - // Reset to empty on error - setBingoGrid(createEmptyGrid(3)); + setBingoGrid(createEmptyGrid(GRID_SIZE)); setBingoDescription(""); } }; @@ -631,8 +627,11 @@ function DashboardPage() { {!selectedIcebreaker && (
    -
    + {bingoSaveMessage && ( +
    + {bingoSaveMessage.text} + +
    + )} +