import { defineStore } from 'pinia'; import { ref, computed, shallowRef, watch } from 'vue'; import { io, Socket } from 'socket.io-client'; import type { ServerToClientEvents, ClientToServerEvents, AdminState, SystemPhase, VotingControlPayload, LotteryControlPayload, EmergencyResetPayload, MusicControlPayload, LotteryRound, } from '@gala/shared/types'; import { PRIZE_CONFIG } from '@gala/shared/types'; import { SOCKET_EVENTS } from '@gala/shared/constants'; import { ADMIN_TOKEN_KEY } from '../router'; type GalaSocket = Socket; // Storage key for state persistence const STORAGE_KEY = 'gala_admin_state'; export const useAdminStore = defineStore('admin', () => { // Socket const socket = shallowRef(null); const isConnected = ref(false); const isConnecting = ref(false); const latency = ref(0); const onlineUsers = ref(0); // Admin State (mirrors server state) const systemPhase = ref('IDLE'); const votingOpen = ref(false); const votingPaused = ref(false); const totalVotes = ref(0); const programs = ref>([]); const allowLateCatch = ref(true); const currentProgramId = ref(null); const awards = ref([]); // Lottery State const lotteryRound = ref(1); const lotterySubPhase = ref<'IDLE' | 'GALAXY' | 'STORM' | 'REVEAL' | 'COMPLETE'>('IDLE'); const stormStartedAt = ref(null); const currentWinners = ref>([]); // Timer for countdown updates const currentTime = ref(Date.now()); let countdownTimer: ReturnType | null = null; function startCountdownTimer() { if (countdownTimer) return; countdownTimer = setInterval(() => { currentTime.value = Date.now(); }, 100); } function stopCountdownTimer() { if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; } } // Music State const musicPlaying = ref(false); const musicTrack = ref<'bgm' | 'lottery' | 'fanfare' | 'none'>('none'); // UI State const pendingAction = ref(null); const lastError = ref(null); const lastActionTime = ref(0); // Computed const connectionStatus = computed(() => { if (isConnected.value) return 'connected'; if (isConnecting.value) return 'connecting'; return 'disconnected'; }); const currentPrize = computed(() => { const config = PRIZE_CONFIG.find(p => p.round === lotteryRound.value); return config || PRIZE_CONFIG[0]; }); const canStopLottery = computed(() => { if (lotterySubPhase.value !== 'STORM') return false; if (!stormStartedAt.value) return false; // 3-second safety delay (use currentTime for reactivity) return currentTime.value - stormStartedAt.value >= 3000; }); const stopButtonCountdown = computed(() => { if (lotterySubPhase.value !== 'STORM') return 0; if (!stormStartedAt.value) return 3; const elapsed = currentTime.value - stormStartedAt.value; return Math.max(0, Math.ceil((3000 - elapsed) / 1000)); }); // Persist state to localStorage function saveState() { const state = { systemPhase: systemPhase.value, votingOpen: votingOpen.value, votingPaused: votingPaused.value, lotteryRound: lotteryRound.value, lotterySubPhase: lotterySubPhase.value, stormStartedAt: stormStartedAt.value, currentWinners: currentWinners.value, musicPlaying: musicPlaying.value, musicTrack: musicTrack.value, }; localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } // Restore state from localStorage function restoreState() { try { const saved = localStorage.getItem(STORAGE_KEY); if (saved) { const state = JSON.parse(saved); systemPhase.value = state.systemPhase || 'IDLE'; votingOpen.value = state.votingOpen || false; votingPaused.value = state.votingPaused || false; lotteryRound.value = state.lotteryRound || 1; lotterySubPhase.value = state.lotterySubPhase || 'IDLE'; stormStartedAt.value = state.stormStartedAt || null; currentWinners.value = state.currentWinners || []; musicPlaying.value = state.musicPlaying || false; musicTrack.value = state.musicTrack || 'none'; // If in STORM phase but stormStartedAt is missing, set it to allow stopping if (lotterySubPhase.value === 'STORM' && !stormStartedAt.value) { stormStartedAt.value = Date.now() - 4000; // Allow immediate stop } } } catch (e) { console.error('[Admin] Failed to restore state:', e); } } // Watch for state changes and persist watch( [systemPhase, votingOpen, votingPaused, lotteryRound, lotterySubPhase, stormStartedAt, currentWinners, musicPlaying, musicTrack], () => saveState(), { deep: true } ); // Watch lotterySubPhase to manage countdown timer watch(lotterySubPhase, (newPhase) => { if (newPhase === 'STORM') { startCountdownTimer(); } else { stopCountdownTimer(); } }, { immediate: true }); // Connect to server function connect() { if (socket.value?.connected || isConnecting.value) return; isConnecting.value = true; restoreState(); const adminToken = localStorage.getItem(ADMIN_TOKEN_KEY) || ''; const socketInstance = io(import.meta.env.VITE_SOCKET_URL || '', { reconnection: true, reconnectionAttempts: Infinity, reconnectionDelay: 1000, reconnectionDelayMax: 5000, timeout: 10000, transports: ['websocket', 'polling'], auth: adminToken ? { token: adminToken } : undefined, }); socketInstance.on('connect', () => { console.log('[Admin] Connected'); isConnected.value = true; isConnecting.value = false; // Join as admin socketInstance.emit(SOCKET_EVENTS.CONNECTION_JOIN as any, { userId: 'admin_main', userName: 'Admin Console', role: 'admin', sessionToken: adminToken || undefined, }, () => { }); // Request state sync socketInstance.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, {}); }); socketInstance.on('disconnect', (reason) => { console.log('[Admin] Disconnected:', reason); isConnected.value = false; }); socketInstance.on('connect_error', (error) => { console.error('[Admin] Connection error:', error); isConnecting.value = false; }); // User count updates socketInstance.on('connection:users_count' as any, (count: number) => { onlineUsers.value = count; }); // Admin state sync from server socketInstance.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, (state: AdminState) => { syncFromServer(state); }); // Real-time vote updates (for totalVotes and program votes) socketInstance.on(SOCKET_EVENTS.VOTE_UPDATED as any, (data: { candidateId: string; totalVotes?: number; programVotes?: number; delta?: number }) => { const program = programs.value.find(p => p.id === data.candidateId); if (program) { if (typeof data.programVotes === 'number') { program.votes = data.programVotes; } else if (typeof data.delta === 'number') { program.votes += data.delta; } } if (typeof data.totalVotes === 'number') { totalVotes.value = data.totalVotes; } }); socket.value = socketInstance as GalaSocket; } function syncFromServer(state: AdminState) { systemPhase.value = state.systemPhase; votingOpen.value = state.voting.subPhase === 'OPEN'; votingPaused.value = state.voting.subPhase === 'PAUSED'; totalVotes.value = state.voting.totalVotes; programs.value = state.voting.programs || []; awards.value = state.voting.awards || []; allowLateCatch.value = state.voting.allowLateCatch ?? true; currentProgramId.value = state.voting.currentProgramId || null; lotteryRound.value = state.lottery.round; lotterySubPhase.value = state.lottery.subPhase; currentWinners.value = state.lottery.currentWinners; musicPlaying.value = state.music.isPlaying; musicTrack.value = state.music.track; saveState(); } function disconnect() { if (socket.value) { socket.value.disconnect(); socket.value = null; } isConnected.value = false; isConnecting.value = false; } // Admin Actions function setPhase(phase: SystemPhase) { if (!socket.value?.connected) return; pendingAction.value = 'phase_change'; socket.value.emit(SOCKET_EVENTS.ADMIN_PHASE_CHANGE as any, { phase, timestamp: Date.now(), }, (response: any) => { pendingAction.value = null; if (response.success) { systemPhase.value = phase; lastActionTime.value = Date.now(); } else { lastError.value = response.message || 'Failed to change phase'; } }); } function controlVoting(action: 'open' | 'close' | 'pause' | 'resume') { if (!socket.value?.connected) return; pendingAction.value = `voting_${action}`; socket.value.emit(SOCKET_EVENTS.ADMIN_VOTING_CONTROL as any, { action, timestamp: Date.now(), } as VotingControlPayload, (response: any) => { pendingAction.value = null; if (response.success) { if (action === 'open') { votingOpen.value = true; votingPaused.value = false; } else if (action === 'close') { votingOpen.value = false; votingPaused.value = false; } else if (action === 'pause') { votingPaused.value = true; } else if (action === 'resume') { votingPaused.value = false; } lastActionTime.value = Date.now(); } else { lastError.value = response.message || `Failed to ${action} voting`; } }); } function controlLottery(action: LotteryControlPayload['action'], winnerIds?: string[]) { if (!socket.value?.connected) return; pendingAction.value = `lottery_${action}`; const payload: LotteryControlPayload = { action, round: lotteryRound.value, timestamp: Date.now(), }; if (winnerIds) payload.winnerIds = winnerIds; socket.value.emit(SOCKET_EVENTS.ADMIN_LOTTERY_CONTROL as any, payload, (response: any) => { pendingAction.value = null; if (response.success) { if (action === 'start_galaxy') { lotterySubPhase.value = 'GALAXY'; } else if (action === 'start_storm') { lotterySubPhase.value = 'STORM'; stormStartedAt.value = Date.now(); } else if (action === 'stop_reveal') { lotterySubPhase.value = 'REVEAL'; if (response.data?.winners) { currentWinners.value = response.data.winners; } } else if (action === 'next_round') { lotteryRound.value = Math.min(4, lotteryRound.value + 1) as LotteryRound; lotterySubPhase.value = 'IDLE'; currentWinners.value = []; stormStartedAt.value = null; } else if (action === 'reset') { lotteryRound.value = 1; lotterySubPhase.value = 'IDLE'; currentWinners.value = []; stormStartedAt.value = null; } lastActionTime.value = Date.now(); } else { lastError.value = response.message || `Failed to ${action}`; } }); } function emergencyReset(scope: 'all' | 'voting' | 'lottery', confirmCode: string) { if (!socket.value?.connected) return; if (confirmCode !== 'RESET') { lastError.value = 'Invalid confirmation code'; return; } pendingAction.value = 'emergency_reset'; socket.value.emit(SOCKET_EVENTS.ADMIN_EMERGENCY_RESET as any, { scope, confirmCode, timestamp: Date.now(), } as EmergencyResetPayload, (response: any) => { pendingAction.value = null; if (response.success) { if (scope === 'all' || scope === 'voting') { votingOpen.value = false; votingPaused.value = false; totalVotes.value = 0; } if (scope === 'all' || scope === 'lottery') { lotteryRound.value = 1; lotterySubPhase.value = 'IDLE'; currentWinners.value = []; stormStartedAt.value = null; } if (scope === 'all') { systemPhase.value = 'IDLE'; } lastActionTime.value = Date.now(); } else { lastError.value = response.message || 'Emergency reset failed'; } }); } function controlMusic(action: 'play' | 'stop' | 'change_track', track?: MusicControlPayload['track']) { if (!socket.value?.connected) return; pendingAction.value = `music_${action}`; socket.value.emit(SOCKET_EVENTS.ADMIN_MUSIC_CONTROL as any, { action, track, } as MusicControlPayload, (response: any) => { pendingAction.value = null; if (response.success) { if (action === 'play') { musicPlaying.value = true; if (track) musicTrack.value = track; } else if (action === 'stop') { musicPlaying.value = false; } else if (action === 'change_track' && track) { musicTrack.value = track; } lastActionTime.value = Date.now(); } else { lastError.value = response.message || `Failed to ${action} music`; } }); } function clearError() { lastError.value = null; } return { // Connection isConnected, isConnecting, connectionStatus, latency, onlineUsers, // State systemPhase, votingOpen, votingPaused, totalVotes, programs, allowLateCatch, currentProgramId, awards, lotteryRound, lotterySubPhase, stormStartedAt, currentWinners, musicPlaying, musicTrack, // Computed currentPrize, canStopLottery, stopButtonCountdown, // UI State pendingAction, lastError, lastActionTime, // Actions connect, disconnect, setPhase, controlVoting, controlLottery, emergencyReset, controlMusic, clearError, getSocket: () => socket.value, }; });