diff --git a/apps/web/e2e/join-room-flow.spec.ts b/apps/web/e2e/join-room-flow.spec.ts
new file mode 100644
index 00000000..fbb2719d
--- /dev/null
+++ b/apps/web/e2e/join-room-flow.spec.ts
@@ -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 })
+ })
+ })
+})
diff --git a/apps/web/src/app/join/[code]/page.tsx b/apps/web/src/app/join/[code]/page.tsx
new file mode 100644
index 00000000..b6602e80
--- /dev/null
+++ b/apps/web/src/app/join/[code]/page.tsx
@@ -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 (
+
+
+
+ Switch Rooms?
+
+
+ You are currently in another room. Would you like to switch?
+
+
+
+
+
+ Current Room
+
+
+ {currentRoom.name}
+
+
+ Code: {currentRoom.code}
+
+
+
+
+
+
+
+ New Room
+
+
+ {targetRoom.name}
+
+
+ Code: {targetRoom.code}
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+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(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 (
+
+ )
+ }
+
+ if (isJoining || !targetRoomData) {
+ return (
+
+ {isJoining ? 'Joining room...' : 'Loading...'}
+
+ )
+ }
+
+ if (showConfirmation && roomData) {
+ return (
+
+ )
+ }
+
+ return null
+}
diff --git a/apps/web/src/components/nav/CreateRoomModal.tsx b/apps/web/src/components/nav/CreateRoomModal.tsx
new file mode 100644
index 00000000..936a688e
--- /dev/null
+++ b/apps/web/src/components/nav/CreateRoomModal.tsx
@@ -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) => {
+ 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 (
+
+
+
+ Create New Room
+
+
+ You'll leave the current room and create a new one
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/nav/JoinRoomInput.tsx b/apps/web/src/components/nav/JoinRoomInput.tsx
new file mode 100644
index 00000000..5b5d8249
--- /dev/null
+++ b/apps/web/src/components/nav/JoinRoomInput.tsx
@@ -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('idle')
+ const [error, setError] = React.useState('')
+ const inputRef = React.useRef(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) => {
+ 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 (
+
+
+
+
+ {/* Validation Icon */}
+ {validationState !== 'idle' && (
+
+ {getIcon()}
+
+ )}
+
+
+ {/* Error message */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Join button */}
+ {validationState === 'valid' && (
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/nav/JoinRoomModal.tsx b/apps/web/src/components/nav/JoinRoomModal.tsx
new file mode 100644
index 00000000..2c72ccb8
--- /dev/null
+++ b/apps/web/src/components/nav/JoinRoomModal.tsx
@@ -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 (
+
+
+
+ Join Room by Code
+
+
+ Enter the 6-character room code
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/nav/PlayOnlineTab.tsx b/apps/web/src/components/nav/PlayOnlineTab.tsx
new file mode 100644
index 00000000..a3cd8189
--- /dev/null
+++ b/apps/web/src/components/nav/PlayOnlineTab.tsx
@@ -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 (
+
+ {/* Quick Create Section */}
+
+
+ 🆕 Start Playing
+
+
+
+
+ {/* Divider */}
+
+
+ {/* Join Room Section */}
+
+
+ {/* Recent Rooms Section */}
+
+
+ )
+}
diff --git a/apps/web/src/components/nav/RecentRoomsList.tsx b/apps/web/src/components/nav/RecentRoomsList.tsx
new file mode 100644
index 00000000..a05ef323
--- /dev/null
+++ b/apps/web/src/components/nav/RecentRoomsList.tsx
@@ -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([])
+
+ // 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 (
+
+
+ ⏱️ Recent Rooms
+
+
+
+ {recentRooms.map((room) => (
+
+ ))}
+
+
+ )
+}
+
+// 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)
+ }
+}