From 7f950322530e8deb2e330d0d2147d1a20fa1e642 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Mon, 13 Oct 2025 11:23:28 -0500 Subject: [PATCH] feat: add room creation and join flow UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/e2e/join-room-flow.spec.ts | 296 ++++++++++++++++ apps/web/src/app/join/[code]/page.tsx | 331 ++++++++++++++++++ .../src/components/nav/CreateRoomModal.tsx | 260 ++++++++++++++ apps/web/src/components/nav/JoinRoomInput.tsx | 199 +++++++++++ apps/web/src/components/nav/JoinRoomModal.tsx | 214 +++++++++++ apps/web/src/components/nav/PlayOnlineTab.tsx | 105 ++++++ .../src/components/nav/RecentRoomsList.tsx | 147 ++++++++ 7 files changed, 1552 insertions(+) create mode 100644 apps/web/e2e/join-room-flow.spec.ts create mode 100644 apps/web/src/app/join/[code]/page.tsx create mode 100644 apps/web/src/components/nav/CreateRoomModal.tsx create mode 100644 apps/web/src/components/nav/JoinRoomInput.tsx create mode 100644 apps/web/src/components/nav/JoinRoomModal.tsx create mode 100644 apps/web/src/components/nav/PlayOnlineTab.tsx create mode 100644 apps/web/src/components/nav/RecentRoomsList.tsx 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 ( +
+
{error}
+ + Go to Champion Arena + +
+ ) + } + + 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 +

+ +
+
+ + { + e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.6)' + }} + onBlur={(e) => { + e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)' + }} + /> +
+ +
+ + +
+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+
+
+ ) +} 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' && ( + + )} + +