refactor: implement in-room game selection UI

Phase 2: UI and workflow updates

- Update room settings API to support setting game via PATCH
- Add useSetRoomGame hook for client-side game selection
- Update /arcade/room page to show game selection when no game set
- Create beautiful game selection UI with gradient cards
- Update AddPlayerButton to create rooms without games
- Navigate to /arcade/room after creating or joining rooms
- Remove dependency on local-only play - all games now room-based

Workflow:
1. User clicks "Create Room" from (+) menu
2. Room is created without a game (gameName = null)
3. User is navigated to /arcade/room
4. Game selection screen is shown
5. User clicks a game
6. Room game is set via API
7. Game loads - URL never changes, it's always /arcade/room

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-14 11:33:39 -05:00
parent a9a6cefafc
commit f07b96d26e
4 changed files with 186 additions and 7 deletions

View File

@@ -18,6 +18,8 @@ type RouteContext = {
* Body:
* - accessMode?: 'open' | 'locked' | 'retired' | 'password' | 'restricted' | 'approval-only'
* - password?: string (plain text, will be hashed)
* - gameName?: 'matching' | 'memory-quiz' | 'complement-race' | null (select game for room)
* - gameConfig?: object (game-specific settings)
*/
export async function PATCH(req: NextRequest, context: RouteContext) {
try {
@@ -58,6 +60,14 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
)
}
// Validate gameName if provided
if (body.gameName !== undefined && body.gameName !== null) {
const validGames = ['matching', 'memory-quiz', 'complement-race']
if (!validGames.includes(body.gameName)) {
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
}
}
// Prepare update data
const updateData: Record<string, any> = {}
@@ -77,6 +87,16 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
}
}
// Update game selection if provided
if (body.gameName !== undefined) {
updateData.gameName = body.gameName
}
// Update game config if provided
if (body.gameConfig !== undefined) {
updateData.gameConfig = body.gameConfig
}
// Update room settings
const [updatedRoom] = await db
.update(schema.arcadeRooms)

View File

@@ -1,13 +1,19 @@
'use client'
import { useRoomData } from '@/hooks/useRoomData'
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
import { GameSelector, GAMES_CONFIG } from '@/components/GameSelector'
import type { GameType } from '@/components/GameSelector'
import { css } from '../../../../styled-system/css'
/**
* /arcade/room - Renders the game for the user's current room
* Since users can only be in one room at a time, this is a simple singular route
*
* Shows game selection when no game is set, then shows the game itself once selected.
* URL never changes - it's always /arcade/room regardless of selection, setup, or gameplay.
*
* Note: We don't redirect to /arcade if no room exists to avoid navigation loops.
* Instead, we show a friendly message with a link back to the Champion Arena.
*
@@ -16,6 +22,7 @@ import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProv
*/
export default function RoomPage() {
const { roomData, isLoading } = useRoomData()
const { mutate: setRoomGame } = useSetRoomGame()
// Show loading state
if (isLoading) {
@@ -64,6 +71,110 @@ export default function RoomPage() {
)
}
// Show game selection if no game is set
if (!roomData.gameName) {
const handleGameSelect = (gameName: GameType) => {
const gameConfig = GAMES_CONFIG[gameName]
if (gameConfig.available === false) {
return // Don't allow selecting unavailable games
}
setRoomGame({
roomId: roomData.id,
gameName,
gameConfig: {},
})
}
return (
<div
className={css({
minHeight: '100vh',
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '4',
})}
>
<h1
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
color: 'white',
mb: '8',
textAlign: 'center',
})}
>
Choose a Game
</h1>
<div
className={css({
display: 'grid',
gridTemplateColumns: { base: '1fr', md: 'repeat(2, 1fr)' },
gap: '4',
maxWidth: '800px',
width: '100%',
})}
>
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => (
<button
key={gameType}
onClick={() => handleGameSelect(gameType as GameType)}
disabled={config.available === false}
className={css({
background: config.gradient,
border: '2px solid',
borderColor: config.borderColor || 'blue.200',
borderRadius: '2xl',
padding: '6',
cursor: config.available === false ? 'not-allowed' : 'pointer',
opacity: config.available === false ? 0.5 : 1,
transition: 'all 0.3s ease',
_hover:
config.available === false
? {}
: {
transform: 'translateY(-4px) scale(1.02)',
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
},
})}
>
<div
className={css({
fontSize: '4xl',
mb: '2',
})}
>
{config.icon}
</div>
<h3
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'gray.900',
mb: '2',
})}
>
{config.name}
</h3>
<p
className={css({
fontSize: 'sm',
color: 'gray.600',
})}
>
{config.description}
</p>
</button>
))}
</div>
</div>
)
}
// Render the appropriate game based on room's gameName
switch (roomData.gameName) {
case 'matching':

View File

@@ -62,12 +62,12 @@ export function AddPlayerButton({
const { mutate: joinRoom } = useJoinRoom()
const { mutateAsync: getRoomByCode } = useGetRoomByCode()
// Handler for creating a new room
// Handler for creating a new room (without a game - game will be selected in room)
const handleCreateRoom = () => {
createRoom(
{
name: `${gameName} Room`,
gameName: gameName,
name: null, // Auto-generated from code
gameName: null, // No game selected yet - will be chosen in room
creatorName: 'Player',
},
{
@@ -78,8 +78,9 @@ export function AddPlayerButton({
name: data.name,
gameName: data.gameName,
})
// Popover stays open, switch to invite tab to share room code
setActiveTab('invite')
// Close popover and navigate to room to choose game
setShowPopover(false)
router.push('/arcade/room')
},
onError: (error) => {
console.error('Failed to create room:', error)
@@ -108,8 +109,9 @@ export function AddPlayerButton({
gameName: data.room.gameName,
})
}
// Close popover
// Close popover and navigate to room
setShowPopover(false)
router.push('/arcade/room')
},
}
)

View File

@@ -558,3 +558,49 @@ export function useGetRoomByCode() {
mutationFn: getRoomByCodeApi,
})
}
/**
* Set game for a room
*/
async function setRoomGameApi(params: {
roomId: string
gameName: string
gameConfig?: Record<string, unknown>
}): Promise<void> {
const response = await fetch(`/api/arcade/rooms/${params.roomId}/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gameName: params.gameName,
gameConfig: params.gameConfig || {},
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to set room game')
}
}
/**
* Hook: Set game for a room
*/
export function useSetRoomGame() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: setRoomGameApi,
onSuccess: (_, variables) => {
// Update the cache with the new game
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
if (!prev) return null
return {
...prev,
gameName: variables.gameName,
}
})
// Refetch to get the full updated room data
queryClient.invalidateQueries({ queryKey: roomKeys.current() })
},
})
}