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:
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user