fix: move invitations into nav and filter out current/banned rooms

Improvements to invitation banner UX:
- Move PendingInvitations from arcade page into GameContextNav
- Now appears as part of the mini app nav (not underneath it)
- Filter out invitations for the room user is currently in
- Filter out invitations for rooms where user is banned
- Backend filters banned room invitations automatically

This resolves awkward situations where users see invitations for:
1. The room they're already in
2. Rooms they were kicked from or banned from

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-13 13:05:28 -05:00
parent 3e0b254df9
commit cfaf82b2cc
4 changed files with 200 additions and 184 deletions

View File

@@ -1,11 +1,12 @@
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { eq } from 'drizzle-orm'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/arcade/invitations/pending
* Get all pending invitations for the current user with room details
* Excludes invitations for rooms where the user is currently banned
*/
export async function GET(req: NextRequest) {
try {
@@ -33,8 +34,18 @@ export async function GET(req: NextRequest) {
.where(eq(schema.roomInvitations.userId, viewerId))
.orderBy(schema.roomInvitations.createdAt)
// Filter to only pending invitations
const pendingInvitations = invitations.filter((inv) => inv.status === 'pending')
// Get all active bans for this user
const activeBans = await db
.select({ roomId: schema.roomBans.roomId })
.from(schema.roomBans)
.where(and(eq(schema.roomBans.userId, viewerId), isNull(schema.roomBans.unbannedAt)))
const bannedRoomIds = new Set(activeBans.map((ban) => ban.roomId))
// Filter to only pending invitations, excluding banned rooms
const pendingInvitations = invitations.filter(
(inv) => inv.status === 'pending' && !bannedRoomIds.has(inv.roomId)
)
return NextResponse.json({ invitations: pendingInvitations }, { status: 200 })
} catch (error: any) {

View File

@@ -5,13 +5,10 @@ import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../styled-system/css'
import { EnhancedChampionArena } from '../../components/EnhancedChampionArena'
import { FullscreenProvider, useFullscreen } from '../../contexts/FullscreenContext'
import { PendingInvitations } from '@/components/nav/PendingInvitations'
import { useRoomData } from '@/hooks/useRoomData'
function ArcadeContent() {
const { setFullscreenElement } = useFullscreen()
const arcadeRef = useRef<HTMLDivElement>(null)
const { refetch: refetchRoomData } = useRoomData()
useEffect(() => {
// Register this component's main div as the fullscreen element
@@ -50,17 +47,6 @@ function ArcadeContent() {
})}
/>
{/* Pending Invitations */}
<div
className={css({
px: { base: '4', md: '6' },
position: 'relative',
zIndex: 1,
})}
>
<PendingInvitations onInvitationChange={() => refetchRoomData()} />
</div>
{/* Main Champion Arena - takes remaining space */}
<div
className={css({

View File

@@ -1,12 +1,13 @@
import { useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import { ActivePlayersList } from './ActivePlayersList'
import { AddPlayerButton } from './AddPlayerButton'
import { FullscreenPlayerSelection } from './FullscreenPlayerSelection'
import { GameModeIndicator } from './GameModeIndicator'
import { GameTitleMenu } from './GameTitleMenu'
import { NetworkPlayerIndicator } from './NetworkPlayerIndicator'
import { PendingInvitations } from './PendingInvitations'
import { RoomInfo } from './RoomInfo'
import { useViewerId } from '@/hooks/useViewerId'
import { useRoomData } from '@/hooks/useRoomData'
type GameMode = 'none' | 'single' | 'battle' | 'tournament'
@@ -88,7 +89,7 @@ export function GameContextNav({
}: GameContextNavProps) {
// Get current user info for moderation
const { data: currentUserId } = useViewerId()
const { roomData } = useRoomData()
const { roomData, refetch: refetchRoomData } = useRoomData()
// Check if current user is the host
const currentMember = roomData?.members.find((m) => m.userId === currentUserId)
@@ -169,171 +170,178 @@ export function GameContextNav({
const showPlayers = activePlayers.length > 0 || (shouldEmphasize && inactivePlayers.length > 0)
return (
<div
style={{
display: 'flex',
gap: '20px',
alignItems: 'center',
width: 'auto',
}}
>
{/* Game Title Section - Always mounted, hidden when in room */}
<>
{/* Pending Invitations Banner - Shows above nav when user has invitations */}
<PendingInvitations
currentRoomId={roomInfo?.roomId}
onInvitationChange={() => refetchRoomData()}
/>
<div
style={{
display: roomInfo ? 'none' : 'flex',
display: 'flex',
gap: '20px',
alignItems: 'center',
gap: '12px',
flex: 1,
width: 'auto',
}}
>
<GameTitleMenu
navTitle={navTitle}
navEmoji={navEmoji}
onSetup={onSetup}
onNewGame={onNewGame}
onQuit={onExitSession}
/>
<div style={{ marginLeft: 'auto' }}>
<GameModeIndicator
gameMode={gameMode}
{/* Game Title Section - Always mounted, hidden when in room */}
<div
style={{
display: roomInfo ? 'none' : 'flex',
alignItems: 'center',
gap: '12px',
flex: 1,
}}
>
<GameTitleMenu
navTitle={navTitle}
navEmoji={navEmoji}
onSetup={onSetup}
onNewGame={onNewGame}
onQuit={onExitSession}
/>
<div style={{ marginLeft: 'auto' }}>
<GameModeIndicator
gameMode={gameMode}
shouldEmphasize={shouldEmphasize}
showFullscreenSelection={false}
/>
</div>
</div>
{/* Room Info Section - Always mounted, hidden when not in room */}
<div
style={{
display: roomInfo ? 'flex' : 'none',
alignItems: 'flex-end',
gap: '12px',
padding: '6px 12px 12px 12px',
background:
'linear-gradient(135deg, rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0.05))',
borderRadius: '12px',
border: '2px solid rgba(255, 255, 255, 0.15)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
transition: 'all 0.3s ease',
}}
>
<RoomInfo
roomId={roomInfo?.roomId}
roomName={roomInfo?.roomName}
gameName={roomInfo?.gameName ?? ''}
playerCount={roomInfo?.playerCount ?? 0}
joinCode={roomInfo?.joinCode}
shouldEmphasize={shouldEmphasize}
showFullscreenSelection={false}
gameMode={gameMode}
modeColor={
gameMode === 'battle'
? '#8b5cf6'
: gameMode === 'single'
? '#3b82f6'
: gameMode === 'tournament'
? '#f59e0b'
: '#6b7280'
}
modeEmoji={
gameMode === 'battle'
? '⚔️'
: gameMode === 'single'
? '🎯'
: gameMode === 'tournament'
? '🏆'
: '👥'
}
modeLabel={
gameMode === 'battle'
? 'Battle'
: gameMode === 'single'
? 'Solo'
: gameMode === 'tournament'
? 'Tournament'
: 'Select Players'
}
navTitle={navTitle}
navEmoji={navEmoji}
onSetup={onSetup}
onNewGame={onNewGame}
onQuit={onExitSession}
/>
{/* Network Players - inside same pane as room info */}
{networkPlayers.length > 0 && (
<>
<div
style={{
width: '1px',
height: '48px',
background: 'rgba(255, 255, 255, 0.2)',
margin: '0 4px',
}}
/>
{networkPlayers.map((player) => (
<NetworkPlayerIndicator
key={player.id}
player={player}
shouldEmphasize={shouldEmphasize}
currentPlayerId={currentPlayerId}
playerScores={playerScores}
playerStreaks={playerStreaks}
roomId={roomInfo?.roomId}
currentUserId={currentUserId}
isCurrentUserHost={isCurrentUserHost}
/>
))}
</>
)}
</div>
{/* Player Section - Always mounted, hidden when no players */}
<div
style={{
display: showPlayers ? 'flex' : 'none',
alignItems: 'flex-end',
gap: shouldEmphasize ? '12px' : '8px',
padding: shouldEmphasize ? '12px 20px 16px 20px' : '6px 12px 12px 12px',
background: shouldEmphasize
? 'linear-gradient(135deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.10))'
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0.05))',
borderRadius: shouldEmphasize ? '16px' : '12px',
border: shouldEmphasize
? '3px solid rgba(255, 255, 255, 0.3)'
: '2px solid rgba(255, 255, 255, 0.15)',
boxShadow: shouldEmphasize
? '0 8px 24px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255,255,255,0.3)'
: '0 4px 12px rgba(0, 0, 0, 0.1)',
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
transform: shouldEmphasize ? 'scale(1.05)' : 'scale(1)',
}}
>
<ActivePlayersList
activePlayers={activePlayers}
shouldEmphasize={shouldEmphasize}
onRemovePlayer={onRemovePlayer}
onConfigurePlayer={onConfigurePlayer}
currentPlayerId={currentPlayerId}
playerScores={playerScores}
playerStreaks={playerStreaks}
/>
<AddPlayerButton
inactivePlayers={inactivePlayers}
shouldEmphasize={shouldEmphasize}
onAddPlayer={onAddPlayer}
showPopover={showPopover}
setShowPopover={setShowPopover}
activeTab={activeTab}
setActiveTab={setActiveTab}
isInRoom={!!roomInfo}
gameName={gameName || 'matching'}
/>
</div>
</div>
{/* Room Info Section - Always mounted, hidden when not in room */}
<div
style={{
display: roomInfo ? 'flex' : 'none',
alignItems: 'flex-end',
gap: '12px',
padding: '6px 12px 12px 12px',
background:
'linear-gradient(135deg, rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0.05))',
borderRadius: '12px',
border: '2px solid rgba(255, 255, 255, 0.15)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
transition: 'all 0.3s ease',
}}
>
<RoomInfo
roomId={roomInfo?.roomId}
roomName={roomInfo?.roomName}
gameName={roomInfo?.gameName ?? ''}
playerCount={roomInfo?.playerCount ?? 0}
joinCode={roomInfo?.joinCode}
shouldEmphasize={shouldEmphasize}
gameMode={gameMode}
modeColor={
gameMode === 'battle'
? '#8b5cf6'
: gameMode === 'single'
? '#3b82f6'
: gameMode === 'tournament'
? '#f59e0b'
: '#6b7280'
}
modeEmoji={
gameMode === 'battle'
? '⚔️'
: gameMode === 'single'
? '🎯'
: gameMode === 'tournament'
? '🏆'
: '👥'
}
modeLabel={
gameMode === 'battle'
? 'Battle'
: gameMode === 'single'
? 'Solo'
: gameMode === 'tournament'
? 'Tournament'
: 'Select Players'
}
navTitle={navTitle}
navEmoji={navEmoji}
onSetup={onSetup}
onNewGame={onNewGame}
onQuit={onExitSession}
/>
{/* Network Players - inside same pane as room info */}
{networkPlayers.length > 0 && (
<>
<div
style={{
width: '1px',
height: '48px',
background: 'rgba(255, 255, 255, 0.2)',
margin: '0 4px',
}}
/>
{networkPlayers.map((player) => (
<NetworkPlayerIndicator
key={player.id}
player={player}
shouldEmphasize={shouldEmphasize}
currentPlayerId={currentPlayerId}
playerScores={playerScores}
playerStreaks={playerStreaks}
roomId={roomInfo?.roomId}
currentUserId={currentUserId}
isCurrentUserHost={isCurrentUserHost}
/>
))}
</>
)}
</div>
{/* Player Section - Always mounted, hidden when no players */}
<div
style={{
display: showPlayers ? 'flex' : 'none',
alignItems: 'flex-end',
gap: shouldEmphasize ? '12px' : '8px',
padding: shouldEmphasize ? '12px 20px 16px 20px' : '6px 12px 12px 12px',
background: shouldEmphasize
? 'linear-gradient(135deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.10))'
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0.05))',
borderRadius: shouldEmphasize ? '16px' : '12px',
border: shouldEmphasize
? '3px solid rgba(255, 255, 255, 0.3)'
: '2px solid rgba(255, 255, 255, 0.15)',
boxShadow: shouldEmphasize
? '0 8px 24px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255,255,255,0.3)'
: '0 4px 12px rgba(0, 0, 0, 0.1)',
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
transform: shouldEmphasize ? 'scale(1.05)' : 'scale(1)',
}}
>
<ActivePlayersList
activePlayers={activePlayers}
shouldEmphasize={shouldEmphasize}
onRemovePlayer={onRemovePlayer}
onConfigurePlayer={onConfigurePlayer}
currentPlayerId={currentPlayerId}
playerScores={playerScores}
playerStreaks={playerStreaks}
/>
<AddPlayerButton
inactivePlayers={inactivePlayers}
shouldEmphasize={shouldEmphasize}
onAddPlayer={onAddPlayer}
showPopover={showPopover}
setShowPopover={setShowPopover}
activeTab={activeTab}
setActiveTab={setActiveTab}
isInRoom={!!roomInfo}
gameName={gameName || 'matching'}
/>
</div>
<style
dangerouslySetInnerHTML={{
__html: `
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes fadeIn {
from {
opacity: 0;
@@ -355,8 +363,9 @@ export function GameContextNav({
}
}
`,
}}
/>
</div>
}}
/>
</div>
</>
)
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useJoinRoom } from '@/hooks/useRoomData'
interface PendingInvitation {
@@ -20,12 +20,17 @@ export interface PendingInvitationsProps {
* Called when invitations change (for refreshing)
*/
onInvitationChange?: () => void
/**
* Optional: Room ID to exclude (if user is already in this room)
*/
currentRoomId?: string
}
/**
* Displays a list of pending room invitations for the current user
* Excludes invitations for the room the user is currently in
*/
export function PendingInvitations({ onInvitationChange }: PendingInvitationsProps) {
export function PendingInvitations({ onInvitationChange, currentRoomId }: PendingInvitationsProps) {
const router = useRouter()
const [invitations, setInvitations] = useState<PendingInvitation[]>([])
const [isLoading, setIsLoading] = useState(true)
@@ -126,7 +131,12 @@ export function PendingInvitations({ onInvitationChange }: PendingInvitationsPro
)
}
if (invitations.length === 0) {
// Filter out invitations for the current room
const filteredInvitations = currentRoomId
? invitations.filter((inv) => inv.roomId !== currentRoomId)
: invitations
if (filteredInvitations.length === 0) {
return null // Don't show anything if no invitations
}
@@ -157,12 +167,12 @@ export function PendingInvitations({ onInvitationChange }: PendingInvitationsPro
margin: 0,
}}
>
Room Invitations ({invitations.length})
Room Invitations ({filteredInvitations.length})
</h3>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{invitations.map((invitation) => {
{filteredInvitations.map((invitation) => {
const isAutoUnban = invitation.invitationType === 'auto-unban'
const timeSince = Math.floor(
(Date.now() - new Date(invitation.createdAt).getTime()) / 1000 / 60