feat: add invitation system UI components

Add invitation management UI:
- PendingInvitations: Display pending room invitations with accept/decline
- InvitePlayersTab: Tab for inviting players to rooms
- RoomShareButtons: Share room via link or code

Includes real-time invitation updates, auto-unban celebration UI,
and seamless accept & join flow.

🤖 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 11:23:43 -05:00
parent 7f95032253
commit fd3a2d1f76
3 changed files with 548 additions and 0 deletions

View File

@@ -0,0 +1,185 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCreateRoom, useRoomData } from '@/hooks/useRoomData'
import { RoomShareButtons } from './RoomShareButtons'
/**
* Tab content for inviting players to a room
*
* Behavior:
* - If user is already in a room: Shows share buttons immediately
* - If user is NOT in a room: Lazily creates a room on mount, then shows share buttons
* - Shows loading state during room creation
* - Shows error state if room creation fails
*/
export function InvitePlayersTab() {
const { roomData, isInRoom, getRoomShareUrl, isLoading: isRoomDataLoading } = useRoomData()
const { mutateAsync: createRoom, isPending: isCreating } = useCreateRoom()
const [error, setError] = useState<string | null>(null)
const hasAttemptedCreation = useRef(false)
// Lazy room creation: only create if not already in a room
const createQuickRoom = useCallback(async () => {
if (isRoomDataLoading || isInRoom || isCreating || hasAttemptedCreation.current) {
return // Already in a room, loading, creating, or already attempted
}
hasAttemptedCreation.current = true
setError(null)
try {
await createRoom({
name: 'Quick Room',
gameName: 'matching',
creatorName: 'Player',
gameConfig: { difficulty: 6, gameType: 'abacus-numeral', turnTimer: 30 },
})
// Room will be automatically updated in cache by the mutation's onSuccess
} catch (err) {
console.error('[InvitePlayersTab] Failed to create room:', err)
setError(err instanceof Error ? err.message : 'Failed to create room')
hasAttemptedCreation.current = false // Allow retry on error
}
}, [isRoomDataLoading, isInRoom, isCreating, createRoom])
// Auto-create room on mount if not in one
useEffect(() => {
if (!isRoomDataLoading && !isInRoom && !isCreating && !error && !hasAttemptedCreation.current) {
createQuickRoom()
}
}, [isRoomDataLoading, isInRoom, isCreating, error, createQuickRoom])
// Loading state - show loading only if we're truly loading and not in a room yet
if ((isRoomDataLoading || isCreating) && !isInRoom && !error) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '24px 16px',
gap: '12px',
}}
>
<div
style={{
width: '32px',
height: '32px',
border: '3px solid rgba(139, 92, 246, 0.3)',
borderTop: '3px solid rgba(139, 92, 246, 1)',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
}}
/>
<div
style={{
fontSize: '13px',
color: '#6b7280',
fontWeight: '500',
}}
>
Creating room...
</div>
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`,
}}
/>
</div>
)
}
// Error state
if (error) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '20px 16px',
gap: '12px',
}}
>
<div style={{ fontSize: '32px' }}></div>
<div
style={{
fontSize: '13px',
color: '#ef4444',
fontWeight: '500',
textAlign: 'center',
}}
>
{error}
</div>
<button
type="button"
onClick={() => {
setError(null)
createQuickRoom()
}}
style={{
padding: '8px 16px',
background: 'linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(139, 92, 246, 0.3))',
border: '2px solid rgba(139, 92, 246, 0.4)',
borderRadius: '8px',
color: '#8b5cf6',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(139, 92, 246, 0.3), rgba(139, 92, 246, 0.4))'
e.currentTarget.style.borderColor = 'rgba(139, 92, 246, 0.6)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(139, 92, 246, 0.3))'
e.currentTarget.style.borderColor = 'rgba(139, 92, 246, 0.4)'
}}
>
Try Again
</button>
</div>
)
}
// Success state: Show room share buttons
if (isInRoom && roomData) {
const shareUrl = getRoomShareUrl(roomData.code)
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
padding: '12px 8px 8px 8px',
gap: '8px',
}}
>
<div
style={{
fontSize: '11px',
fontWeight: '600',
color: '#6b7280',
textAlign: 'center',
marginBottom: '4px',
}}
>
Share to invite players
</div>
<RoomShareButtons joinCode={roomData.code} shareUrl={shareUrl} />
</div>
)
}
// Fallback (should not reach here)
return null
}

View File

@@ -0,0 +1,317 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useJoinRoom } from '@/hooks/useRoomData'
interface PendingInvitation {
id: string
roomId: string
roomName: string
roomGameName: string
invitedBy: string
invitedByName: string
invitationType: 'manual' | 'auto-unban' | 'auto-create'
message?: string | null
createdAt: Date
expiresAt?: Date | null
}
export interface PendingInvitationsProps {
/**
* Called when invitations change (for refreshing)
*/
onInvitationChange?: () => void
}
/**
* Displays a list of pending room invitations for the current user
*/
export function PendingInvitations({ onInvitationChange }: PendingInvitationsProps) {
const router = useRouter()
const [invitations, setInvitations] = useState<PendingInvitation[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
const [actionLoading, setActionLoading] = useState<string | null>(null)
const { mutateAsync: joinRoom } = useJoinRoom()
const fetchInvitations = async () => {
try {
const res = await fetch('/api/arcade/invitations/pending')
if (res.ok) {
const data = await res.json()
setInvitations(data.invitations || [])
} else {
throw new Error('Failed to fetch invitations')
}
} catch (err) {
console.error('Failed to fetch invitations:', err)
setError('Failed to load invitations')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
fetchInvitations()
}, [])
const handleAccept = async (invitation: PendingInvitation) => {
setActionLoading(`accept-${invitation.id}`)
try {
// Join the room
await joinRoom({ roomId: invitation.roomId })
// Navigate to the room
router.push('/arcade/room')
// Refresh invitations
await fetchInvitations()
onInvitationChange?.()
} catch (error) {
console.error('Failed to join room:', error)
alert(error instanceof Error ? error.message : 'Failed to join room')
} finally {
setActionLoading(null)
}
}
const handleDecline = async (invitation: PendingInvitation) => {
setActionLoading(`decline-${invitation.id}`)
try {
// Decline the invitation
const res = await fetch(`/api/arcade/rooms/${invitation.roomId}/invite`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
})
if (!res.ok) {
throw new Error('Failed to decline invitation')
}
// Refresh invitations
await fetchInvitations()
onInvitationChange?.()
} catch (error) {
console.error('Failed to decline invitation:', error)
alert(error instanceof Error ? error.message : 'Failed to decline invitation')
} finally {
setActionLoading(null)
}
}
if (isLoading) {
return (
<div
style={{
padding: '16px',
textAlign: 'center',
color: 'rgba(156, 163, 175, 1)',
fontSize: '14px',
}}
>
Loading invitations...
</div>
)
}
if (error) {
return (
<div
style={{
padding: '16px',
textAlign: 'center',
color: 'rgba(239, 68, 68, 1)',
fontSize: '14px',
}}
>
{error}
</div>
)
}
if (invitations.length === 0) {
return null // Don't show anything if no invitations
}
return (
<div
style={{
background: 'linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(59, 130, 246, 0.1))',
border: '2px solid rgba(139, 92, 246, 0.4)',
borderRadius: '16px',
padding: '20px',
marginBottom: '24px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '16px',
}}
>
<span style={{ fontSize: '24px' }}></span>
<h3
style={{
fontSize: '18px',
fontWeight: 'bold',
color: 'rgba(196, 181, 253, 1)',
margin: 0,
}}
>
Room Invitations ({invitations.length})
</h3>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{invitations.map((invitation) => {
const isAutoUnban = invitation.invitationType === 'auto-unban'
const timeSince = Math.floor(
(Date.now() - new Date(invitation.createdAt).getTime()) / 1000 / 60
)
const timeText =
timeSince < 1
? 'Just now'
: timeSince < 60
? `${timeSince}m ago`
: `${Math.floor(timeSince / 60)}h ago`
return (
<div
key={invitation.id}
style={{
background: 'rgba(0, 0, 0, 0.3)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '12px',
padding: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '16px',
}}
>
<div style={{ flex: 1 }}>
<div
style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}
>
{isAutoUnban && <span style={{ fontSize: '20px' }}>🎉</span>}
<h4
style={{
fontSize: '16px',
fontWeight: 'bold',
color: 'white',
margin: 0,
}}
>
{invitation.roomName}
</h4>
</div>
<p
style={{
fontSize: '13px',
color: 'rgba(209, 213, 219, 0.9)',
margin: '4px 0',
}}
>
<strong>{invitation.invitedByName}</strong> invited you {timeText}
</p>
{invitation.message && (
<p
style={{
fontSize: '12px',
color: 'rgba(156, 163, 175, 1)',
margin: '4px 0',
fontStyle: 'italic',
}}
>
"{invitation.message}"
</p>
)}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="button"
onClick={() => handleDecline(invitation)}
disabled={actionLoading === `decline-${invitation.id}`}
style={{
padding: '8px 16px',
background: 'rgba(75, 85, 99, 0.3)',
color: 'rgba(209, 213, 219, 1)',
border: '1px solid rgba(75, 85, 99, 0.5)',
borderRadius: '8px',
fontSize: '13px',
fontWeight: '600',
cursor:
actionLoading === `decline-${invitation.id}` ? 'not-allowed' : 'pointer',
opacity: actionLoading === `decline-${invitation.id}` ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (actionLoading !== `decline-${invitation.id}`) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
}
}}
onMouseLeave={(e) => {
if (actionLoading !== `decline-${invitation.id}`) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
}
}}
>
{actionLoading === `decline-${invitation.id}` ? 'Declining...' : 'Decline'}
</button>
<button
type="button"
onClick={() => handleAccept(invitation)}
disabled={
actionLoading === `accept-${invitation.id}` ||
actionLoading === `decline-${invitation.id}`
}
style={{
padding: '8px 16px',
background:
'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
color: 'white',
border: '1px solid rgba(59, 130, 246, 0.6)',
borderRadius: '8px',
fontSize: '13px',
fontWeight: '600',
cursor:
actionLoading === `accept-${invitation.id}` ||
actionLoading === `decline-${invitation.id}`
? 'not-allowed'
: 'pointer',
opacity:
actionLoading === `accept-${invitation.id}` ||
actionLoading === `decline-${invitation.id}`
? 0.5
: 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (
actionLoading !== `accept-${invitation.id}` &&
actionLoading !== `decline-${invitation.id}`
) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))'
}
}}
onMouseLeave={(e) => {
if (
actionLoading !== `accept-${invitation.id}` &&
actionLoading !== `decline-${invitation.id}`
) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
}
}}
>
{actionLoading === `accept-${invitation.id}` ? 'Joining...' : 'Accept & Join'}
</button>
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import { CopyButton } from '@/components/common/CopyButton'
export interface RoomShareButtonsProps {
/**
* The room join code (e.g., "ABC123")
*/
joinCode: string
/**
* The full shareable URL for the room
*/
shareUrl: string
}
/**
* Reusable component for sharing room join code and link
* Used in both RoomInfo dropdown and AddPlayerButton's Invite tab
*/
export function RoomShareButtons({ joinCode, shareUrl }: RoomShareButtonsProps) {
return (
<>
<CopyButton
text={joinCode}
variant="code"
label={
<>
<span>📋</span>
<span>{joinCode}</span>
</>
}
/>
<CopyButton
text={shareUrl}
variant="link"
label={
<>
<span>🔗</span>
<span>Share Link</span>
</>
}
copiedLabel="Link Copied!"
/>
</>
)
}