Added Chat btwn teams for bonuses

This commit is contained in:
2026-01-30 00:21:03 -06:00
parent 307f073259
commit d1e0cd5de0
6 changed files with 373 additions and 8 deletions

View File

@@ -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 });
}
}

View File

@@ -100,4 +100,4 @@ export async function POST(
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}
}2

View File

@@ -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() {
</div>
)}
</div>
{/* Team Chat - only during bonus for your team */}
{phase === "bonus" && gameState?.currentTeam === parseInt(teamId) && (
<Chat
roomCode={code}
playerId={playerId}
teamId={parseInt(teamId)}
isActive={true}
/>
)}
{/* Leave Button */}
<div style={{ marginTop: "24px", textAlign: "center" }}>

169
src/components/Chat.tsx Normal file
View File

@@ -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<ChatMessage[]>([]);
const [input, setInput] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(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 (
<div
className="card"
style={{
padding: "16px",
marginTop: "16px",
maxHeight: "300px",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
paddingBottom: "12px",
borderBottom: "2px solid var(--ctp-surface2)",
}}
>
<span style={{ fontSize: "1.25rem" }}>💬</span>
<h3
className="text-text"
style={{ fontSize: "1rem", fontWeight: "bold" }}
>
Team Chat
</h3>
</div>
{/* Messages */}
<div
className="bg-surface1"
style={{
flex: 1,
overflowY: "auto",
padding: "12px",
borderRadius: "8px",
minHeight: "150px",
maxHeight: "150px",
}}
>
{messages.length === 0 ? (
<p
className="text-subtext0"
style={{ fontSize: "0.875rem", fontStyle: "italic" }}
>
No messages yet...
</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{messages.map((msg) => (
<div key={msg.id}>
<div
style={{
display: "flex",
gap: "8px",
alignItems: "baseline",
}}
>
<span
className={
msg.playerId === playerId ? "text-mauve" : "text-blue"
}
style={{ fontSize: "0.875rem", fontWeight: "bold" }}
>
{msg.playerName}:
</span>
<span className="text-text" style={{ fontSize: "0.875rem" }}>
{msg.message}
</span>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input */}
<form onSubmit={sendMessage} style={{ display: "flex", gap: "8px" }}>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
maxLength={200}
className="input"
style={{
flex: 1,
padding: "10px 12px",
fontSize: "0.875rem",
background: "var(--ctp-surface1)",
border: "2px solid var(--ctp-surface2)",
borderRadius: "8px",
}}
/>
<button
type="submit"
disabled={!input.trim()}
className="btn btn-primary text-base"
style={{ padding: "10px 20px", fontSize: "0.875rem" }}
>
Send
</button>
</form>
</div>
);
}

View File

@@ -1,5 +1,4 @@
import { Room, Player, GameState } from '@/types';
import type { Room, Player, GameState, ChatMessage } from '@/types';
declare global {
var roomStorage: Map<string, Room> | 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();

View File

@@ -41,9 +41,19 @@ export interface Room {
code: string;
moderatorId: string;
players: Map<string, Player>;
teams: Map<1 | 2, Team>;
teams: Map<number, Team>;
gameState: GameState;
isActive: boolean;
createdAt: Date;
teamMode: 1 | 2; // 1 = individual scoring, 2 = team mode
}
teamMode: 1 | 2;
chatMessages: Map<number, ChatMessage[]>; // teamId -> messages
}
export interface ChatMessage {
id: string;
playerId: string;
playerName: string;
message: string;
timestamp: number;
}