ironie-nextjs/src/app/components/TeamInvitationBanner.tsx
2025-08-18 15:29:09 +02:00

211 lines
7.0 KiB
TypeScript

// TeamInvitationBanner.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Button from './Button'
import TeamPremierRankBadge from './TeamPremierRankBadge'
import type { Invitation } from '../types/invitation'
import type { Team } from '../types/team'
type Props = {
invitation: Invitation
notificationId: string
onAction: (action: 'accept' | 'reject', invitationId: string) => Promise<void>
onMarkAsRead: (id: string) => Promise<void>
}
export default function TeamInvitationBanner({
invitation,
notificationId,
onAction,
onMarkAsRead,
}: Props) {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState<'accept' | 'reject' | null>(null)
if (!invitation?.team) return null
const team: Team = invitation.team
const targetHref = `/team/${team.id}`
const active = team.activePlayers ?? []
const inactive = team.inactivePlayers ?? []
const badgePlayers = active.length ? active : inactive
const handleRespond = async (action: 'accept' | 'reject') => {
if (isSubmitting) return
try {
setIsSubmitting(action)
await onAction(action, invitation.id)
await onMarkAsRead(notificationId)
} catch (err) {
console.error(`[TeamInvitationView] ${action} failed:`, err)
} finally {
setIsSubmitting(null)
}
}
// Klassen als Ausdruck (keine mehrzeiligen String-Literals im JSX)
const cardClasses =
'relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 ' +
'dark:border-neutral-700 shadow-sm hover:shadow-md transition cursor-pointer ' +
'focus:outline-none ring-1 ring-green-500/15 hover:ring-green-500/25 mb-5';
return (
<div
role="button"
tabIndex={0}
onClick={() => router.push(targetHref)}
onKeyDown={(e) => e.key === 'Enter' && router.push(targetHref)}
className={cardClasses}
>
{/* animierter, dezenter grüner Gradient */}
<div aria-hidden className="absolute inset-0 z-0 pointer-events-none invitationGradient" />
{/* Inhalt */}
<div className="relative z-[1] p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<img
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
alt={team.name ?? 'Teamlogo'}
className="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-neutral-600"
/>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="font-medium truncate text-gray-800 dark:text-neutral-200">
{team.name ?? 'Team'}
</span>
{badgePlayers.length > 0 && <TeamPremierRankBadge players={badgePlayers} />}
</div>
<span className="text-xs text-gray-600 dark:text-neutral-400">
Du wurdest in dieses Team eingeladen.
</span>
</div>
{/* Teammitglieder */}
<div className="flex -space-x-3">
{[...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])].map((p) => (
<img
key={p.steamId}
src={p.avatar}
alt={p.name}
title={p.name}
className="w-12 h-12 rounded-full border-2 border-white dark:border-neutral-800 object-cover"
/>
))}
</div>
</div>
<div className="flex items-center gap-2">
<Button
title="Ablehnen"
size="sm"
color="red"
variant="ghost"
disabled={isSubmitting !== null}
onClick={(e) => {
e.stopPropagation()
handleRespond('reject')
}}
>
{isSubmitting === 'reject' ? (
<>
<span className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full mr-1" />
Ablehnen
</>
) : (
'Ablehnen'
)}
</Button>
<Button
title="Annehmen"
size="sm"
color="green"
variant="solid"
disabled={isSubmitting !== null}
onClick={(e) => {
e.stopPropagation()
handleRespond('accept')
}}
>
{isSubmitting === 'accept' ? (
<>
<span className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full mr-1" />
Annehmen
</>
) : (
'Annehmen'
)}
</Button>
</div>
</div>
</div>
<style jsx>{`
/* Hintergrund-Schimmer (läuft permanent) */
@keyframes slide-x {
from { background-position-x: 0%; }
to { background-position-x: 200%; }
}
.invitationGradient {
background-image: repeating-linear-gradient(
90deg,
rgba(16,168,54,0.20) 0%,
rgba(16,168,54,0.04) 50%,
rgba(16,168,54,0.20) 100%
);
background-size: 200% 100%;
background-repeat: repeat-x;
animation: slide-x 6s linear infinite; /* etwas ruhiger */
}
:global(.dark) .invitationGradient {
background-image: repeating-linear-gradient(
90deg,
rgba(16,168,54,0.28) 0%,
rgba(16,168,54,0.08) 50%,
rgba(16,168,54,0.28) 100%
);
}
/* Shine-Sweep nur auf Hover */
@keyframes shine {
0% { transform: translateX(-120%) skewX(-20deg); opacity: 0; }
10% { opacity: .7; }
27% { transform: translateX(120%) skewX(-20deg); opacity: 0; }
100% { transform: translateX(120%) skewX(-20deg); opacity: 0; }
}
.shine {
position: absolute;
inset: 0;
}
.shine::before {
content: "";
position: absolute;
top: -25%;
bottom: -25%;
left: -20%;
width: 35%;
pointer-events: none;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.35), transparent);
filter: blur(2px);
opacity: 0;
transform: translateX(-120%) skewX(-20deg);
transition: opacity .2s;
}
/* nur wenn die Karte offen ist und gehovert wird */
:global(.group:hover) .shine::before {
animation: shine 3.8s ease-out infinite;
}
/* Respektiere Bewegungs-Präferenzen */
@media (prefers-reduced-motion: reduce) {
.invitationGradient { animation: none; }
.shine::before { animation: none !important; transform: none !important; opacity: 0 !important; }
}
`}</style>
</div>
)
}