diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 0000000..5ee7abd
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1 @@
+pnpm exec lint-staged
diff --git a/.spec/phase5-games-view.md b/.spec/phase5-games-view.md
new file mode 100644
index 0000000..1e2244d
--- /dev/null
+++ b/.spec/phase5-games-view.md
@@ -0,0 +1,313 @@
+# 🎮 Phase 5 — Games View Wireframe Spec
+
+> **Goal:** Build the authenticated user's games dashboard with header, options menu, active games list, and game creation flow. Focus on wireframe structure with clay tablet styling.
+
+---
+
+## 🎯 Vision
+
+**Games dashboard for authenticated users:**
+
+1. **Header:** Logo left + Options menu right (user name, change name, how to play, clear data)
+2. **Create Game:** Simple button with 5 waiting games limit
+3. **Games List:** Cards showing opponent, status, turn indicator, actions
+4. **Empty State:** Centered create button when no games exist
+
+**Tech Stack:**
+
+- Existing clay tablet theme from Phase 4
+- Reusable Button component
+- API integration with existing endpoints
+- Mobile-first responsive design
+
+---
+
+## 📋 Implementation Plan
+
+### Step 1: Create Header Component
+
+**File:** `apps/web/components/games/Header.tsx`
+
+**Features:**
+
+- Logo on left ("GAME OF UR" text for now)
+- Options menu icon on right (hamburger/user icon)
+- Dropdown/modal with user info and actions
+- Clay tablet styling consistent with homepage
+
+**Options Menu Content:**
+
+- User display name at top
+- "Change Name" option
+- "How to Play" option (reuse existing component)
+- "Clear User Data" option (logout)
+
+### Step 2: Create Games List Components
+
+**Files:**
+
+- `apps/web/components/games/GamesList.tsx` - Main container
+- `apps/web/components/games/GameCard.tsx` - Individual game display
+
+**GamesList Features:**
+
+- Fetch user games using `api.getUserGames(secret)`
+- Loading and error states
+- Responsive grid layout
+
+**GameCard Features:**
+
+- Opponent name (player1 or player2, whoever isn't current user)
+- Game status badge (waiting, active, finished)
+- "Your turn" indicator if `currentTurn === userId`
+- "View Game" button (navigate to `/games/[id]`)
+- "Forfeit" button (call `api.forfeitGame()`)
+
+### Step 3: Create Game Creation Components
+
+**Files:**
+
+- `apps/web/components/games/CreateGameButton.tsx`
+- `apps/web/components/games/ShareGameModal.tsx`
+
+**CreateGameButton Features:**
+
+- Check current "waiting" games count
+- Limit: Maximum 5 games in "waiting" status
+- Show error if limit reached
+- On success: Show shareable link modal
+
+**ShareGameModal Features:**
+
+- Display game link (e.g., `https://yourdomain.com/games/[gameId]`)
+- Copy to clipboard functionality
+- Instructions for sharing
+
+### Step 4: Create Empty State
+
+**File:** `apps/web/components/games/EmptyState.tsx`
+
+**Features:**
+
+- Centered message: "No active games yet"
+- Large "Create New Game" button
+- Clay tablet styling
+
+### Step 5: Refactor Games Page
+
+**File:** `apps/web/app/games/page.tsx`
+
+**Layout Structure:**
+
+```
+┌─────────────────────────────────────┐
+│ Header (Logo + Options Menu) │
+├─────────────────────────────────────┤
+│ │
+│ Create New Game Button (top) │
+│ │
+│ ┌──────────────────────────────┐ │
+│ │ Game Card 1 │ │
+│ │ - Opponent: Player Name │ │
+│ │ - Status: Active │ │
+│ │ - Your Turn! │ │
+│ │ [View Game] [Forfeit] │ │
+│ └──────────────────────────────┘ │
+│ │
+│ ┌──────────────────────────────┐ │
+│ │ Game Card 2 (waiting) │ │
+│ └──────────────────────────────┘ │
+│ │
+└─────────────────────────────────────┘
+```
+
+**Features:**
+
+- User authentication check (redirect to `/` if no user)
+- Integrate all components
+- State management for games list
+- Refresh after create/forfeit actions
+
+---
+
+## 🔧 Technical Implementation
+
+### API Integration
+
+**Endpoints (already implemented):**
+
+- `GET /games` - Get user's games list
+- `POST /games` - Create new game
+- `POST /games/:id/forfeit` - Forfeit game
+
+**Response Structure:**
+
+```typescript
+GetGamesListResponse = Array<{
+ id: string
+ status: 'waiting' | 'active' | 'finished'
+ player1: { id: string; displayName: string }
+ player2: { id: string; displayName: string } | null
+ currentTurn: string | null
+ winnerId: string | null
+}>
+```
+
+### Business Logic
+
+**Waiting Games Limit:**
+
+- Count games where `status === 'waiting'`
+- If count >= 5, disable create button
+- Show tooltip: "Maximum 5 waiting games allowed"
+
+**Game Card Logic:**
+
+- Determine opponent: if `currentUserId === player1.id` then opponent is `player2`, else `player1`
+- Show "Waiting for opponent" if `player2 === null`
+- Show "Your turn" badge if `currentTurn === currentUserId`
+- Disable forfeit for finished games
+
+**Options Menu Actions:**
+
+- Change Name: Open modal with input, call `api.updateUser()`
+- How to Play: Reuse `HowToPlay` component from landing
+- Clear User Data: `localStorage.removeItem('urUser')`, redirect to `/`
+
+### State Management
+
+- Use React state (defer React Query for now)
+- Fetch games on mount
+- Refresh games list after create/forfeit
+- Handle loading and error states
+
+---
+
+## 🎨 Styling Guidelines
+
+### Clay Tablet Theme
+
+- Reuse existing clay tablet theme from Phase 4
+- Use existing Button component for actions
+- Use existing `.card` class for game cards
+- Apply noisy texture background to games page
+- Maintain amber/brown color palette
+
+### Mobile-First Design
+
+- Responsive grid layout for game cards
+- Touch-friendly button sizes
+- Proper spacing for mobile devices
+- Sticky header if needed
+
+### Component Styling
+
+- Header: Full width, fixed height
+- Game cards: Consistent card styling with clay theme
+- Buttons: Use existing Button component
+- Modals: Clay-themed overlays
+
+---
+
+## 📱 User Experience Flow
+
+### New User Journey
+
+1. User creates account on homepage
+2. Redirected to `/games` page
+3. Sees empty state with "Create New Game" button
+4. Clicks create → sees shareable link modal
+5. Shares link with friend
+
+### Existing User Journey
+
+1. User visits `/games` page
+2. Sees list of active games
+3. Can create new game (if under limit)
+4. Can view existing games
+5. Can forfeit games if needed
+
+### Game Creation Flow
+
+1. Click "Create New Game" button
+2. Check waiting games count
+3. If under limit: Create game via API
+4. Show shareable link modal
+5. User copies link to share
+6. Games list refreshes to show new game
+
+---
+
+## ✅ Testing Checklist
+
+### Component Testing
+
+- [ ] Header displays logo and opens options menu
+- [ ] Options menu shows user name and action buttons
+- [ ] Games list fetches and displays user games correctly
+- [ ] Game cards show opponent name and status
+- [ ] "Your turn" indicator appears correctly
+- [ ] Create game button creates game and shows share link
+- [ ] Waiting games limit (5) is enforced
+- [ ] Forfeit button works and updates list
+- [ ] Empty state shows when no games exist
+- [ ] Shareable link is copyable
+
+### User Flow Testing
+
+- [ ] Change name updates user display name
+- [ ] Clear user data logs out and redirects
+- [ ] Mobile responsive layout works
+- [ ] Error states are handled gracefully
+- [ ] Loading states show appropriately
+
+### API Integration Testing
+
+- [ ] Games list loads correctly
+- [ ] Game creation works
+- [ ] Game forfeit works
+- [ ] Error handling for API failures
+- [ ] Authentication works properly
+
+---
+
+## 🚫 Not Included (Future Phases)
+
+- Real-time game updates (polling/websockets)
+- Game board interface (Phase 6)
+- Turn timers and notifications
+- Game history/replay
+- Pagination for games list
+- Advanced game filtering/sorting
+
+---
+
+## ⏭️ Next Steps After This Phase
+
+**Phase 6: Game Board Interface**
+
+- Individual game view (`/games/[id]`)
+- 3D board rendering with React Three Fiber
+- Dice rolling and piece movement
+- Move validation and game state updates
+
+**Phase 7: Async Multiplayer**
+
+- Turn timeouts and notifications
+- Background polling for updates
+- Game state synchronization
+
+---
+
+## 📝 Implementation Tasks
+
+- [ ] Create Header component with logo and options menu
+- [ ] Create GamesList component that fetches and displays games
+- [ ] Create GameCard component for individual game display
+- [ ] Create CreateGameButton with 5 waiting games limit
+- [ ] Create ShareGameModal for displaying shareable link
+- [ ] Create EmptyState component for no games
+- [ ] Refactor /games page to integrate all components
+- [ ] Add user authentication check and redirect logic
+- [ ] Test complete games flow: create, list, forfeit, share
+- [ ] Update README with Phase 5 completion
diff --git a/README.md b/README.md
index 4a0283f..3fd4f24 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,8 @@ game-of-ur/
│ ├─ phase1-users.md
│ ├─ phase2-game-creation.md
│ ├─ phase3-game-rules.md
-│ └─ phase4-homepage.md
+│ ├─ phase4-homepage.md
+│ └─ phase5-games-view.md
│
├─ turbo.json
├─ docker-compose.yml # Full local stack (web + api + postgres)
@@ -153,8 +154,8 @@ Each spec describes _exactly_ what should be implemented before moving forward.
| **Phase 1** | Device identity and persistence (deviceSecret model) | ✅ Done |
| **Phase 2** | Game creation and joining logic | ✅ Done |
| **Phase 3** | Game rules, dice rolls, and move logic | ✅ Done |
-| **Phase 4** | Homepage redesign with 3D clay tablet aesthetic | ⏳ Next |
-| **Phase 5** | Games view wireframe and active games dashboard | 🔜 |
+| **Phase 4** | Homepage redesign with 3D clay tablet aesthetic | ✅ Done |
+| **Phase 5** | Games view wireframe and active games dashboard | ⏳ Next |
| **Phase 6** | Game board interface and 3D board rendering | 🔜 |
| **Phase 7** | Async multiplayer and turn timeouts | 🔜 |
| **Phase 8** | Polish, UX, and visual improvements | 🔜 |
diff --git a/apps/api/src/games/games.e2e.test.ts b/apps/api/src/games/games.e2e.test.ts
index 2a0f408..5976bbf 100644
--- a/apps/api/src/games/games.e2e.test.ts
+++ b/apps/api/src/games/games.e2e.test.ts
@@ -88,7 +88,7 @@ describe('Games E2E', () => {
expect(joinRes.body.currentTurn).toBe(player1Id)
})
- it('prevents unauthorized access to game', async () => {
+ it('allows access to game for joining purposes', async () => {
// Create third player
const player3Res = await request(app.getHttpServer())
.post('/users')
@@ -97,11 +97,11 @@ describe('Games E2E', () => {
const player3Secret = player3Res.body.secret
- // Try to access game without being part of it
+ // Try to access game without being part of it (should succeed for joining)
await request(app.getHttpServer())
.get(`/games/${gameId}`)
.set('Authorization', `Bearer ${player3Secret}`)
- .expect(400)
+ .expect(200)
})
})
diff --git a/apps/api/src/games/games.service.test.ts b/apps/api/src/games/games.service.test.ts
index 1b41b4e..893c567 100644
--- a/apps/api/src/games/games.service.test.ts
+++ b/apps/api/src/games/games.service.test.ts
@@ -212,15 +212,14 @@ describe('GamesService', () => {
)
})
- it('should throw BadRequestException if user is not part of the game', async () => {
+ it('should return game even if user is not part of the game', async () => {
const gameId = 'game-1'
const game = { ...mockGame, player1Id: 'user-3', player2Id: 'user-4' }
mockRepository.findOne.mockResolvedValue(game)
- await expect(service.getById(TEST_CORRELATION_ID, gameId, mockUser1.id)).rejects.toThrow(
- BadRequestException,
- )
+ const result = await service.getById(TEST_CORRELATION_ID, gameId, mockUser1.id)
+ expect(result).toEqual(game)
})
})
diff --git a/apps/api/src/games/games.service.ts b/apps/api/src/games/games.service.ts
index bd97698..6c65c12 100644
--- a/apps/api/src/games/games.service.ts
+++ b/apps/api/src/games/games.service.ts
@@ -154,11 +154,6 @@ export class GamesService {
throw new NotFoundException('Game not found')
}
- if (game.player1Id !== userId && game.player2Id !== userId) {
- this.logger.warn({ correlationId, gameId, userId }, 'User not authorized to view game')
- throw new BadRequestException('You are not part of this game')
- }
-
this.logger.debug({ correlationId, gameId, userId }, 'Game retrieved successfully')
return game
}
diff --git a/apps/web/app/games/[id]/page.tsx b/apps/web/app/games/[id]/page.tsx
index 4a8735e..0307bb0 100644
--- a/apps/web/app/games/[id]/page.tsx
+++ b/apps/web/app/games/[id]/page.tsx
@@ -1,5 +1,203 @@
-import GameView from 'components/GameView'
+'use client'
-export default function GamePage() {
- return
+import { GetGameResponse } from '@ur/shared'
+import { useParams, useRouter } from 'next/navigation'
+import { useEffect, useState } from 'react'
+
+import Button from '../../../components/ui/Button'
+import { api } from '../../../lib/api'
+
+export default function GameDetailPage() {
+ const [game, setGame] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const [isJoining, setIsJoining] = useState(false)
+ const [error, setError] = useState('')
+ const [user, setUser] = useState<{ id: string; secret: string } | null>(null)
+ const router = useRouter()
+ const params = useParams()
+ const gameId = params.id as string
+
+ useEffect(() => {
+ const checkUserAndLoadGame = async () => {
+ try {
+ // Check if user exists
+ const userData = localStorage.getItem('urUser')
+ if (!userData) {
+ router.push('/')
+ return
+ }
+
+ const parsedUser = JSON.parse(userData)
+ setUser(parsedUser)
+
+ // Load game
+ const gameData = await api.getGame(gameId, parsedUser.secret)
+ setGame(gameData)
+ } catch (error) {
+ console.error('Failed to load game:', error)
+ setError('Failed to load game. It may not exist or you may not have access.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ checkUserAndLoadGame()
+ }, [gameId, router])
+
+ const handleJoinGame = async () => {
+ if (!user || !game) return
+
+ setIsJoining(true)
+ try {
+ const joinedGame = await api.joinGame(gameId, user.secret)
+ setGame(joinedGame)
+
+ // After joining, redirect to the game board (which will be implemented in Phase 6)
+ // For now, redirect to games list
+ router.push('/games')
+ } catch (error) {
+ console.error('Failed to join game:', error)
+ setError('Failed to join game. Please try again.')
+ } finally {
+ setIsJoining(false)
+ }
+ }
+
+ const canJoinGame = () => {
+ if (!game || !user) return false
+ return game.status === 'waiting' && !game.player2 && game.player1.id !== user.id
+ }
+
+ // Early returns for loading and error states
+ if (isLoading) {
+ return (
+
+
+
+
Loading game...
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
❌
+
Game Not Found
+
{error}
+
+
+
+ )
+ }
+
+ if (!game) return null
+
+ // Unified game view - works for all scenarios
+ return (
+
+ Click to join and start playing the Royal Game of Ur!
+
+ >
+ ) : (
+ <>
+
+ {game.status === 'finished'
+ ? 'View the final board state below!'
+ : game.status === 'active'
+ ? 'The 3D game board will be implemented in Phase 6!'
+ : 'Game information and board view coming in Phase 6!'}
+
+
+ >
+ )}
+
+
+
+
+ )
}
diff --git a/apps/web/app/games/page.tsx b/apps/web/app/games/page.tsx
index 0419dde..52a7a5a 100644
--- a/apps/web/app/games/page.tsx
+++ b/apps/web/app/games/page.tsx
@@ -1,22 +1,82 @@
+'use client'
+
+import { useRouter } from 'next/navigation'
+import { useEffect, useState } from 'react'
+
+import GamesList from '../../components/games/GamesList'
+import Header from '../../components/games/Header'
+import { api } from '../../lib/api'
+
export default function GamesPage() {
- return (
-
-
-
Games Dashboard
-
- Welcome to your games dashboard! This will be built in Phase 5.
-