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:
185
apps/web/src/components/nav/InvitePlayersTab.tsx
Normal file
185
apps/web/src/components/nav/InvitePlayersTab.tsx
Normal 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
|
||||
}
|
||||
317
apps/web/src/components/nav/PendingInvitations.tsx
Normal file
317
apps/web/src/components/nav/PendingInvitations.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
apps/web/src/components/nav/RoomShareButtons.tsx
Normal file
46
apps/web/src/components/nav/RoomShareButtons.tsx
Normal 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!"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user