// MapVetoBanner.tsx 'use client' import { useCallback, useEffect, useMemo, useState } from 'react' import { useRouter } from 'next/navigation' import { useSession } from 'next-auth/react' import { useSSEStore } from '@/app/lib/useSSEStore' import type { MapVetoState } from '../types/mapveto' type Props = { match: any; initialNow: number } export default function MapVetoBanner({ match, initialNow }: Props) { const router = useRouter() const { data: session } = useSession() const { lastEvent } = useSSEStore() // ✅ eine Uhr, deterministisch bei Hydration (kommt als Prop vom Server) const [now, setNow] = useState(initialNow) const [state, setState] = useState(null) const [error, setError] = useState(null) const load = useCallback(async () => { try { setError(null) const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store' }) if (!r.ok) { const j = await r.json().catch(() => ({})) throw new Error(j?.message || 'Laden fehlgeschlagen') } const json = await r.json() if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)') setState(json) } catch (e: any) { setState(null) setError(e?.message ?? 'Unbekannter Fehler') } }, [match.id]) // ✅ tickt NUR im Client, nach Hydration useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000) return () => clearInterval(id) }, []) useEffect(() => { load() }, [load]) // Live-Refresh via SSE useEffect(() => { if (!lastEvent || lastEvent.type !== 'map-vote-updated') return if (lastEvent.payload?.matchId !== match.id) return load() }, [lastEvent, match.id, load]) // Öffnet 1h vor Match-/Demotermin (stabil, ohne Date.now() im Render) const opensAt = useMemo(() => { if (state?.opensAt) return new Date(state.opensAt).getTime() const base = new Date(match.matchDate ?? match.demoDate ?? initialNow) return base.getTime() - 60 * 60 * 1000 }, [state?.opensAt, match.matchDate, match.demoDate, initialNow]) const isOpen = now >= opensAt const msToOpen = Math.max(opensAt - now, 0) const current = state?.steps?.[state.currentIndex] const whoIsUp = current?.teamId ? (current.teamId === match.teamA?.id ? match.teamA?.name : match.teamB?.name) : null // ⚠️ leader ist bei dir ein Player-Objekt → .steamId vergleichen const isLeaderA = !!session?.user?.steamId && match.teamA?.leader?.steamId === session.user.steamId const isLeaderB = !!session?.user?.steamId && match.teamB?.leader?.steamId === session.user.steamId const isAdmin = !!session?.user?.isAdmin const iCanAct = Boolean( isOpen && !state?.locked && current?.teamId && (isAdmin || (current.teamId === match.teamA?.id && isLeaderA) || (current.teamId === match.teamB?.id && isLeaderB)) ) const gotoFullPage = () => router.push(`/match-details/${match.id}/vote`) const cardClasses = 'relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 ' + 'dark:border-neutral-700 shadow-sm transition cursor-pointer focus:outline-none ' + (isOpen ? 'ring-1 ring-blue-500/20 hover:ring-blue-500/30 hover:shadow-md' : 'ring-1 ring-neutral-500/10 hover:ring-neutral-500/20 hover:shadow-md') return (
e.key === 'Enter' && gotoFullPage()} className={cardClasses} aria-label="Map-Vote öffnen" > {isOpen &&
}
Map-Vote
Modus: BO{match.bestOf ?? state?.bestOf ?? 3} {state?.locked ? ' • Auswahl fixiert' : isOpen ? (whoIsUp ? ` • am Zug: ${whoIsUp}` : ' • läuft') : ' • startet 1h vor Matchbeginn'}
{error && (
{error}
)}
{state?.locked ? ( Veto abgeschlossen ) : isOpen ? ( {iCanAct ? 'Jetzt wählen' : 'Map-Vote offen'} ) : ( Öffnet in {formatCountdown(msToOpen)} )}
) } function formatCountdown(ms: number) { if (ms <= 0) return '0:00:00' const totalSec = Math.floor(ms / 1000) const h = Math.floor(totalSec / 3600) const m = Math.floor((totalSec % 3600) / 60) const s = totalSec % 60 const pad = (n:number)=>String(n).padStart(2,'0') return `${h}:${pad(m)}:${pad(s)}` }