Added Chat btwn teams for bonuses
This commit is contained in:
62
src/app/api/chat/[code]/route.ts
Normal file
62
src/app/api/chat/[code]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -100,4 +100,4 @@ export async function POST(
|
||||
|
||||
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
}2
|
||||
@@ -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
169
src/components/Chat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
src/lib/rooms.ts
121
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<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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user