diff --git a/src/app/api/chat/[code]/route.ts b/src/app/api/chat/[code]/route.ts
new file mode 100644
index 0000000..f2a22e4
--- /dev/null
+++ b/src/app/api/chat/[code]/route.ts
@@ -0,0 +1,62 @@
+import { NextResponse } from 'next/server';
+import { sendChatMessage, getChatMessages } from '@/lib/rooms';
+import { z, ZodError } from 'zod';
+
+const chatMessageSchema = z.object({
+ playerId: z.string(),
+ message: z.string().min(1).max(200),
+});
+
+const chatQuerySchema = z.object({
+ teamId: z.coerce.number().int().min(1).max(2),
+});
+
+export async function POST(
+ req: Request,
+ { params }: { params: Promise<{ code: string }> }
+) {
+ try {
+ const { code } = await params;
+ const body = await req.json();
+ const { playerId, message } = chatMessageSchema.parse(body);
+
+ const success = sendChatMessage(code, playerId, message);
+
+ if (!success) {
+ return NextResponse.json({ error: 'Cannot send message' }, { status: 400 });
+ }
+
+ return NextResponse.json({ success: true });
+
+ } catch (error: unknown) {
+ if (error instanceof ZodError) {
+ return NextResponse.json({ error: error.issues[0].message }, { status: 400 });
+ }
+
+ return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
+ }
+}
+
+export async function GET(
+ req: Request,
+ { params }: { params: Promise<{ code: string }> }
+) {
+ try {
+ const { code } = await params;
+ const url = new URL(req.url);
+ const teamId = url.searchParams.get('teamId');
+
+ const { teamId: validatedTeamId } = chatQuerySchema.parse({ teamId });
+
+ const messages = getChatMessages(code, validatedTeamId);
+
+ return NextResponse.json({ messages });
+
+ } catch (error: unknown) {
+ if (error instanceof ZodError) {
+ return NextResponse.json({ error: error.issues[0].message }, { status: 400 });
+ }
+
+ return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/game/[code]/route.ts b/src/app/api/game/[code]/route.ts
index 415b484..cbaa946 100644
--- a/src/app/api/game/[code]/route.ts
+++ b/src/app/api/game/[code]/route.ts
@@ -100,4 +100,4 @@ export async function POST(
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
-}
\ No newline at end of file
+}2
\ No newline at end of file
diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx
index 63352cc..51e2151 100644
--- a/src/app/play/page.tsx
+++ b/src/app/play/page.tsx
@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from "react";
import Cookies from "js-cookie";
+import Chat from "@/components/Chat";
export default function PlayPage() {
const [name, setName] = useState("");
@@ -796,6 +797,16 @@ export default function PlayPage() {
)}
+ {/* Team Chat - only during bonus for your team */}
+ {phase === "bonus" && gameState?.currentTeam === parseInt(teamId) && (
+
+ )}
+
{/* Leave Button */}
diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx
new file mode 100644
index 0000000..3f8beea
--- /dev/null
+++ b/src/components/Chat.tsx
@@ -0,0 +1,169 @@
+"use client";
+
+import { useState, useEffect, useRef } from "react";
+import { ChatMessage } from "@/types";
+
+interface ChatProps {
+ roomCode: string;
+ playerId: string;
+ teamId: number;
+ isActive: boolean; // Only active during bonus for your team
+}
+
+export default function Chat({
+ roomCode,
+ playerId,
+ teamId,
+ isActive,
+}: ChatProps) {
+ const [messages, setMessages] = useState
([]);
+ const [input, setInput] = useState("");
+ const messagesEndRef = useRef(null);
+
+ // Fetch messages
+ useEffect(() => {
+ if (!isActive) {
+ setMessages([]);
+ return;
+ }
+
+ const interval = setInterval(async () => {
+ const res = await fetch(`/api/chat/${roomCode}?teamId=${teamId}`);
+ if (res.ok) {
+ const data = await res.json();
+ setMessages(data.messages || []);
+ }
+ }, 500); // Poll every 500ms
+
+ return () => clearInterval(interval);
+ }, [roomCode, teamId, isActive]);
+
+ // Auto-scroll to bottom
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [messages]);
+
+ async function sendMessage(e: React.FormEvent) {
+ e.preventDefault();
+ if (!input.trim() || !isActive) return;
+
+ await fetch(`/api/chat/${roomCode}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ playerId, message: input }),
+ });
+
+ setInput("");
+ }
+
+ if (!isActive) return null;
+
+ return (
+
+
+ 💬
+
+ Team Chat
+
+
+
+ {/* Messages */}
+
+ {messages.length === 0 ? (
+
+ No messages yet...
+
+ ) : (
+
+ {messages.map((msg) => (
+
+
+
+ {msg.playerName}:
+
+
+ {msg.message}
+
+
+
+ ))}
+
+
+ )}
+
+
+ {/* Input */}
+
+
+ );
+}
diff --git a/src/lib/rooms.ts b/src/lib/rooms.ts
index faed201..9533577 100644
--- a/src/lib/rooms.ts
+++ b/src/lib/rooms.ts
@@ -1,5 +1,4 @@
-import { Room, Player, GameState } from '@/types';
-
+import type { Room, Player, GameState, ChatMessage } from '@/types';
declare global {
var roomStorage: Map | undefined;
}
@@ -34,7 +33,6 @@ function createInitialGameState(): GameState {
totalQuestions: 20,
};
}
-
export function createRoom(teamMode: 1 | 2 = 2) {
const code = generateCode();
const moderatorId = generateId();
@@ -51,12 +49,15 @@ export function createRoom(teamMode: 1 | 2 = 2) {
isActive: true,
createdAt: new Date(),
teamMode,
+ chatMessages: new Map([ // ADD THIS
+ [1, []],
+ [2, []],
+ ]),
});
console.log('✅ Created room:', code, 'Mode:', teamMode === 1 ? 'Individual' : 'Team');
return { code, moderatorId };
}
-
export function joinRoom(code: string, name: string, teamId: 1 | 2) {
const room = rooms.get(code);
@@ -272,6 +273,68 @@ export function markTossupWrong(code: string, moderatorId: string) {
return true;
}
+import { NextResponse } from 'next/server';
+import { z, ZodError } from 'zod';
+
+const chatMessageSchema = z.object({
+ playerId: z.string(),
+ message: z.string().min(1).max(200),
+});
+
+const chatQuerySchema = z.object({
+ teamId: z.coerce.number().int().min(1).max(2),
+});
+
+export async function POST(
+ req: Request,
+ { params }: { params: Promise<{ code: string }> }
+) {
+ try {
+ const { code } = await params;
+ const body = await req.json();
+ const { playerId, message } = chatMessageSchema.parse(body);
+
+ const success = sendChatMessage(code, playerId, message);
+
+ if (!success) {
+ return NextResponse.json({ error: 'Cannot send message' }, { status: 400 });
+ }
+
+ return NextResponse.json({ success: true });
+
+ } catch (error: unknown) {
+ if (error instanceof ZodError) {
+ return NextResponse.json({ error: error.issues[0].message }, { status: 400 });
+ }
+
+ return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
+ }
+}
+
+export async function GET(
+ req: Request,
+ { params }: { params: Promise<{ code: string }> }
+) {
+ try {
+ const { code } = await params;
+ const url = new URL(req.url);
+ const teamId = url.searchParams.get('teamId');
+
+ const { teamId: validatedTeamId } = chatQuerySchema.parse({ teamId });
+
+ const messages = getChatMessages(code, validatedTeamId);
+
+ return NextResponse.json({ messages });
+
+ } catch (error: unknown) {
+ if (error instanceof ZodError) {
+ return NextResponse.json({ error: error.issues[0].message }, { status: 400 });
+ }
+
+ return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
+ }
+}
+
export function moveOn(code: string, moderatorId: string) {
const room = rooms.get(code);
if (!room || room.moderatorId !== moderatorId) return false;
@@ -319,6 +382,56 @@ export function endGame(code: string, moderatorId: string) {
return true;
}
+// Chat functions
+export function sendChatMessage(code: string, playerId: string, message: string) {
+ const room = rooms.get(code);
+ if (!room) return false;
+
+ const player = room.players.get(playerId);
+ if (!player) return false;
+
+ // Only allow chat during bonus phase
+ if (room.gameState.phase !== 'bonus') return false;
+
+ // Only allow chat for the team that has the bonus
+ if (room.gameState.currentTeam !== player.teamId) return false;
+
+ const teamMessages = room.chatMessages.get(player.teamId) || [];
+
+ const chatMessage: ChatMessage = {
+ id: generateId(),
+ playerId: player.id,
+ playerName: player.name,
+ message: message.trim().slice(0, 200), // Max 200 chars
+ timestamp: Date.now(),
+ };
+
+ teamMessages.push(chatMessage);
+
+ // Keep only last 50 messages per team
+ if (teamMessages.length > 50) {
+ teamMessages.shift();
+ }
+
+ room.chatMessages.set(player.teamId, teamMessages);
+
+ return true;
+}
+
+export function getChatMessages(code: string, teamId: number) {
+ const room = rooms.get(code);
+ if (!room) return [];
+
+ return room.chatMessages.get(teamId) || [];
+}
+
+export function clearTeamChat(code: string, teamId: number) {
+ const room = rooms.get(code);
+ if (!room) return;
+
+ room.chatMessages.set(teamId, []);
+}
+
// Cleanup
setInterval(() => {
const now = Date.now();
diff --git a/src/types/index.ts b/src/types/index.ts
index 0d7ce3d..e01e9e4 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -41,9 +41,19 @@ export interface Room {
code: string;
moderatorId: string;
players: Map;
- teams: Map<1 | 2, Team>;
+ teams: Map;
gameState: GameState;
isActive: boolean;
createdAt: Date;
- teamMode: 1 | 2; // 1 = individual scoring, 2 = team mode
-}
\ No newline at end of file
+ teamMode: 1 | 2;
+ chatMessages: Map; // teamId -> messages
+}
+
+export interface ChatMessage {
+ id: string;
+ playerId: string;
+ playerName: string;
+ message: string;
+ timestamp: number;
+}
+