// CommunityMatchList.tsx 'use client' import { useEffect, useState, useCallback, useMemo } from 'react' import { useSession } from 'next-auth/react' import { useTranslations, useLocale } from 'next-intl' import Link from 'next/link' import Image from 'next/image' import Switch from '../components/Switch' import Button from './Button' import Modal from './Modal' import { Match } from '../../../types/match' import { useSSEStore } from '@/lib/useSSEStore' import { useUserTimeZone } from '@/hooks/useUserTimeZone' type Props = { matchType?: string } const getTeamLogo = (logo?: string | null) => logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp' type TeamOption = { id: string; name: string; logo?: string | null } type UnknownRec = Record; function isRecord(v: unknown): v is UnknownRec { return !!v && typeof v === 'object'; } type PlayerLike = { user?: { steamId?: string | null } | null; steamId?: string | null; }; type TeamMaybeWithPlayers = { id?: string | null; players?: PlayerLike[] | null; }; function toStringOrNull(v: unknown): string | null { return typeof v === 'string' ? v : null; } function firstString(...vals: unknown[]): string { for (const v of vals) if (typeof v === 'string') return v; return ''; } /** lokale Date+Time -> ISO (bewahrt lokale Uhrzeit) */ function combineLocalDateTime(dateStr: string, timeStr: string) { const [y, m, d] = dateStr.split('-').map(Number) const [hh, mm] = timeStr.split(':').map(Number) const dt = new Date(y, (m - 1), d, hh, mm, 0, 0) // lokale Zeit return dt.toISOString() } /** nächste volle Stunde als Default */ function getNextHourDefaults() { const now = new Date() now.setMinutes(0, 0, 0) now.setHours(now.getHours() + 1) const dateStr = now.toISOString().slice(0, 10) const timeStr = now.toTimeString().slice(0, 5) // HH:MM return { dateStr, timeStr } } // 👇 Helper für Map-Vote-Status function getMapVoteState(m: Match, nowMs: number) { const opensAt = m?.mapVote?.opensAt ? new Date(m.mapVote.opensAt) : null const locked = m?.mapVote?.locked ?? false if (!opensAt) return { hasVote: false as const } const opensAtMs = opensAt.getTime() const isOpen = !locked && opensAtMs <= nowMs const opensInMs = Math.max(0, opensAtMs - nowMs) // nie negativ return { hasVote: true as const, isOpen, opensAt, opensInMs } } // ---- Timezone Utils ---- type ZonedParts = { year: number; month: number; day: number; hour: number; minute: number; }; function getZonedParts(date: Date | string, timeZone: string, locale = 'de-DE'): ZonedParts { const d = typeof date === 'string' ? new Date(date) : date; const parts = new Intl.DateTimeFormat(locale, { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }).formatToParts(d); const g = (t: string) => Number(parts.find(p => p.type === t)?.value ?? '0'); return { year: g('year'), month: g('month'), day: g('day'), hour: g('hour'), minute: g('minute') }; } function formatDateInTZ(date: Date | string, timeZone: string, locale = 'de-DE') { const p = getZonedParts(date, timeZone, locale); const pad = (n: number) => String(n).padStart(2, '0'); return `${pad(p.day)}.${pad(p.month)}.${p.year}`; } function formatTimeInTZ(date: Date | string, timeZone: string, locale = 'de-DE') { const p = getZonedParts(date, timeZone, locale); const pad = (n: number) => String(n).padStart(2, '0'); return `${pad(p.hour)}:${pad(p.minute)}`; } function dateKeyInTZ(date: Date | string, timeZone: string): string { const p = getZonedParts(date, timeZone); const pad = (n: number) => String(n).padStart(2, '0'); return `${p.year}-${pad(p.month)}-${pad(p.day)}`; // YYYY-MM-DD } type BanStatus = { vacBanned?: boolean | null numberOfVACBans?: number | null numberOfGameBans?: number | null communityBanned?: boolean | null economyBan?: string | null daysSinceLastBan?: number | null } function isBanStatusFlagged(b?: BanStatus | null): boolean { if (!b) return false const econ = (b.economyBan ?? 'none').toLowerCase() const hasEcon = econ !== 'none' && econ !== '' return ( b.vacBanned === true || (b.numberOfVACBans ?? 0) > 0 || (b.numberOfGameBans ?? 0) > 0 || b.communityBanned === true || hasEcon ) } /** Liefert Info, ob Match gebannte Spieler enthält (zählt beide Seiten) */ function matchBanInfo(m: Match): { hasBan: boolean; count: number; tooltip: string } { const teamA = (m.teamA as unknown as TeamMaybeWithPlayers); const teamB = (m.teamB as unknown as TeamMaybeWithPlayers); const playersA = Array.isArray(teamA?.players) ? teamA.players! : []; const playersB = Array.isArray(teamB?.players) ? teamB.players! : []; // Fallback: flaches players-Array (ältere API) const flat = (m as unknown as { players?: PlayerLike[] | null }).players ?? []; const all: PlayerLike[] = playersA.length || playersB.length ? [...playersA, ...playersB] : Array.isArray(flat) ? flat : []; let count = 0; const lines: string[] = []; for (const p of all) { const user = (p?.user as unknown as { name?: string | null; banStatus?: BanStatus | null }) ?? {}; const name = user?.name ?? 'Unbekannt'; const b = user?.banStatus ?? null; if (isBanStatusFlagged(b)) { count++; const parts: string[] = []; if (b?.vacBanned) parts.push('VAC aktiv'); if ((b?.numberOfVACBans ?? 0) > 0) parts.push(`VAC=${b?.numberOfVACBans}`); if ((b?.numberOfGameBans ?? 0) > 0) parts.push(`Game=${b?.numberOfGameBans}`); if (b?.communityBanned) parts.push('Community'); if (b?.economyBan && b.economyBan !== 'none') parts.push(`Economy=${b.economyBan}`); if (typeof b?.daysSinceLastBan === 'number') parts.push(`Tage seit Ban=${b.daysSinceLastBan}`); lines.push(`${name}: ${parts.join(' · ')}`); } } return { hasBan: count > 0, count, tooltip: lines.join('\n') }; } export default function CommunityMatchList({ matchType }: Props) { const { data: session } = useSession() const locale = useLocale() const userTZ = useUserTimeZone([session?.user?.steamId]) const weekdayFmt = useMemo(() => new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'long', timeZone: userTZ, }), [locale, userTZ]) const tMatches = useTranslations('matches') const tMapvote = useTranslations('mapvote') const { lastEvent } = useSSEStore() const [matches, setMatches] = useState([]) const [onlyOwn, setOnlyOwn] = useState(false) // Modal-States const [showCreate, setShowCreate] = useState(false) const [teams, setTeams] = useState([]) const [loadingTeams, setLoadingTeams] = useState(false) const [saving, setSaving] = useState(false) const [teamAId, setTeamAId] = useState('') const [teamBId, setTeamBId] = useState('') const [title, setTitle] = useState('') // auto editierbar const [autoTitle, setAutoTitle] = useState(true) const [bestOf, setBestOf] = useState<3 | 5>(3) // Datum & Uhrzeit const defaults = getNextHourDefaults() const [matchDateStr, setMatchDateStr] = useState(defaults.dateStr) // YYYY-MM-DD const [matchTimeStr, setMatchTimeStr] = useState(defaults.timeStr) // HH:MM const pad = (n: number) => String(n).padStart(2, '0'); const hours = Array.from({ length: 24 }, (_, i) => i); const quarters = [0, 15, 30, 45]; const teamById = useCallback( (id?: string) => teams.find(t => t.id === id), [teams] ) const [now, setNow] = useState(() => Date.now()) const mySteamId = session?.user?.steamId const isOwnMatch = useCallback((m: Match) => { if (!mySteamId) return false; const teamA = (m.teamA as unknown as TeamMaybeWithPlayers); const teamB = (m.teamB as unknown as TeamMaybeWithPlayers); const inTeamA = Array.isArray(teamA?.players) && teamA.players!.some((p) => p?.user?.steamId === mySteamId || p?.steamId === mySteamId); const inTeamB = Array.isArray(teamB?.players) && teamB.players!.some((p) => p?.user?.steamId === mySteamId || p?.steamId === mySteamId); if (inTeamA || inTeamB) return true; // Fallback: flaches players-Array const flatPlayers = (m as unknown as { players?: PlayerLike[] | null }).players ?? []; const inFlat = Array.isArray(flatPlayers) && flatPlayers.some((p) => p?.user?.steamId === mySteamId || p?.steamId === mySteamId); if (inFlat) return true; // Optionaler Fallback: Team-Mitgliedschaft const byTeamMembership = !!session?.user?.team && (m?.teamA?.id === session.user.team || m?.teamB?.id === session.user.team); return byTeamMembership; }, [mySteamId, session?.user?.team]); useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000) return () => clearInterval(id) }, []) useEffect(() => { const id = setInterval(() => { // force re-render, damit isOpen (Vergleich mit Date.now) neu bewertet wird setMatches(m => [...m]) }, 30_000) return () => clearInterval(id) }, []) // Auto-Titel useEffect(() => { if (!autoTitle) return const a = teamById(teamAId)?.name ?? 'Team A' const b = teamById(teamBId)?.name ?? 'Team B' setTitle(`${a} vs ${b}`) }, [teamAId, teamBId, autoTitle, teamById]) // Matches laden const loadMatches = useCallback(async () => { const url = `/api/matches${matchType ? `?type=${encodeURIComponent(matchType)}` : ''}` try { const r = await fetch(url, { cache: 'no-store' }) const data = r.ok ? await r.json() : [] setMatches(data) } catch (err) { console.error('[MatchList] Laden fehlgeschlagen:', err) } }, [matchType]) useEffect(() => { loadMatches() }, [loadMatches]) useEffect(() => { if (!lastEvent) return // auf diese Typen reagieren const TRIGGER_TYPES = new Set([ 'match-created', 'matches-updated', 'match-deleted', 'map-vote-updated', // damit das Map-Vote Badge live aktualisiert ]) if (!TRIGGER_TYPES.has(lastEvent.type)) return // kurzer Cooldown, falls mehrere Events gleichzeitig eintreffen let cancelled = false const t = setTimeout(async () => { if (!cancelled) await loadMatches() }, 150) return () => { cancelled = true; clearTimeout(t) } }, [lastEvent, loadMatches]) // Teams laden, wenn Modal aufgeht (robust gegen verschiedene Response-Shapes) useEffect(() => { if (!showCreate) return let ignore = false const ctrl = new AbortController() ;(async () => { setLoadingTeams(true) try { const res = await fetch('/api/teams', { cache: 'no-store', credentials: 'same-origin', // wichtig: Cookies mitnehmen signal: ctrl.signal, }) const json: unknown = await res.json().catch(() => ({})); const raw: unknown = (isRecord(json) && Array.isArray(json.teams)) ? json.teams : (isRecord(json) && Array.isArray(json.data)) ? json.data : (isRecord(json) && Array.isArray(json.items)) ? json.items : Array.isArray(json) ? json : []; const arr: UnknownRec[] = Array.isArray(raw) ? (raw as UnknownRec[]) : []; const opts: TeamOption[] = arr .map((r) => { const id = firstString(r.id, r._id, r.teamId, r.uuid); const name = firstString(r.name, r.title, r.displayName, r.tag) || 'Unbenanntes Team'; const logo = toStringOrNull(r.logo ?? r.logoUrl ?? r.image) ?? null; return { id, name, logo }; }) .filter((t) => !!t.id && !!t.name); if (!ignore) setTeams(opts) } catch (e) { if (!ignore) { console.error('[MatchList] /api/teams fehlgeschlagen:', e) setTeams([]) } } finally { if (!ignore) setLoadingTeams(false) } })() return () => { ignore = true; ctrl.abort() } }, [showCreate]) useEffect(() => { if (!showCreate) return if (teams.length >= 2 && !teamAId && !teamBId) { setTeamAId(teams[0].id) setTeamBId(teams[1].id) } }, [teams, showCreate, teamAId, teamBId]) const resetCreateState = () => { setTeamAId('') setTeamBId('') setTitle('') setAutoTitle(true) setBestOf(3) const d = getNextHourDefaults() setMatchDateStr(d.dateStr) setMatchTimeStr(d.timeStr) } const canSave = !saving && teamAId && teamBId && teamAId !== teamBId && title.trim().length > 0 && (bestOf === 3 || bestOf === 5) && !!matchDateStr && !!matchTimeStr const handleCreate = async () => { if (!canSave) return setSaving(true) try { const matchDateISO = combineLocalDateTime(matchDateStr, matchTimeStr) const res = await fetch('/api/matches/create', { method : 'POST', headers: { 'Content-Type': 'application/json' }, body : JSON.stringify({ teamAId, teamBId, title: title.trim(), bestOf, // 3 | 5 matchDate: matchDateISO, // <- Datum+Zeit type : matchType, // optional }), }) if (!res.ok) { const j = await res.json().catch(() => ({})) alert(j.message ?? 'Erstellen fehlgeschlagen') return } setShowCreate(false) resetCreateState() await loadMatches() } catch (e) { console.error('[MatchList] Match erstellen fehlgeschlagen:', e) alert('Match konnte nicht erstellt werden.') } finally { setSaving(false) } } // Gruppieren const grouped = useMemo(() => { // optional filtern const base = onlyOwn ? matches.filter(isOwnMatch) : matches const sorted = [...base].sort( (a, b) => new Date(a.demoDate).getTime() - new Date(b.demoDate).getTime(), ) const map = new Map() for (const m of sorted) { const key = dateKeyInTZ(m.demoDate, userTZ) map.set(key, [...(map.get(key) ?? []), m]) } return Array.from(map.entries()) }, [matches, onlyOwn, isOwnMatch, userTZ]) return (
{/* Kopfzeile */}

{tMatches("title")}

{session?.user?.isAdmin && ( )}
{/* Inhalt */} {grouped.length === 0 ? (

{tMatches("description")}

) : (
{grouped.map(([dateKey, dayMatches], dayIdx) => { const dateObj = new Date(dateKey + 'T00:00:00'); const weekday = weekdayFmt.format(dateObj); const dayLabel = `${tMatches('day')} #${dayIdx + 1} – ${weekday}`; return (
{dayLabel}
{dateKey}
{dayMatches.map((m: Match) => { const started = new Date(m.demoDate).getTime() <= Date.now() const unfinished = !m.winnerTeam && m.scoreA == null && m.scoreB == null const isLive = started && unfinished const isOwn = isOwnMatch(m) const dimmed = onlyOwn ? false : !isOwn const banInfo = matchBanInfo(m) // 👇 Map-Vote Status berechnen const mv = getMapVoteState(m, now) const opensText = mv.hasVote && !mv.isOpen && mv.opensAt ? `${tMapvote("opens-in")} ${formatCountdown(mv.opensInMs)}` : null return ( {/* Zeile 1: Badges (immer Platz, auch wenn leer) */}
{isLive ? ( LIVE ) : mv.hasVote && (mv.isOpen || opensText) ? ( <> {mv.isOpen && ( Map-Vote offen )} {!mv.isOpen && opensText && ( Map-Vote {opensText} )} ) : null}
{/* Zeile 2: Teams */}
{m.teamA?.name {m.teamA.name}
vs
{m.teamB?.name {m.teamB.name}
{/* Zeile 3: Datum & Uhrzeit */}
{/* Datum-Badge */} {formatDateInTZ(m.demoDate, userTZ, locale === 'de' ? 'de-DE' : 'en-GB')} {formatTimeInTZ(m.demoDate, userTZ, locale === 'de' ? 'de-DE' : 'en-GB')} Uhr
) })}
) })}
)} {/* Modal: Match erstellen */} { setShowCreate(false); resetCreateState() }} onSave={handleCreate} closeButtonTitle={saving ? 'Speichern …' : 'Erstellen'} closeButtonColor="blue" >
{/* Team A */} {/* Team B */} {/* Titel */}
{ setTitle(e.target.value); setAutoTitle(false) }} onFocus={() => setAutoTitle(false)} className="w-full rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm" placeholder="Team A vs Team B" /> {!autoTitle && ( )}
{/* Datum & Uhrzeit */}
{/* Links: Datum */}
setMatchDateStr(e.target.value)} className="w-full rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm" />
{/* Rechts: Uhrzeit (HH + MM in Vierteln) */}
{/* Stunde */} {/* Minuten (00/15/30/45) */}

Datum & Uhrzeit werden als lokale Zeit gespeichert.

{/* Best-of */}
{loadingTeams && (

Teams werden geladen …

)} {teamAId && teamBId && teamAId === teamBId && (

Bitte zwei unterschiedliche Teams wählen.

)} {!loadingTeams && showCreate && teams.length === 0 && (

Keine Teams gefunden. Prüfe den /api/teams Response (erwartet id & name).

)}
) } 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)}` }