feat: add room creation and join flow UI
Add comprehensive room management UI: - CreateRoomModal: Modal for creating new multiplayer rooms - JoinRoomModal: Modal for joining rooms via code - JoinRoomInput: Reusable input component for room codes - PlayOnlineTab: Tab component for arcade lobby - RecentRoomsList: List of user's recent rooms - /join/[code] page: Direct join link page - E2E test for join flow Includes shareable room links, clipboard integration, and user-friendly error handling. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
cd3115aa6d
commit
7f95032253
|
|
@ -0,0 +1,296 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe('Join Room Flow', () => {
|
||||
test.describe('Room Creation', () => {
|
||||
test('should create a room from the game page', async ({ page }) => {
|
||||
// Navigate to a game
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Click the (+) Add Player button to open the popover
|
||||
const addPlayerButton = page.locator('button[title="Add player"]')
|
||||
await expect(addPlayerButton).toBeVisible()
|
||||
await addPlayerButton.click()
|
||||
|
||||
// Wait for popover to appear
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Click the "Play Online" or "Invite Players" tab
|
||||
const onlineTab = page.locator('button:has-text("Play Online"), button:has-text("Invite")')
|
||||
await expect(onlineTab.first()).toBeVisible()
|
||||
await onlineTab.first().click()
|
||||
|
||||
// Click "Create New Room" button
|
||||
const createRoomButton = page.locator('button:has-text("Create New Room")')
|
||||
await expect(createRoomButton).toBeVisible()
|
||||
await createRoomButton.click()
|
||||
|
||||
// Wait for room creation to complete
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Verify we're now in a room - should see room info in nav
|
||||
const roomInfo = page.locator('text=/Room|Code/i')
|
||||
await expect(roomInfo).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Join Room by Code', () => {
|
||||
let roomCode: string
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Create a room first
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const addPlayerButton = page.locator('button[title="Add player"]')
|
||||
await addPlayerButton.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const onlineTab = page.locator('button:has-text("Play Online"), button:has-text("Invite")')
|
||||
await onlineTab.first().click()
|
||||
|
||||
const createRoomButton = page.locator('button:has-text("Create New Room")')
|
||||
await createRoomButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Extract the room code from the page
|
||||
const roomCodeElement = page.locator('text=/[A-Z]{3}-[0-9]{3}/')
|
||||
await expect(roomCodeElement).toBeVisible({ timeout: 5000 })
|
||||
const roomCodeText = await roomCodeElement.textContent()
|
||||
roomCode = roomCodeText?.match(/[A-Z]{3}-[0-9]{3}/)?.[0] || ''
|
||||
expect(roomCode).toMatch(/[A-Z]{3}-[0-9]{3}/)
|
||||
})
|
||||
|
||||
test('should join room via direct URL', async ({ page, context }) => {
|
||||
// Open a new page (simulating a different user)
|
||||
const newPage = await context.newPage()
|
||||
|
||||
// Navigate to the join URL
|
||||
await newPage.goto(`/join/${roomCode}`)
|
||||
await newPage.waitForLoadState('networkidle')
|
||||
|
||||
// Should show "Joining room..." or redirect to game
|
||||
await newPage.waitForTimeout(1000)
|
||||
|
||||
// Should now be in the room
|
||||
const url = newPage.url()
|
||||
expect(url).toContain('/arcade')
|
||||
})
|
||||
|
||||
test('should show error for invalid room code', async ({ page, context }) => {
|
||||
const newPage = await context.newPage()
|
||||
|
||||
// Try to join with invalid code
|
||||
await newPage.goto('/join/INVALID')
|
||||
await newPage.waitForLoadState('networkidle')
|
||||
|
||||
// Should show error message
|
||||
const errorMessage = newPage.locator('text=/not found|failed/i')
|
||||
await expect(errorMessage).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('should show confirmation when switching rooms', async ({ page }) => {
|
||||
// User is already in a room from beforeEach
|
||||
|
||||
// Try to join a different room (we'll create another one)
|
||||
const addPlayerButton = page.locator('button[title="Add player"]')
|
||||
await addPlayerButton.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const onlineTab = page.locator('button:has-text("Play Online"), button:has-text("Invite")')
|
||||
await onlineTab.first().click()
|
||||
|
||||
const createRoomButton = page.locator('button:has-text("Create New Room")')
|
||||
await createRoomButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Get the new room code
|
||||
const newRoomCodeElement = page.locator('text=/[A-Z]{3}-[0-9]{3}/')
|
||||
await expect(newRoomCodeElement).toBeVisible({ timeout: 5000 })
|
||||
const newRoomCodeText = await newRoomCodeElement.textContent()
|
||||
const newRoomCode = newRoomCodeText?.match(/[A-Z]{3}-[0-9]{3}/)?.[0] || ''
|
||||
|
||||
// Navigate to join the new room
|
||||
await page.goto(`/join/${newRoomCode}`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Should show room switch confirmation
|
||||
const confirmationDialog = page.locator('text=/Switch Rooms?|already in another room/i')
|
||||
await expect(confirmationDialog).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Should show both room codes
|
||||
await expect(page.locator(`text=${roomCode}`)).toBeVisible()
|
||||
await expect(page.locator(`text=${newRoomCode}`)).toBeVisible()
|
||||
|
||||
// Click "Switch Rooms" button
|
||||
const switchButton = page.locator('button:has-text("Switch Rooms")')
|
||||
await expect(switchButton).toBeVisible()
|
||||
await switchButton.click()
|
||||
|
||||
// Should navigate to the new room
|
||||
await page.waitForTimeout(1000)
|
||||
const url = page.url()
|
||||
expect(url).toContain('/arcade')
|
||||
})
|
||||
|
||||
test('should stay in current room when canceling switch', async ({ page }) => {
|
||||
// User is already in a room from beforeEach
|
||||
const originalRoomCode = roomCode
|
||||
|
||||
// Create another room to try switching to
|
||||
const addPlayerButton = page.locator('button[title="Add player"]')
|
||||
await addPlayerButton.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const onlineTab = page.locator('button:has-text("Play Online"), button:has-text("Invite")')
|
||||
await onlineTab.first().click()
|
||||
|
||||
const createRoomButton = page.locator('button:has-text("Create New Room")')
|
||||
await createRoomButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
const newRoomCodeElement = page.locator('text=/[A-Z]{3}-[0-9]{3}/')
|
||||
const newRoomCodeText = await newRoomCodeElement.textContent()
|
||||
const newRoomCode = newRoomCodeText?.match(/[A-Z]{3}-[0-9]{3}/)?.[0] || ''
|
||||
|
||||
// Navigate to join the new room
|
||||
await page.goto(`/join/${newRoomCode}`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Should show confirmation
|
||||
const confirmationDialog = page.locator('text=/Switch Rooms?/i')
|
||||
await expect(confirmationDialog).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Click "Cancel"
|
||||
const cancelButton = page.locator('button:has-text("Cancel")')
|
||||
await expect(cancelButton).toBeVisible()
|
||||
await cancelButton.click()
|
||||
|
||||
// Should stay on original room
|
||||
await page.waitForTimeout(500)
|
||||
const url = page.url()
|
||||
expect(url).toContain('/arcade')
|
||||
|
||||
// Should still see original room code
|
||||
await expect(page.locator(`text=${originalRoomCode}`)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Join Room Input Validation', () => {
|
||||
test('should format room code as user types', async ({ page }) => {
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Open the add player popover
|
||||
const addPlayerButton = page.locator('button[title="Add player"]')
|
||||
await addPlayerButton.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Switch to Play Online tab
|
||||
const onlineTab = page.locator('button:has-text("Play Online")')
|
||||
if (await onlineTab.isVisible()) {
|
||||
await onlineTab.click()
|
||||
}
|
||||
|
||||
// Find the room code input
|
||||
const codeInput = page.locator('input[placeholder*="ABC"]')
|
||||
await expect(codeInput).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Type a room code
|
||||
await codeInput.fill('abc123')
|
||||
|
||||
// Should be formatted as ABC-123
|
||||
const inputValue = await codeInput.inputValue()
|
||||
expect(inputValue).toBe('ABC-123')
|
||||
})
|
||||
|
||||
test('should validate room code in real-time', async ({ page }) => {
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const addPlayerButton = page.locator('button[title="Add player"]')
|
||||
await addPlayerButton.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const onlineTab = page.locator('button:has-text("Play Online")')
|
||||
if (await onlineTab.isVisible()) {
|
||||
await onlineTab.click()
|
||||
}
|
||||
|
||||
const codeInput = page.locator('input[placeholder*="ABC"]')
|
||||
await expect(codeInput).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Type an invalid code
|
||||
await codeInput.fill('INVALID')
|
||||
|
||||
// Should show validation icon (❌)
|
||||
await page.waitForTimeout(500)
|
||||
const validationIcon = page.locator('text=/❌|Room not found/i')
|
||||
await expect(validationIcon).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Recent Rooms List', () => {
|
||||
test('should show recently joined rooms', async ({ page }) => {
|
||||
// Create and join a room
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const addPlayerButton = page.locator('button[title="Add player"]')
|
||||
await addPlayerButton.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const onlineTab = page.locator('button:has-text("Play Online"), button:has-text("Invite")')
|
||||
await onlineTab.first().click()
|
||||
|
||||
const createRoomButton = page.locator('button:has-text("Create New Room")')
|
||||
await createRoomButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Leave the room
|
||||
const leaveButton = page.locator('button:has-text("Leave"), button:has-text("Quit")')
|
||||
if (await leaveButton.isVisible()) {
|
||||
await leaveButton.click()
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
// Open the popover again
|
||||
await addPlayerButton.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
await onlineTab.first().click()
|
||||
|
||||
// Should see "Recent Rooms" section
|
||||
const recentRoomsSection = page.locator('text=/Recent Rooms/i')
|
||||
await expect(recentRoomsSection).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Should see at least one room in the list
|
||||
const roomListItem = page.locator('text=/[A-Z]{3}-[0-9]{3}/')
|
||||
await expect(roomListItem.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Room Ownership', () => {
|
||||
test('creator should see room controls', async ({ page }) => {
|
||||
// Create a room
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const addPlayerButton = page.locator('button[title="Add player"]')
|
||||
await addPlayerButton.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const onlineTab = page.locator('button:has-text("Play Online"), button:has-text("Invite")')
|
||||
await onlineTab.first().click()
|
||||
|
||||
const createRoomButton = page.locator('button:has-text("Create New Room")')
|
||||
await createRoomButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Creator should see room management controls
|
||||
// (e.g., leave room, room settings, etc.)
|
||||
const roomControls = page.locator('button:has-text("Leave"), button:has-text("Settings")')
|
||||
await expect(roomControls.first()).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useGetRoomByCode, useJoinRoom, useRoomData } from '@/hooks/useRoomData'
|
||||
|
||||
interface RoomSwitchConfirmationProps {
|
||||
currentRoom: { name: string; code: string }
|
||||
targetRoom: { name: string; code: string }
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
function RoomSwitchConfirmation({
|
||||
currentRoom,
|
||||
targetRoom,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: RoomSwitchConfirmationProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.7)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, rgba(17, 24, 39, 0.98), rgba(31, 41, 55, 0.98))',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '500px',
|
||||
width: '90%',
|
||||
border: '2px solid rgba(251, 146, 60, 0.3)',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: 'rgba(253, 186, 116, 1)',
|
||||
}}
|
||||
>
|
||||
Switch Rooms?
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'rgba(209, 213, 219, 0.8)',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
You are currently in another room. Would you like to switch?
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(251, 146, 60, 0.1)',
|
||||
border: '1px solid rgba(251, 146, 60, 0.3)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'rgba(209, 213, 219, 0.6)',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Current Room
|
||||
</div>
|
||||
<div style={{ color: 'rgba(253, 186, 116, 1)', fontWeight: '600' }}>
|
||||
{currentRoom.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'rgba(209, 213, 219, 0.7)',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
Code: {currentRoom.code}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
height: '1px',
|
||||
background: 'rgba(251, 146, 60, 0.2)',
|
||||
margin: '12px 0',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'rgba(209, 213, 219, 0.6)',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
New Room
|
||||
</div>
|
||||
<div style={{ color: 'rgba(134, 239, 172, 1)', fontWeight: '600' }}>
|
||||
{targetRoom.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'rgba(209, 213, 219, 0.7)',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
Code: {targetRoom.code}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: 'rgba(75, 85, 99, 0.3)',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
border: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(251, 146, 60, 0.8), rgba(249, 115, 22, 0.8))',
|
||||
color: 'rgba(255, 255, 255, 1)',
|
||||
border: '2px solid rgba(251, 146, 60, 0.6)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background =
|
||||
'linear-gradient(135deg, rgba(251, 146, 60, 0.9), rgba(249, 115, 22, 0.9))'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background =
|
||||
'linear-gradient(135deg, rgba(251, 146, 60, 0.8), rgba(249, 115, 22, 0.8))'
|
||||
}}
|
||||
>
|
||||
Switch Rooms
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
const router = useRouter()
|
||||
const { roomData } = useRoomData()
|
||||
const { mutateAsync: getRoomByCode } = useGetRoomByCode()
|
||||
const { mutateAsync: joinRoom } = useJoinRoom()
|
||||
const [targetRoomData, setTargetRoomData] = useState<{
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
} | null>(null)
|
||||
const [showConfirmation, setShowConfirmation] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isJoining, setIsJoining] = useState(false)
|
||||
const code = params.code.toUpperCase()
|
||||
|
||||
const handleJoin = useCallback(
|
||||
async (targetRoomId: string) => {
|
||||
setIsJoining(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await joinRoom({ roomId: targetRoomId, displayName: 'Player' })
|
||||
// Navigate to the game
|
||||
router.push('/arcade/room')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to join room')
|
||||
} finally {
|
||||
setIsJoining(false)
|
||||
}
|
||||
},
|
||||
[joinRoom, router]
|
||||
)
|
||||
|
||||
// Fetch target room data and handle join logic
|
||||
useEffect(() => {
|
||||
if (!code) return
|
||||
|
||||
let mounted = true
|
||||
|
||||
// Look up room by code
|
||||
getRoomByCode(code)
|
||||
.then((room) => {
|
||||
if (!mounted) return
|
||||
|
||||
setTargetRoomData({
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
code: room.code,
|
||||
})
|
||||
|
||||
// If user is already in this exact room, just navigate to game
|
||||
if (roomData && roomData.id === room.id) {
|
||||
router.push('/arcade/room')
|
||||
return
|
||||
}
|
||||
|
||||
// If user is in a different room, show confirmation
|
||||
if (roomData) {
|
||||
setShowConfirmation(true)
|
||||
} else {
|
||||
// Otherwise, auto-join
|
||||
handleJoin(room.id)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!mounted) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load room')
|
||||
})
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [code, roomData, handleJoin, router, getRoomByCode])
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (targetRoomData) {
|
||||
handleJoin(targetRoomData.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
router.push('/arcade/room') // Stay in current room
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '18px', color: '#ef4444' }}>{error}</div>
|
||||
<a
|
||||
href="/arcade"
|
||||
style={{
|
||||
color: '#3b82f6',
|
||||
textDecoration: 'underline',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
Go to Champion Arena
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isJoining || !targetRoomData) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
{isJoining ? 'Joining room...' : 'Loading...'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (showConfirmation && roomData) {
|
||||
return (
|
||||
<RoomSwitchConfirmation
|
||||
currentRoom={{ name: roomData.name, code: roomData.code }}
|
||||
targetRoom={{ name: targetRoomData.name, code: targetRoomData.code }}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
import { useState } from 'react'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
|
||||
export interface CreateRoomModalProps {
|
||||
/**
|
||||
* Whether the modal is open
|
||||
*/
|
||||
isOpen: boolean
|
||||
|
||||
/**
|
||||
* Callback when modal should close
|
||||
*/
|
||||
onClose: () => void
|
||||
|
||||
/**
|
||||
* Optional callback when room is successfully created
|
||||
*/
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for creating a new multiplayer room
|
||||
*/
|
||||
export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalProps) {
|
||||
const { createRoom } = useRoomData()
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleClose = () => {
|
||||
setError('')
|
||||
setIsLoading(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const name = formData.get('name') as string
|
||||
const gameName = formData.get('gameName') as string
|
||||
|
||||
if (!name || !gameName) {
|
||||
setError('Please fill in all fields')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Create the room (creator is auto-added as first member)
|
||||
await createRoom({
|
||||
name,
|
||||
gameName,
|
||||
creatorName: 'Player',
|
||||
gameConfig: { difficulty: 6 },
|
||||
})
|
||||
|
||||
// Success! Close modal
|
||||
handleClose()
|
||||
onSuccess?.()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create room')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose}>
|
||||
<div
|
||||
style={{
|
||||
border: '2px solid rgba(34, 197, 94, 0.3)',
|
||||
borderRadius: '16px',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: 'rgba(134, 239, 172, 1)',
|
||||
}}
|
||||
>
|
||||
Create New Room
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'rgba(209, 213, 219, 0.8)',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
You'll leave the current room and create a new one
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontWeight: '600',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Room Name
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="My Awesome Room"
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '15px',
|
||||
outline: 'none',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.6)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontWeight: '600',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Game
|
||||
</label>
|
||||
<select
|
||||
name="gameName"
|
||||
required
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '15px',
|
||||
outline: 'none',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.6)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
|
||||
}}
|
||||
>
|
||||
<option value="matching">Memory Matching</option>
|
||||
<option value="memory-quiz">Memory Quiz</option>
|
||||
<option value="complement-race">Complement Race</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'rgba(248, 113, 113, 1)',
|
||||
marginBottom: '16px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: 'rgba(75, 85, 99, 0.3)',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
border: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: isLoading
|
||||
? 'rgba(75, 85, 99, 0.3)'
|
||||
: 'linear-gradient(135deg, rgba(34, 197, 94, 0.8), rgba(22, 163, 74, 0.8))',
|
||||
color: 'rgba(255, 255, 255, 1)',
|
||||
border: isLoading
|
||||
? '2px solid rgba(75, 85, 99, 0.5)'
|
||||
: '2px solid rgba(34, 197, 94, 0.6)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background =
|
||||
'linear-gradient(135deg, rgba(34, 197, 94, 0.9), rgba(22, 163, 74, 0.9))'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background =
|
||||
'linear-gradient(135deg, rgba(34, 197, 94, 0.8), rgba(22, 163, 74, 0.8))'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Room'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
import React from 'react'
|
||||
|
||||
interface JoinRoomInputProps {
|
||||
onJoin: (code: string) => void
|
||||
}
|
||||
|
||||
type ValidationState = 'idle' | 'checking' | 'valid' | 'invalid'
|
||||
|
||||
export function JoinRoomInput({ onJoin }: JoinRoomInputProps) {
|
||||
const [code, setCode] = React.useState('')
|
||||
const [validationState, setValidationState] = React.useState<ValidationState>('idle')
|
||||
const [error, setError] = React.useState<string>('')
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
// Format code as user types: ABC123 → ABC-123
|
||||
const formatCode = (value: string): string => {
|
||||
const cleaned = value.toUpperCase().replace(/[^A-Z0-9]/g, '')
|
||||
if (cleaned.length <= 3) return cleaned
|
||||
return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 6)}`
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const formatted = formatCode(e.target.value)
|
||||
setCode(formatted)
|
||||
setError('')
|
||||
|
||||
// Reset validation when user types
|
||||
if (validationState !== 'idle') {
|
||||
setValidationState('idle')
|
||||
}
|
||||
|
||||
// Auto-validate when 6 characters entered
|
||||
const cleanCode = formatted.replace('-', '')
|
||||
if (cleanCode.length === 6) {
|
||||
validateCode(cleanCode)
|
||||
}
|
||||
}
|
||||
|
||||
const validateCode = async (codeToValidate: string) => {
|
||||
setValidationState('checking')
|
||||
|
||||
try {
|
||||
// Check if room exists via API
|
||||
const response = await fetch(`/api/arcade/rooms/code/${codeToValidate}`)
|
||||
|
||||
if (response.ok) {
|
||||
setValidationState('valid')
|
||||
} else {
|
||||
setValidationState('invalid')
|
||||
setError('Room not found')
|
||||
}
|
||||
} catch (err) {
|
||||
setValidationState('invalid')
|
||||
setError('Unable to validate code')
|
||||
}
|
||||
}
|
||||
|
||||
const handleJoin = () => {
|
||||
const cleanCode = code.replace('-', '')
|
||||
if (cleanCode.length === 6 && validationState === 'valid') {
|
||||
onJoin(cleanCode)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && validationState === 'valid') {
|
||||
handleJoin()
|
||||
}
|
||||
}
|
||||
|
||||
// Visual state colors
|
||||
const getBorderColor = () => {
|
||||
switch (validationState) {
|
||||
case 'valid':
|
||||
return '#10b981'
|
||||
case 'invalid':
|
||||
return '#ef4444'
|
||||
case 'checking':
|
||||
return '#3b82f6'
|
||||
default:
|
||||
return '#e5e7eb'
|
||||
}
|
||||
}
|
||||
|
||||
const getIcon = () => {
|
||||
switch (validationState) {
|
||||
case 'valid':
|
||||
return '✅'
|
||||
case 'invalid':
|
||||
return '❌'
|
||||
case 'checking':
|
||||
return '🔄'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={handleChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="ABC-123"
|
||||
maxLength={7} // ABC-123
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 40px 12px 12px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
letterSpacing: '2px',
|
||||
textAlign: 'center',
|
||||
border: `2px solid ${getBorderColor()}`,
|
||||
borderRadius: '8px',
|
||||
outline: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Validation Icon */}
|
||||
{validationState !== 'idle' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '12px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
fontSize: '18px',
|
||||
animation: validationState === 'checking' ? 'spin 1s linear infinite' : 'none',
|
||||
}}
|
||||
>
|
||||
{getIcon()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '6px',
|
||||
fontSize: '12px',
|
||||
color: '#ef4444',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Join button */}
|
||||
{validationState === 'valid' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleJoin}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: '8px',
|
||||
padding: '10px 16px',
|
||||
background: 'linear-gradient(135deg, #3b82f6, #2563eb)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 4px rgba(59, 130, 246, 0.3)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 8px rgba(59, 130, 246, 0.4)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = '0 2px 4px rgba(59, 130, 246, 0.3)'
|
||||
}}
|
||||
>
|
||||
🚀 Join Room
|
||||
</button>
|
||||
)}
|
||||
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes spin {
|
||||
from { transform: translateY(-50%) rotate(0deg); }
|
||||
to { transform: translateY(-50%) rotate(360deg); }
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import { useState } from 'react'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
|
||||
export interface JoinRoomModalProps {
|
||||
/**
|
||||
* Whether the modal is open
|
||||
*/
|
||||
isOpen: boolean
|
||||
|
||||
/**
|
||||
* Callback when modal should close
|
||||
*/
|
||||
onClose: () => void
|
||||
|
||||
/**
|
||||
* Optional callback when room is successfully joined
|
||||
*/
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for joining a room by entering a 6-character code
|
||||
*/
|
||||
export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps) {
|
||||
const { getRoomByCode, joinRoom } = useRoomData()
|
||||
const [code, setCode] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleClose = () => {
|
||||
setCode('')
|
||||
setError('')
|
||||
setIsLoading(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
const normalizedCode = code.trim().toUpperCase()
|
||||
if (normalizedCode.length !== 6) {
|
||||
setError('Code must be 6 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Look up room by code
|
||||
const room = await getRoomByCode(normalizedCode)
|
||||
|
||||
// Join the room
|
||||
await joinRoom(room.id)
|
||||
|
||||
// Success! Close modal
|
||||
handleClose()
|
||||
onSuccess?.()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to join room')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose}>
|
||||
<div
|
||||
style={{
|
||||
border: '2px solid rgba(139, 92, 246, 0.3)',
|
||||
borderRadius: '16px',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: 'rgba(196, 181, 253, 1)',
|
||||
}}
|
||||
>
|
||||
Join Room by Code
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'rgba(209, 213, 219, 0.8)',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
Enter the 6-character room code
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => {
|
||||
setCode(e.target.value.toUpperCase())
|
||||
setError('')
|
||||
}}
|
||||
placeholder="ABC123"
|
||||
maxLength={6}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px',
|
||||
border: error
|
||||
? '2px solid rgba(239, 68, 68, 0.6)'
|
||||
: '2px solid rgba(139, 92, 246, 0.4)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
fontFamily: 'monospace',
|
||||
textAlign: 'center',
|
||||
letterSpacing: '4px',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
color: 'rgba(196, 181, 253, 1)',
|
||||
outline: 'none',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'rgba(248, 113, 113, 1)',
|
||||
marginBottom: '16px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: 'rgba(75, 85, 99, 0.3)',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
border: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={code.trim().length !== 6 || isLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background:
|
||||
code.trim().length === 6 && !isLoading
|
||||
? 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
|
||||
: 'rgba(75, 85, 99, 0.3)',
|
||||
color:
|
||||
code.trim().length === 6 && !isLoading
|
||||
? 'rgba(255, 255, 255, 1)'
|
||||
: 'rgba(156, 163, 175, 1)',
|
||||
border:
|
||||
code.trim().length === 6 && !isLoading
|
||||
? '2px solid rgba(59, 130, 246, 0.6)'
|
||||
: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: code.trim().length === 6 && !isLoading ? 'pointer' : 'not-allowed',
|
||||
opacity: code.trim().length === 6 && !isLoading ? 1 : 0.5,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (code.trim().length === 6 && !isLoading) {
|
||||
e.currentTarget.style.background =
|
||||
'linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (code.trim().length === 6 && !isLoading) {
|
||||
e.currentTarget.style.background =
|
||||
'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading ? 'Joining...' : 'Join Room'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import React from 'react'
|
||||
import { JoinRoomInput } from './JoinRoomInput'
|
||||
import { RecentRoomsList } from './RecentRoomsList'
|
||||
|
||||
interface PlayOnlineTabProps {
|
||||
onCreateRoom: () => void
|
||||
onJoinRoom: (code: string) => void
|
||||
}
|
||||
|
||||
export function PlayOnlineTab({ onCreateRoom, onJoinRoom }: PlayOnlineTabProps) {
|
||||
const [isCreating, setIsCreating] = React.useState(false)
|
||||
|
||||
const handleCreateRoom = async () => {
|
||||
setIsCreating(true)
|
||||
try {
|
||||
await onCreateRoom()
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
{/* Quick Create Section */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: '#6b7280',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
🆕 Start Playing
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateRoom}
|
||||
disabled={isCreating}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
background: isCreating ? '#d1d5db' : 'linear-gradient(135deg, #10b981, #059669)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: isCreating ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isCreating) {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 8px rgba(16, 185, 129, 0.3)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
>
|
||||
{isCreating ? '⏳ Creating...' : '🚀 Create New Room'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div
|
||||
style={{
|
||||
height: '1px',
|
||||
background: 'linear-gradient(90deg, transparent, #e5e7eb, transparent)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Join Room Section */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: '#6b7280',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
🚪 Join Room
|
||||
</div>
|
||||
<JoinRoomInput onJoin={onJoinRoom} />
|
||||
</div>
|
||||
|
||||
{/* Recent Rooms Section */}
|
||||
<RecentRoomsList onSelectRoom={onJoinRoom} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
import React from 'react'
|
||||
|
||||
interface RecentRoom {
|
||||
code: string
|
||||
name: string
|
||||
gameName: string
|
||||
joinedAt: number
|
||||
}
|
||||
|
||||
interface RecentRoomsListProps {
|
||||
onSelectRoom: (code: string) => void
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'arcade_recent_rooms'
|
||||
const MAX_RECENT_ROOMS = 3
|
||||
|
||||
export function RecentRoomsList({ onSelectRoom }: RecentRoomsListProps) {
|
||||
const [recentRooms, setRecentRooms] = React.useState<RecentRoom[]>([])
|
||||
|
||||
// Load recent rooms from localStorage
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const rooms: RecentRoom[] = JSON.parse(stored)
|
||||
setRecentRooms(rooms.slice(0, MAX_RECENT_ROOMS))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load recent rooms:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const formatTimeAgo = (timestamp: number): string => {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||
|
||||
if (seconds < 60) return 'just now'
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`
|
||||
return `${Math.floor(seconds / 86400)} days ago`
|
||||
}
|
||||
|
||||
if (recentRooms.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: '#6b7280',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
⏱️ Recent Rooms
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{recentRooms.map((room) => (
|
||||
<button
|
||||
key={room.code}
|
||||
type="button"
|
||||
onClick={() => onSelectRoom(room.code)}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
background: 'transparent',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f9fafb'
|
||||
e.currentTarget.style.borderColor = '#d1d5db'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
marginBottom: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<span>🏟️</span>
|
||||
<span>{room.name}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: 'monospace', fontWeight: '600' }}>{room.code}</span>
|
||||
<span>·</span>
|
||||
<span>{formatTimeAgo(room.joinedAt)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to add a room to recent rooms
|
||||
export function addToRecentRooms(room: { code: string; name: string; gameName: string }): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
const rooms: RecentRoom[] = stored ? JSON.parse(stored) : []
|
||||
|
||||
// Remove if already exists (to update timestamp)
|
||||
const filtered = rooms.filter((r) => r.code !== room.code)
|
||||
|
||||
// Add to front
|
||||
const updated = [
|
||||
{
|
||||
...room,
|
||||
joinedAt: Date.now(),
|
||||
},
|
||||
...filtered,
|
||||
].slice(0, MAX_RECENT_ROOMS)
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated))
|
||||
} catch (err) {
|
||||
console.error('Failed to save recent room:', err)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue