feat: reconexión de sesión + 6 nuevas categorías + corrección de bugs

- Añade sistema de reconexión tras refresh/cierre del navegador
  - Persistencia de sesión en localStorage (3h TTL)
  - Banner de reconexión en Home
  - Evento rejoin_room en backend

- Nuevas categorías: Series TV, Marvel/DC, Disney, Memes, Pokémon, Mitología

- Correcciones de bugs:
  - Fix: juego bloqueado al fallar robo (steal decision)
  - Fix: jugador duplicado al cambiar de equipo
  - Fix: rotación incorrecta de turno tras fallo

- Config: soporte para Cloudflare tunnel (allowedHosts)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-27 01:53:32 +00:00
parent 6248037b47
commit 112f489e40
9 changed files with 327 additions and 12 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useCallback } from 'react'
import { useGameStore } from '../stores/gameStore'
import { useGameStore, saveSession, clearSession } from '../stores/gameStore'
import { soundPlayer } from './useSound'
import { useThemeStore } from '../stores/themeStore'
import { useSoundStore } from '../stores/soundStore'
@@ -58,6 +58,25 @@ export function useSocket() {
setRoom(data.room)
})
// Reconnection events
socket.on('rejoin_success', (data: { room: GameRoom; player_name: string; team: 'A' | 'B' }) => {
console.log('Rejoin successful:', data.player_name)
setRoom(data.room)
useGameStore.getState().setPlayerName(data.player_name)
// Update saved session with possibly new team
saveSession(data.room.code, data.player_name, data.team)
})
socket.on('rejoin_failed', (data: { message: string }) => {
console.log('Rejoin failed:', data.message)
clearSession()
})
socket.on('player_reconnected', (data: { player_name: string; team: string; room: GameRoom }) => {
console.log('Player reconnected:', data.player_name)
setRoom(data.room)
})
// Game events
socket.on('game_started', (data: { room: GameRoom }) => {
setRoom(data.room)
@@ -268,10 +287,19 @@ export function useSocket() {
socketService.emit('timer_expired', {})
}, [])
const rejoinRoom = useCallback((roomCode: string, playerName: string, team: 'A' | 'B') => {
socketService.emit('rejoin_room', {
room_code: roomCode,
player_name: playerName,
team,
})
}, [])
return {
socket: socketService.connect(),
createRoom,
joinRoom,
rejoinRoom,
changeTeam,
startGame,
selectQuestion,

View File

@@ -21,6 +21,12 @@ const allCategories: Record<number, { name: string; icon: string; color: string
6: { name: 'Películas', icon: '🎬', color: '#F5C518' },
7: { name: 'Libros', icon: '📚', color: '#8B4513' },
8: { name: 'Historia', icon: '🏛️', color: '#6B5B95' },
9: { name: 'Series de TV', icon: '📺', color: '#E50914' },
10: { name: 'Marvel/DC', icon: '🦸', color: '#ED1D24' },
11: { name: 'Disney', icon: '🏰', color: '#113CCF' },
12: { name: 'Memes', icon: '🐸', color: '#7CFC00' },
13: { name: 'Pokémon', icon: '🔴', color: '#FFCB05' },
14: { name: 'Mitología', icon: '⚡', color: '#9B59B6' },
}
export default function Game() {
@@ -116,7 +122,13 @@ export default function Game() {
const handleStealDecision = (attempt: boolean) => {
if (!currentQuestion) return
if (!attempt) {
if (attempt) {
// Notify server that we're attempting to steal
stealDecision(true, currentQuestion.id)
// Keep the question modal open for the steal attempt
// The modal is already controlled by currentQuestion state
} else {
// Pass on steal
stealDecision(false, currentQuestion.id)
}
setShowStealPrompt(false)

View File

@@ -1,8 +1,8 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useSocket } from '../hooks/useSocket'
import { useGameStore } from '../stores/gameStore'
import { useGameStore, getSavedSession, saveSession, clearSession } from '../stores/gameStore'
import { useThemeStore, themes } from '../stores/themeStore'
import { useThemeStyles } from '../themes/ThemeProvider'
import type { ThemeName } from '../types'
@@ -12,16 +12,63 @@ export default function Home() {
const [roomCode, setRoomCode] = useState('')
const [mode, setMode] = useState<'select' | 'create' | 'join'>('select')
const [error, setError] = useState('')
const [savedSession, setSavedSession] = useState<ReturnType<typeof getSavedSession>>(null)
const [reconnecting, setReconnecting] = useState(false)
const navigate = useNavigate()
const { createRoom, joinRoom } = useSocket()
const { createRoom, joinRoom, rejoinRoom } = useSocket()
const { setPlayerName: storeSetPlayerName, room } = useGameStore()
const { currentTheme, setTheme } = useThemeStore()
const { config, styles } = useThemeStyles()
// Check for saved session on mount
useEffect(() => {
const session = getSavedSession()
if (session) {
setSavedSession(session)
}
}, [])
// Navigate when room is created/joined
if (room) {
navigate(`/lobby/${room.code}`)
useEffect(() => {
if (room) {
// Save session when we have a room
const currentName = useGameStore.getState().playerName
const myTeam = room.teams.A.find(p => p.name === currentName) ? 'A' : 'B'
saveSession(room.code, currentName, myTeam)
setReconnecting(false)
// Navigate based on game status
if (room.status === 'playing') {
navigate(`/game/${room.code}`)
} else if (room.status === 'finished') {
navigate(`/results/${room.code}`)
} else {
navigate(`/lobby/${room.code}`)
}
}
}, [room, navigate])
const handleReconnect = () => {
if (!savedSession) return
setReconnecting(true)
storeSetPlayerName(savedSession.playerName)
rejoinRoom(savedSession.roomCode, savedSession.playerName, savedSession.team)
// Timeout for reconnection
setTimeout(() => {
if (!room) {
setReconnecting(false)
setError('No se pudo reconectar. La sala puede haber expirado.')
clearSession()
setSavedSession(null)
}
}, 5000)
}
const handleClearSession = () => {
clearSession()
setSavedSession(null)
}
const handleCreateRoom = () => {
@@ -106,6 +153,50 @@ export default function Home() {
: '0 4px 6px rgba(0,0,0,0.1)',
}}
>
{/* Reconnect Banner */}
{savedSession && mode === 'select' && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-4 p-4 rounded-lg"
style={{
backgroundColor: config.colors.accent + '20',
border: `1px solid ${config.colors.accent}`,
}}
>
<p className="text-sm mb-2" style={{ color: config.colors.text }}>
Partida en progreso detectada
</p>
<p className="text-xs mb-3" style={{ color: config.colors.textMuted }}>
Sala: <strong>{savedSession.roomCode}</strong> Jugador: <strong>{savedSession.playerName}</strong>
</p>
<div className="flex gap-2">
<button
onClick={handleReconnect}
disabled={reconnecting}
className="flex-1 py-2 rounded-lg font-bold text-sm transition-all hover:scale-105 disabled:opacity-50"
style={{
backgroundColor: config.colors.accent,
color: '#FFF',
}}
>
{reconnecting ? 'Reconectando...' : 'Reconectar'}
</button>
<button
onClick={handleClearSession}
className="px-3 py-2 rounded-lg text-sm transition-all hover:scale-105"
style={{
backgroundColor: 'transparent',
color: config.colors.textMuted,
border: `1px solid ${config.colors.textMuted}`,
}}
>
Ignorar
</button>
</div>
</motion.div>
)}
{mode === 'select' ? (
<div className="space-y-4">
<button

View File

@@ -10,11 +10,13 @@ class SocketService {
if (!this.socket) {
console.log('Creating new socket connection to:', SOCKET_URL)
this.socket = io(SOCKET_URL, {
transports: ['websocket', 'polling'],
transports: ['polling', 'websocket'],
autoConnect: true,
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 1000,
upgrade: true,
rememberUpgrade: true,
})
this.socket.on('connect', () => {

View File

@@ -1,6 +1,48 @@
import { create } from 'zustand'
import type { GameRoom, Question, ChatMessage, Achievement } from '../types'
// Session persistence helpers
const SESSION_KEY = 'trivy_session'
interface SavedSession {
roomCode: string
playerName: string
team: 'A' | 'B'
timestamp: number
}
export function saveSession(roomCode: string, playerName: string, team: 'A' | 'B') {
const session: SavedSession = {
roomCode,
playerName,
team,
timestamp: Date.now()
}
localStorage.setItem(SESSION_KEY, JSON.stringify(session))
}
export function getSavedSession(): SavedSession | null {
try {
const data = localStorage.getItem(SESSION_KEY)
if (!data) return null
const session: SavedSession = JSON.parse(data)
// Session expires after 3 hours (same as room TTL)
const threeHours = 3 * 60 * 60 * 1000
if (Date.now() - session.timestamp > threeHours) {
clearSession()
return null
}
return session
} catch {
return null
}
}
export function clearSession() {
localStorage.removeItem(SESSION_KEY)
}
export interface Reaction {
id: string
player_name: string

View File

@@ -5,7 +5,8 @@ export default defineConfig({
plugins: [react()],
server: {
port: 3000,
host: true
host: true,
allowedHosts: ['trivy.consultoria-as.com', 'localhost', '192.168.10.217']
},
build: {
outDir: 'dist',