// /src/app/[locale]/components/InvitePlayersModal.tsx 'use client' import { useState, useEffect, useRef, useCallback, useMemo, useTransition } from 'react' import Modal from './Modal' import MiniCard from './MiniCard' import { useSession } from 'next-auth/react' import LoadingSpinner from './LoadingSpinner' import { Player, Team } from '../../../types/team' import Pagination from './Pagination' import { AnimatePresence, motion } from 'framer-motion' import Switch from './Switch' type InviteStatus = 'sent' | 'failed' | 'added' | 'pending' type Props = { show: boolean onClose: () => void onSuccess: () => void team: Team directAdd?: boolean } type UnknownRec = Record; type ApiResultItem = { steamId: string; ok: boolean }; function parseResultsFromJson(json: unknown): ApiResultItem[] | null { if (!isRecord(json)) return null; if (Array.isArray(json.results)) { const out: ApiResultItem[] = []; for (const r of json.results) { const steamIdVal = isRecord(r) ? r.steamId : undefined; const okVal = isRecord(r) ? r.ok : undefined; const steamId = typeof steamIdVal === 'string' ? steamIdVal : typeof steamIdVal === 'number' ? String(steamIdVal) : null; const ok = typeof okVal === 'boolean' ? okVal : false; if (steamId) out.push({ steamId, ok }); } return out; } if (Array.isArray(json.invitationIds)) { const ids: string[] = (json.invitationIds as unknown[]).map(v => typeof v === 'string' ? v : String(v) ); return ids.map(steamId => ({ steamId, ok: true })); } return null; } function isRecord(v: unknown): v is UnknownRec { return !!v && typeof v === 'object'; } function getTeamLeaderSteamId(team: Team | null | undefined): string | null { if (!team) return null; const leaderUnknown = (team as unknown as { leader?: unknown }).leader; if (typeof leaderUnknown === 'string') return leaderUnknown; if (isRecord(leaderUnknown) && typeof leaderUnknown.steamId === 'string') { return leaderUnknown.steamId; } return null; } export default function InvitePlayersModal({ show, onClose, onSuccess, team, directAdd = false }: Props) { const { data: session } = useSession() const steamId = session?.user?.steamId const [allUsers, setAllUsers] = useState([]) const [selectedIds, setSelectedIds] = useState([]) const [knownUsers, setKnownUsers] = useState>({}) const [invitedIds, setInvitedIds] = useState([]) const [isInviting, setIsInviting] = useState(false) const [isSuccess, setIsSuccess] = useState(false) const [sentCount, setSentCount] = useState(0) const [searchTerm, setSearchTerm] = useState('') // Toggle: nur Spieler ohne Team const [onlyFree, setOnlyFree] = useState(false) // Sanftes UI-Update const [, startTransition] = useTransition() const [isFetching, setIsFetching] = useState(false) const abortRef = useRef(null) // 🔽 Anti-Flacker: verzögert anzeigen + Mindestdauer const SPINNER_DELAY_MS = 120 const SPINNER_MIN_MS = 250 const [spinnerVisible, setSpinnerVisible] = useState(false) const spinnerShowTimer = useRef(null) const spinnerShownAt = useRef(null) // Dynamisch berechnete Items pro Seite const [usersPerPage, setUsersPerPage] = useState(9) const [currentPage, setCurrentPage] = useState(1) const [invitedStatus, setInvitedStatus] = useState>({}) // Refs / Layout const descRef = useRef(null) const selectedWrapRef = useRef(null) const searchRef = useRef(null) const successRef = useRef(null) const gridRef = useRef(null) const firstCardRef = useRef(null) const showControls = !isSuccess // aktuelle Grid-Höhe halten const [gridHoldHeight, setGridHoldHeight] = useState(0) useEffect(() => { if (!show) return void fetchUsers({ resetLayout: true }) setIsSuccess(false) setInvitedIds([]) setInvitedStatus({}) // eslint-disable-next-line react-hooks/exhaustive-deps }, [show]) useEffect(() => { if (!show) return setCurrentPage(1) void fetchUsers({ resetLayout: false }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [onlyFree]) useEffect(() => { if (!gridRef.current) return const observer = new ResizeObserver(() => { if (!gridRef.current) return const gridHeight = gridRef.current.clientHeight const cardHeight = gridRef.current.querySelector('div')?.clientHeight || 0 const rows = cardHeight > 0 ? Math.floor(gridHeight / cardHeight) : 1 const cols = window.innerWidth >= 640 ? 3 : 2 setUsersPerPage(rows * cols) }) observer.observe(gridRef.current) return () => observer.disconnect() }, []) async function fetchUsers(opts: { resetLayout: boolean }) { try { abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; if (gridRef.current) setGridHoldHeight(gridRef.current.clientHeight); setIsFetching(true); if (spinnerShowTimer.current) window.clearTimeout(spinnerShowTimer.current); spinnerShowTimer.current = window.setTimeout(() => { setSpinnerVisible(true); spinnerShownAt.current = Date.now(); }, SPINNER_DELAY_MS); const qs = new URLSearchParams({ teamId: team.id }); if (onlyFree) qs.set('onlyFree', 'true'); const res = await fetch(`/api/team/available-users?${qs.toString()}`, { signal: ctrl.signal, cache: 'no-store', }); if (!res.ok) throw new Error('load failed'); const dataUnknown: unknown = await res.json(); const users = (isRecord(dataUnknown) && Array.isArray(dataUnknown.users)) ? (dataUnknown.users as Player[]) : []; startTransition(() => { setAllUsers(users); setKnownUsers(prev => { const next = { ...prev }; for (const u of users) next[u.steamId] = u; return next; }); if (opts.resetLayout) { setSelectedIds([]); setInvitedIds([]); setIsSuccess(false); } }); } catch (e: unknown) { if (isRecord(e) && e.name === 'AbortError') return; console.error('Fehler beim Laden der Benutzer:', e); } finally { setIsFetching(false) abortRef.current = null // 🔽 Spinner Mindestdauer respektieren const hide = () => { setSpinnerVisible(false) spinnerShownAt.current = null setGridHoldHeight(0) } if (spinnerShowTimer.current) { // Wenn der Delay-Timer noch nicht gefeuert hat: einfach abbrechen window.clearTimeout(spinnerShowTimer.current) spinnerShowTimer.current = null // Der Spinner wurde evtl. nie sichtbar -> direkt aufräumen if (!spinnerShownAt.current) { hide() return } } if (spinnerShownAt.current) { const elapsed = Date.now() - spinnerShownAt.current const remain = Math.max(0, SPINNER_MIN_MS - elapsed) if (remain > 0) { window.setTimeout(hide, remain) } else { hide() } } else { // Falls er nie sichtbar war hide() } } } const handleSelect = (steamId: string) => { setSelectedIds(prev => { const isSelected = prev.includes(steamId) // Beim Hinzufügen: sicherstellen, dass wir die Card-Daten im Cache haben if (!isSelected) { const u = allUsers.find(u => u.steamId === steamId) if (u) { setKnownUsers(map => (map[steamId] ? map : { ...map, [steamId]: u })) } } return isSelected ? prev.filter(id => id !== steamId) : [...prev, steamId] }) } const handleInvite = async () => { if (isInviting) return; if (selectedIds.length === 0 || !steamId) return; const ids = [...selectedIds]; try { setIsInviting(true); const url = directAdd ? '/api/team/add-players' : '/api/team/invite'; const body = directAdd ? { teamId: team.id, steamIds: ids } : { teamId: team.id, userIds: ids, invitedBy: steamId }; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); let jsonUnknown: unknown = null; try { jsonUnknown = await res.clone().json(); } catch { /* ignore */ } let results: ApiResultItem[] | null = parseResultsFromJson(jsonUnknown); // Fallback, falls API keine detailierten results liefert if (!results) { results = ids.map(id => ({ steamId: id, ok: res.ok })); } const nextStatus: Record = {}; let okCount = 0; for (const r of results) { const st: InviteStatus = r.ok ? (directAdd ? 'added' : 'sent') : 'failed'; nextStatus[r.steamId] = st; if (r.ok) okCount++; } setInvitedStatus(prev => ({ ...prev, ...nextStatus })); setInvitedIds(ids); setSentCount(okCount); setIsSuccess(true); setSelectedIds([]); if (okCount > 0) onSuccess(); } catch (err: unknown) { console.error('Fehler beim Einladen:', err); setInvitedStatus(prev => ({ ...prev, ...Object.fromEntries(selectedIds.map(id => [id, 'failed' as InviteStatus])), })); setInvitedIds(selectedIds); setSentCount(0); setIsSuccess(true); } finally { setIsInviting(false); } }; useEffect(() => { setCurrentPage(1) }, [searchTerm]) const filteredUsers = useMemo(() => { if (isSuccess) return allUsers return allUsers.filter(u => u.name?.toLowerCase().includes(searchTerm.toLowerCase())) }, [allUsers, searchTerm, isSuccess]) const unselectedUsers = useMemo(() => { if (isSuccess) return filteredUsers return filteredUsers.filter(user => !selectedIds.includes(user.steamId) && (!isSuccess || !invitedIds.includes(user.steamId)) ) }, [filteredUsers, selectedIds, invitedIds, isSuccess]) const totalPages = Math.ceil(unselectedUsers.length / Math.max(1, usersPerPage)) const startIdx = (currentPage - 1) * Math.max(1, usersPerPage) const paginatedUsers = unselectedUsers.slice(startIdx, startIdx + Math.max(1, usersPerPage)) useEffect(() => { if (totalPages === 0 && currentPage !== 1) setCurrentPage(1) else if (currentPage > totalPages) setCurrentPage(totalPages || 1) }, [totalPages, currentPage]) const recalcUsersPerPage = useCallback(() => { const gridEl = gridRef.current if (!gridEl) return const bodyEl = gridEl.parentElement as HTMLElement | null if (!bodyEl) return const outer = (el: HTMLElement | null) => { if (!el) return 0 const cs = getComputedStyle(el) return el.offsetHeight + parseFloat(cs.marginTop || '0') + parseFloat(cs.marginBottom || '0') } const gridMT = parseFloat(getComputedStyle(gridEl).marginTop || '0') const usedAbove = outer(descRef.current) + outer(selectedWrapRef.current) + outer(searchRef.current) + outer(successRef.current) + gridMT const reserveForPagination = (!isFetching && !isSuccess && totalPages > 1) ? 48 : 0 const availableHeight = Math.max(0, bodyEl.clientHeight - usedAbove - reserveForPagination) const csGrid = getComputedStyle(gridEl) const cols = Math.max(1, csGrid.gridTemplateColumns.split(' ').filter(Boolean).length) const rowGap = parseFloat(csGrid.rowGap || '0') const cardEl = firstCardRef.current let cardHeight = cardEl?.offsetHeight || 0 if (!cardHeight) cardHeight = 140 const rows = Math.max(1, Math.floor((availableHeight + rowGap) / (cardHeight + rowGap))) const nextPerPage = Math.max(1, rows * cols) setUsersPerPage(prev => (prev !== nextPerPage ? nextPerPage : prev)) }, [isFetching, isSuccess, totalPages]) useEffect(() => { recalcUsersPerPage() const gridEl = gridRef.current if (!gridEl) return const bodyEl = gridEl.parentElement as HTMLElement | null const cardEl = firstCardRef.current const ro = new ResizeObserver(() => recalcUsersPerPage()) ro.observe(gridEl) if (bodyEl) ro.observe(bodyEl) if (cardEl) ro.observe(cardEl) const onResize = () => recalcUsersPerPage() window.addEventListener('resize', onResize) const id = window.setTimeout(recalcUsersPerPage, 60) return () => { window.clearTimeout(id) ro.disconnect() window.removeEventListener('resize', onResize) } }, [recalcUsersPerPage, show, filteredUsers.length, isSuccess]) return ( { if (!isInviting) handleInvite() }) : undefined} closeButtonColor={!isSuccess ? (isSuccess ? 'teal' : 'blue') : undefined} closeButtonTitle={ !isSuccess ? (isInviting ? (directAdd ? 'Wird hinzugefügt...' : 'Wird eingeladen...') : (directAdd ? 'Hinzufügen' : 'Einladungen senden')) : undefined } closeButtonLoading={!isSuccess && isInviting} scrollBody > {showControls && (

{directAdd ? 'Wähle Spieler aus, die du direkt zum Team hinzufügen möchtest:' : 'Wähle Spieler aus, die du in dein Team einladen möchtest:'}

)} {/* Filterleiste */} {showControls && (
setSearchTerm(e.target.value)} className="w-40 sm:w-56 rounded border px-2 py-1.5 text-[13px] focus:outline-none focus:ring focus:ring-blue-400 dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-100" />
startTransition(() => setOnlyFree(v))} labelLeft="Alle" labelRight="Nur ohne Team" />
)} {/* Ausgewählte */} {selectedIds.length > 0 && (

Ausgewählte Spieler:

{selectedIds.map((id) => { const user = knownUsers[id] if (!user) return null return ( ) })}
)} {isSuccess && (
{isSuccess && ( <> {directAdd ? (sentCount === 0 ? 'Niemand konnte hinzugefügt werden.' : sentCount === invitedIds.length ? `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt!` : `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt, andere fehlgeschlagen.`) : (sentCount === 0 ? 'Keine Einladungen versendet.' : sentCount === invitedIds.length ? `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet!` : `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet, andere fehlgeschlagen.`)} )}
)} {/* Grid */}
{unselectedUsers.length === 0 ? (
{allUsers.length === 0 ? directAdd ? 'Keine Benutzer verfügbar :(' : 'Niemand zum Einladen verfügbar :(' : 'Keine Benutzer gefunden.'}
) : ( {!isSuccess && paginatedUsers.map((user, idx) => ( ))} {isSuccess && invitedIds.map((id, idx) => { const user = knownUsers[id] if (!user) return null return ( ) })} )} {/* Overlay-Spinner: erscheint verzögert und bleibt min. SPINNER_MIN_MS sichtbar */} {spinnerVisible && (
)}
{/* Pagination */}
{!isFetching && !isSuccess && totalPages > 1 ? ( { const next = Math.max(1, Math.min(totalPages, p)) setCurrentPage(next) setTimeout(() => recalcUsersPerPage(), 0) }} /> ) : null}
) }