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 ( +
+
+
+

+ {game.status === 'finished' + ? 'Game Finished' + : game.status === 'active' + ? 'Game Active' + : canJoinGame() + ? 'Game Invitation' + : 'Game View'} +

+ +
+

+ {canJoinGame() ? ( + <> + {game.player1.displayName} has invited you to play! + + ) : ( + <> + {game.player1.displayName} vs{' '} + {game.player2?.displayName || 'Waiting for opponent...'} + + )} +

+ +
+ + {game.status === 'finished' + ? 'Game Finished' + : game.status === 'active' + ? 'Game Active' + : 'Waiting for Player'} + + + {game.status === 'active' && game.currentTurn && ( + + {game.currentTurn === user?.id + ? 'Your Turn!' + : game.currentTurn === game.player1.id + ? `${game.player1.displayName}'s Turn` + : `${game.player2?.displayName}'s Turn`} + + )} + + {game.status === 'finished' && game.winnerId && ( + + {game.winnerId === user?.id + ? 'You Won!' + : game.winnerId === game.player1.id + ? `${game.player1.displayName} Won!` + : `${game.player2?.displayName} Won!`} + + )} +
+
+ +
+ {canJoinGame() ? ( + <> + +

+ 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. -

-
-

Coming Soon

-
    -
  • • Active games list
  • -
  • • Create new game
  • -
  • • Join existing game
  • -
  • • Game status tracking
  • -
  • • Header with user options
  • -
+ const [user, setUser] = useState<{ id: string; displayName: string; secret: string } | null>(null) + const [isLoading, setIsLoading] = useState(true) + const router = useRouter() + + useEffect(() => { + // Check if user exists + const checkUser = () => { + try { + const userData = localStorage.getItem('urUser') + if (!userData) { + router.push('/') + return + } + + const parsedUser = JSON.parse(userData) + setUser(parsedUser) + } catch (error) { + console.error('Error parsing user data:', error) + localStorage.removeItem('urUser') + router.push('/') + } finally { + setIsLoading(false) + } + } + + checkUser() + }, [router]) + + const handleUpdateName = async (newName: string) => { + if (!user) return + + try { + const updatedUser = await api.updateUser(user.id, user.secret, { + displayName: newName, + }) + + // Update local storage + const updatedUserData = { + ...user, + displayName: updatedUser.displayName, + } + localStorage.setItem('urUser', JSON.stringify(updatedUserData)) + setUser(updatedUserData) + } catch (error) { + console.error('Failed to update name:', error) + throw error + } + } + + if (isLoading) { + return ( +
+
+
+

Loading...

+ ) + } + + if (!user) { + return null // Will redirect to homepage + } + + return ( +
+
+ +
) } diff --git a/apps/web/components/games/CreateGameButton.test.tsx b/apps/web/components/games/CreateGameButton.test.tsx new file mode 100644 index 0000000..a8ccb3b --- /dev/null +++ b/apps/web/components/games/CreateGameButton.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import CreateGameButton from './CreateGameButton' + +describe('CreateGameButton', () => { + const mockOnCreateGame = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders create game button', () => { + render() + + expect(screen.getByText('Create New Game')).toBeInTheDocument() + }) + + it('calls onCreateGame when clicked', () => { + render() + + const createButton = screen.getByText('Create New Game') + fireEvent.click(createButton) + + expect(mockOnCreateGame).toHaveBeenCalledTimes(1) + }) + + it('shows loading state while creating game', () => { + render() + + expect(screen.getByText('Creating Game...')).toBeInTheDocument() + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('disables button when at waiting games limit', () => { + const waitingGames = Array(5) + .fill(null) + .map((_, i) => ({ + id: `game-${i}`, + status: 'waiting' as const, + })) + + render( + , + ) + + const createButton = screen.getByText('Create New Game (Limit Reached)') + expect(createButton).toBeDisabled() + expect( + screen.getByText( + 'You can have a maximum of 5 waiting games. Please wait for opponents to join existing games.', + ), + ).toBeInTheDocument() + }) +}) diff --git a/apps/web/components/games/CreateGameButton.tsx b/apps/web/components/games/CreateGameButton.tsx new file mode 100644 index 0000000..1456522 --- /dev/null +++ b/apps/web/components/games/CreateGameButton.tsx @@ -0,0 +1,51 @@ +'use client' + +import { GetGamesListResponse } from '@ur/shared' +import React from 'react' + +import Button from '../ui/Button' + +interface CreateGameButtonProps { + games: GetGamesListResponse + onCreateGame: () => void + isCreating: boolean +} + +export default function CreateGameButton({ + games, + onCreateGame, + isCreating, +}: CreateGameButtonProps) { + // Count waiting games + const waitingGamesCount = games.filter((game) => game.status === 'waiting').length + const canCreateGame = waitingGamesCount < 5 + + const handleCreateGame = () => { + if (!canCreateGame) return + onCreateGame() + } + + if (!canCreateGame) { + return ( +
+ +

+ You can have a maximum of 5 waiting games. Please wait for opponents to join existing + games. +

+
+ ) + } + + return ( +
+ + +

Waiting games: {waitingGamesCount}/5

+
+ ) +} diff --git a/apps/web/components/games/EmptyState.tsx b/apps/web/components/games/EmptyState.tsx new file mode 100644 index 0000000..62da54e --- /dev/null +++ b/apps/web/components/games/EmptyState.tsx @@ -0,0 +1,33 @@ +'use client' + +import React from 'react' + +import Button from '../ui/Button' + +interface EmptyStateProps { + onCreateGame: () => void + isCreating: boolean +} + +export default function EmptyState({ onCreateGame, isCreating }: EmptyStateProps) { + return ( +
+
+
+
🎲
+

No Active Games

+

Create a new game to start playing the Royal Game of Ur!

+
+ + + +
+

• Create a game and share the link with a friend

+

• Or wait for someone to share a game with you

+
+
+
+ ) +} diff --git a/apps/web/components/games/GameCard.test.tsx b/apps/web/components/games/GameCard.test.tsx new file mode 100644 index 0000000..1f87b8d --- /dev/null +++ b/apps/web/components/games/GameCard.test.tsx @@ -0,0 +1,167 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import GameCard from './GameCard' + +// Mock Next.js router +const mockPush = vi.fn() +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +// Mock API +vi.mock('../../lib/api', () => ({ + api: { + forfeitGame: vi.fn(), + }, +})) + +// Mock localStorage +const mockLocalStorage = { + getItem: vi.fn(), +} +Object.defineProperty(window, 'localStorage', { + value: mockLocalStorage, +}) + +// Mock window.confirm +const mockConfirm = vi.fn() +Object.defineProperty(window, 'confirm', { + value: mockConfirm, +}) + +describe('GameCard', () => { + const mockOnGameUpdated = vi.fn() + const currentUserId = 'user-1' + + beforeEach(() => { + vi.clearAllMocks() + mockLocalStorage.getItem.mockReturnValue(JSON.stringify({ secret: 'test-secret' })) + mockConfirm.mockReturnValue(true) + }) + + const mockGame = { + id: 'game-1', + status: 'active' as const, + player1: { id: 'user-1', displayName: 'Player 1' }, + player2: { id: 'user-2', displayName: 'Player 2' }, + currentTurn: 'user-1', + winnerId: null, + } + + it('renders game information correctly', () => { + render( + , + ) + + expect(screen.getByText('vs Player 2')).toBeInTheDocument() + expect(screen.getByText('Active')).toBeInTheDocument() + expect(screen.getByText('Your Turn!')).toBeInTheDocument() + expect(screen.getByText('View Game')).toBeInTheDocument() + expect(screen.getByText('Forfeit')).toBeInTheDocument() + }) + + it('shows waiting for opponent when no second player', () => { + const waitingGame = { + ...mockGame, + status: 'waiting' as const, + player2: null, + currentTurn: null, + } + + render( + , + ) + + expect(screen.getByText('Waiting for opponent...')).toBeInTheDocument() + expect(screen.getByText('Waiting')).toBeInTheDocument() + expect(screen.queryByText('View Game')).not.toBeInTheDocument() + }) + + it('shows finished game with winner', () => { + const finishedGame = { + ...mockGame, + status: 'finished' as const, + currentTurn: null, + winnerId: 'user-1', + } + + render( + , + ) + + expect(screen.getByText('Finished')).toBeInTheDocument() + expect(screen.getByText('You won!')).toBeInTheDocument() + expect(screen.getByText('View Game')).toBeInTheDocument() + expect(screen.queryByText('Forfeit')).not.toBeInTheDocument() + }) + + it('navigates to game when View Game is clicked', () => { + render( + , + ) + + const viewButton = screen.getByText('View Game') + fireEvent.click(viewButton) + + expect(mockPush).toHaveBeenCalledWith('/games/game-1') + }) + + it('forfeits game when Forfeit is clicked', async () => { + const { api } = await import('../../lib/api') + vi.mocked(api.forfeitGame).mockResolvedValue({}) + + render( + , + ) + + const forfeitButton = screen.getByText('Forfeit') + fireEvent.click(forfeitButton) + + await waitFor(() => { + expect(api.forfeitGame).toHaveBeenCalledWith('game-1', 'test-secret') + expect(mockOnGameUpdated).toHaveBeenCalled() + }) + }) + + it('shows loading state while forfeiting', async () => { + const { api } = await import('../../lib/api') + vi.mocked(api.forfeitGame).mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)), + ) + + render( + , + ) + + const forfeitButton = screen.getByText('Forfeit') + fireEvent.click(forfeitButton) + + expect(screen.getByText('Forfeiting...')).toBeInTheDocument() + }) + + it('does not forfeit when user cancels confirmation', async () => { + mockConfirm.mockReturnValue(false) + + render( + , + ) + + const forfeitButton = screen.getByText('Forfeit') + fireEvent.click(forfeitButton) + + const { api } = await import('../../lib/api') + expect(api.forfeitGame).not.toHaveBeenCalled() + }) +}) diff --git a/apps/web/components/games/GameCard.tsx b/apps/web/components/games/GameCard.tsx new file mode 100644 index 0000000..d8550af --- /dev/null +++ b/apps/web/components/games/GameCard.tsx @@ -0,0 +1,123 @@ +'use client' + +import { GetGameResponse } from '@ur/shared' +import { useRouter } from 'next/navigation' +import React, { useState } from 'react' + +import { api } from '../../lib/api' +import Button from '../ui/Button' + +interface GameCardProps { + game: GetGameResponse + currentUserId: string + onGameUpdated: () => void +} + +export default function GameCard({ game, currentUserId, onGameUpdated }: GameCardProps) { + const [isForfeiting, setIsForfeiting] = useState(false) + const router = useRouter() + + // Determine opponent and display name + const opponent = game.player1.id === currentUserId ? game.player2 : game.player1 + const hasOpponent = !!opponent + + // Get display name based on game state + const getDisplayName = () => { + if (game.status === 'finished' && !hasOpponent) { + return 'No opponent' + } + if (!hasOpponent) { + return 'Waiting for opponent...' + } + return opponent!.displayName + } + + const opponentName = getDisplayName() + const showVsPrefix = hasOpponent && game.status !== 'finished' + + // Check if it's current user's turn + const isMyTurn = game.currentTurn === currentUserId + + // Get status display + const getStatusDisplay = () => { + switch (game.status) { + case 'waiting': + return { text: 'Waiting', color: 'text-yellow-600', bg: 'bg-yellow-100' } + case 'active': + return { text: 'Active', color: 'text-green-600', bg: 'bg-green-100' } + case 'finished': + return { text: 'Finished', color: 'text-gray-600', bg: 'bg-gray-100' } + default: + return { text: 'Unknown', color: 'text-gray-600', bg: 'bg-gray-100' } + } + } + + const statusDisplay = getStatusDisplay() + + const handleViewGame = () => { + router.push(`/games/${game.id}`) + } + + const handleForfeit = async () => { + if (!confirm('Are you sure you want to forfeit this game?')) return + + setIsForfeiting(true) + try { + const userData = localStorage.getItem('urUser') + if (!userData) throw new Error('No user data found') + + const { secret } = JSON.parse(userData) + await api.forfeitGame(game.id, secret) + onGameUpdated() + } catch (error) { + console.error('Failed to forfeit game:', error) + alert('Failed to forfeit game. Please try again.') + } finally { + setIsForfeiting(false) + } + } + + return ( +
+
+
+

+ {showVsPrefix ? `vs ${opponentName}` : opponentName} +

+
+ + {statusDisplay.text} + + {isMyTurn && game.status === 'active' && ( + + Your Turn! + + )} +
+
+
+ +
+ {(game.status === 'active' || game.status === 'finished') && ( + + )} + + {game.status !== 'finished' && ( + + )} +
+ + {game.status === 'finished' && ( +
+ {game.winnerId === currentUserId ? 'You won!' : 'You lost.'} +
+ )} +
+ ) +} diff --git a/apps/web/components/games/GamesList.tsx b/apps/web/components/games/GamesList.tsx new file mode 100644 index 0000000..e90c6b8 --- /dev/null +++ b/apps/web/components/games/GamesList.tsx @@ -0,0 +1,136 @@ +'use client' + +import { GetGamesListResponse } from '@ur/shared' +import React, { useEffect, useState } from 'react' + +import { api } from '../../lib/api' +import CreateGameButton from './CreateGameButton' +import EmptyState from './EmptyState' +import GameCard from './GameCard' +import ShareGameModal from './ShareGameModal' + +interface GamesListProps { + currentUserId: string +} + +export default function GamesList({ currentUserId }: GamesListProps) { + const [games, setGames] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState('') + const [isCreating, setIsCreating] = useState(false) + const [shareGameId, setShareGameId] = useState(null) + + const fetchGames = async () => { + try { + const userData = localStorage.getItem('urUser') + if (!userData) throw new Error('No user data found') + + const { secret } = JSON.parse(userData) + const gamesData = await api.getUserGames(secret) + setGames(gamesData) + setError('') + } catch (error) { + console.error('Failed to fetch games:', error) + setError('Failed to load games. Please refresh the page.') + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + fetchGames() + }, []) + + const handleCreateGame = async () => { + setIsCreating(true) + try { + const userData = localStorage.getItem('urUser') + if (!userData) throw new Error('No user data found') + + const { secret } = JSON.parse(userData) + const newGame = await api.createGame(secret) + + // Refresh games list + await fetchGames() + + // Show share modal + setShareGameId(newGame.id) + } catch (error) { + console.error('Failed to create game:', error) + } finally { + setIsCreating(false) + } + } + + const handleGameUpdated = () => { + fetchGames() + } + + if (isLoading) { + return ( +
+
+
+

Loading games...

+
+
+ ) + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ) + } + + if (games.length === 0) { + return ( + <> + + + setShareGameId(null)} + gameId={shareGameId || ''} + /> + + ) + } + + return ( + <> +
+ {/* Create Game Button */} +
+ +
+ + {/* Games List - One per row */} +
+ {games.map((game) => ( + + ))} +
+
+ + {/* Share Game Modal */} + setShareGameId(null)} + gameId={shareGameId || ''} + /> + + ) +} diff --git a/apps/web/components/games/Header.test.tsx b/apps/web/components/games/Header.test.tsx new file mode 100644 index 0000000..a2c266c --- /dev/null +++ b/apps/web/components/games/Header.test.tsx @@ -0,0 +1,73 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import Header from '../games/Header' + +// Mock Next.js router +const mockPush = vi.fn() +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +// Mock localStorage +const mockLocalStorage = { + removeItem: vi.fn(), +} +Object.defineProperty(window, 'localStorage', { + value: mockLocalStorage, +}) + +describe('Header', () => { + const mockOnUpdateName = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders logo and user display name', () => { + render(
) + + expect(screen.getByText('GAME OF UR')).toBeInTheDocument() + expect(screen.getByText('☰')).toBeInTheDocument() + }) + + it('opens options menu when clicked', () => { + render(
) + + const menuButton = screen.getByText('☰') + fireEvent.click(menuButton) + + expect(screen.getByText('✏️ Change Name')).toBeInTheDocument() + expect(screen.getByText('📖 How to Play')).toBeInTheDocument() + expect(screen.getByText('🚪 Clear User Data')).toBeInTheDocument() + }) + + it('opens change name modal when Change Name is clicked', () => { + render(
) + + const menuButton = screen.getByText('☰') + fireEvent.click(menuButton) + + const changeNameButton = screen.getByText('✏️ Change Name') + fireEvent.click(changeNameButton) + + expect(screen.getByText('Change Display Name')).toBeInTheDocument() + expect(screen.getByDisplayValue('Test User')).toBeInTheDocument() + }) + + it('clears user data when Clear User Data is clicked', () => { + render(
) + + const menuButton = screen.getByText('☰') + fireEvent.click(menuButton) + + const clearDataButton = screen.getByText('🚪 Clear User Data') + fireEvent.click(clearDataButton) + + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('urUser') + expect(mockPush).toHaveBeenCalledWith('/') + }) +}) diff --git a/apps/web/components/games/Header.tsx b/apps/web/components/games/Header.tsx new file mode 100644 index 0000000..3e06f8d --- /dev/null +++ b/apps/web/components/games/Header.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useRouter } from 'next/navigation' +import React, { useEffect, useRef, useState } from 'react' + +import HowToPlay from '../landing/HowToPlay' +import Button from '../ui/Button' +import Modal from '../ui/Modal' + +interface HeaderProps { + userDisplayName: string + onUpdateName: (newName: string) => void +} + +export default function Header({ userDisplayName, onUpdateName }: HeaderProps) { + const [isMenuOpen, setIsMenuOpen] = useState(false) + const [isChangeNameModalOpen, setIsChangeNameModalOpen] = useState(false) + const [isHowToPlayModalOpen, setIsHowToPlayModalOpen] = useState(false) + const [newName, setNewName] = useState(userDisplayName) + const [isUpdating, setIsUpdating] = useState(false) + const menuRef = useRef(null) + const router = useRouter() + + // Close menu when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsMenuOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const handleChangeName = async () => { + if (!newName.trim()) return + + setIsUpdating(true) + try { + await onUpdateName(newName.trim()) + setIsChangeNameModalOpen(false) + setNewName(newName.trim()) // Use the updated name, not the stale prop + } catch (error) { + console.error('Failed to update name:', error) + } finally { + setIsUpdating(false) + } + } + + const handleClearUserData = () => { + localStorage.removeItem('urUser') + router.push('/') + } + + return ( + <> +
+
+ {/* Logo */} +
+

GAME OF UR

+
+ + {/* Options Menu */} +
+ + + {/* Dropdown Menu */} + {isMenuOpen && ( +
+
+

Logged in as:

+

{userDisplayName}

+
+ +
+ + + + + +
+
+ )} +
+
+
+ + {/* Change Name Modal */} + { + setIsChangeNameModalOpen(false) + setNewName(userDisplayName) + }} + title="Change Display Name" + > +
+
+ + setNewName(e.target.value)} + placeholder="Enter new name..." + className="input-field" + maxLength={32} + disabled={isUpdating} + /> +

{newName.length}/32 characters

+
+ +
+ + +
+
+
+ + {/* How to Play Modal */} + setIsHowToPlayModalOpen(false)} + title="How to Play the Royal Game of Ur" + > + + + + ) +} diff --git a/apps/web/components/games/ShareGameModal.tsx b/apps/web/components/games/ShareGameModal.tsx new file mode 100644 index 0000000..3a8f57d --- /dev/null +++ b/apps/web/components/games/ShareGameModal.tsx @@ -0,0 +1,75 @@ +'use client' + +import React, { useState } from 'react' + +import Button from '../ui/Button' +import Modal from '../ui/Modal' + +interface ShareGameModalProps { + isOpen: boolean + onClose: () => void + gameId: string +} + +export default function ShareGameModal({ isOpen, onClose, gameId }: ShareGameModalProps) { + const [copied, setCopied] = useState(false) + const [gameUrl, setGameUrl] = useState('') + + // Generate shareable link only on client side + React.useEffect(() => { + if (typeof window !== 'undefined') { + setGameUrl(`${window.location.origin}/games/${gameId}`) + } + }, [gameId]) + + const handleCopyLink = async () => { + if (!gameUrl) return // Don't copy if URL isn't ready yet + + try { + await navigator.clipboard.writeText(gameUrl) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (error) { + console.error('Failed to copy link:', error) + // Fallback for older browsers + const textArea = document.createElement('textarea') + textArea.value = gameUrl + document.body.appendChild(textArea) + textArea.select() + document.execCommand('copy') + document.body.removeChild(textArea) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + + return ( + +
+
+

Share this link with a friend to start playing!

+ +
+

{gameUrl}

+
+
+ +
+ + + +
+ +
+

+ Your friend can click the link to join the game automatically. +

+
+
+
+ ) +} diff --git a/apps/web/components/ui/Modal.tsx b/apps/web/components/ui/Modal.tsx index 054aa46..6b675d5 100644 --- a/apps/web/components/ui/Modal.tsx +++ b/apps/web/components/ui/Modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { ReactNode, useEffect } from 'react' +import React, { ReactNode, useEffect } from 'react' interface ModalProps { isOpen: boolean diff --git a/package.json b/package.json index a702b56..4778db2 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,30 @@ "format": "prettier --write .", "format:check": "prettier --check .", "test": "turbo run test", - "docker:up": "docker compose up --build" + "docker:up": "docker compose up --build", + "prepare": "husky" }, "devDependencies": { + "husky": "^9.1.7", + "lint-staged": "^16.2.4", "turbo": "^2.1.0", "typescript": "^5.6.0" + }, + "lint-staged": { + "apps/web/**/*.{ts,tsx}": [ + "pnpm --filter @ur/web lint:fix", + "prettier --write" + ], + "apps/api/**/*.ts": [ + "pnpm --filter @ur/api lint:fix", + "prettier --write" + ], + "packages/shared/**/*.ts": [ + "pnpm --filter @ur/shared lint:fix", + "prettier --write" + ], + "*.{json,md,yml,yaml}": [ + "prettier --write" + ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53d7b6e..7add62a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,12 @@ settings: importers: .: devDependencies: + husky: + specifier: ^9.1.7 + version: 9.1.7 + lint-staged: + specifier: ^16.2.4 + version: 16.2.4 turbo: specifier: ^2.1.0 version: 2.5.8 @@ -2749,6 +2755,13 @@ packages: } engines: { node: '>=8' } + ansi-escapes@7.1.1: + resolution: + { + integrity: sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q==, + } + engines: { node: '>=18' } + ansi-regex@5.0.1: resolution: { @@ -3316,6 +3329,13 @@ packages: } engines: { node: '>=8' } + cli-cursor@5.0.0: + resolution: + { + integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==, + } + engines: { node: '>=18' } + cli-spinners@2.9.2: resolution: { @@ -3330,6 +3350,13 @@ packages: } engines: { node: 10.* || >= 12.* } + cli-truncate@5.1.0: + resolution: + { + integrity: sha512-7JDGG+4Zp0CsknDCedl0DYdaeOhc46QNpXi3NLQblkZpXXgA6LncLDUUyvrjSvZeF3VRQa+KiMGomazQrC1V8g==, + } + engines: { node: '>=20' } + cli-width@3.0.0: resolution: { @@ -3410,6 +3437,13 @@ packages: } engines: { node: '>= 0.8' } + commander@14.0.1: + resolution: + { + integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==, + } + engines: { node: '>=20' } + commander@2.20.3: resolution: { @@ -3910,6 +3944,12 @@ packages: } engines: { node: '>=12' } + emoji-regex@10.6.0: + resolution: + { + integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==, + } + emoji-regex@8.0.0: resolution: { @@ -3969,6 +4009,13 @@ packages: } engines: { node: '>=6' } + environment@1.1.0: + resolution: + { + integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==, + } + engines: { node: '>=18' } + err-code@2.0.3: resolution: { @@ -4245,6 +4292,12 @@ packages: } engines: { node: '>=6' } + eventemitter3@5.0.1: + resolution: + { + integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==, + } + events@3.3.0: resolution: { @@ -4612,6 +4665,13 @@ packages: } engines: { node: 6.* || 8.* || >= 10.* } + get-east-asian-width@1.4.0: + resolution: + { + integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==, + } + engines: { node: '>=18' } + get-intrinsic@1.3.0: resolution: { @@ -4882,6 +4942,14 @@ packages: integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==, } + husky@9.1.7: + resolution: + { + integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==, + } + engines: { node: '>=18' } + hasBin: true + iconv-lite@0.4.24: resolution: { @@ -5107,6 +5175,13 @@ packages: } engines: { node: '>=8' } + is-fullwidth-code-point@5.1.0: + resolution: + { + integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==, + } + engines: { node: '>=18' } + is-generator-fn@2.1.0: resolution: { @@ -5867,6 +5942,21 @@ packages: integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==, } + lint-staged@16.2.4: + resolution: + { + integrity: sha512-Pkyr/wd90oAyXk98i/2KwfkIhoYQUMtss769FIT9hFM5ogYZwrk+GRE46yKXSg2ZGhcJ1p38Gf5gmI5Ohjg2yg==, + } + engines: { node: '>=20.17' } + hasBin: true + + listr2@9.0.4: + resolution: + { + integrity: sha512-1wd/kpAdKRLwv7/3OKC8zZ5U8e/fajCfWMxacUvB79S5nLrYGPtUI/8chMQhn3LQjsRVErTb9i1ECAwW0ZIHnQ==, + } + engines: { node: '>=20.0.0' } + loader-runner@4.3.0: resolution: { @@ -5913,6 +6003,13 @@ packages: } engines: { node: '>=10' } + log-update@6.1.0: + resolution: + { + integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==, + } + engines: { node: '>=18' } + loose-envify@1.4.0: resolution: { @@ -6118,6 +6215,13 @@ packages: } engines: { node: '>=6' } + mimic-function@5.0.1: + resolution: + { + integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==, + } + engines: { node: '>=18' } + mimic-response@3.1.0: resolution: { @@ -6305,6 +6409,13 @@ packages: } engines: { node: ^14.17.0 || ^16.13.0 || >=18.0.0 } + nano-spawn@2.0.0: + resolution: + { + integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==, + } + engines: { node: '>=20.17' } + nanoid@3.3.11: resolution: { @@ -6573,6 +6684,13 @@ packages: } engines: { node: '>=6' } + onetime@7.0.0: + resolution: + { + integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==, + } + engines: { node: '>=18' } + optionator@0.9.4: resolution: { @@ -6871,6 +6989,14 @@ packages: } engines: { node: '>=12' } + pidtree@0.6.0: + resolution: + { + integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==, + } + engines: { node: '>=0.10' } + hasBin: true + pino-abstract-transport@1.0.0: resolution: { @@ -7388,6 +7514,13 @@ packages: } engines: { node: '>=8' } + restore-cursor@5.1.0: + resolution: + { + integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==, + } + engines: { node: '>=18' } + ret@0.1.15: resolution: { @@ -7409,6 +7542,12 @@ packages: } engines: { iojs: '>=1.0.0', node: '>=0.10.0' } + rfdc@1.4.1: + resolution: + { + integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==, + } + rimraf@2.7.1: resolution: { @@ -7718,6 +7857,13 @@ packages: } engines: { node: '>=8' } + slice-ansi@7.1.2: + resolution: + { + integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==, + } + engines: { node: '>=18' } + slow-redact@0.3.1: resolution: { @@ -7878,6 +8024,13 @@ packages: } engines: { node: '>=10.0.0' } + string-argv@0.3.2: + resolution: + { + integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==, + } + engines: { node: '>=0.6.19' } + string-length@4.0.2: resolution: { @@ -7899,6 +8052,20 @@ packages: } engines: { node: '>=12' } + string-width@7.2.0: + resolution: + { + integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==, + } + engines: { node: '>=18' } + + string-width@8.1.0: + resolution: + { + integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==, + } + engines: { node: '>=20' } + string.prototype.trim@1.2.10: resolution: { @@ -9083,6 +9250,13 @@ packages: } engines: { node: '>=12' } + wrap-ansi@9.0.2: + resolution: + { + integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==, + } + engines: { node: '>=18' } + wrappy@1.0.2: resolution: { @@ -9164,6 +9338,14 @@ packages: } engines: { node: '>=18' } + yaml@2.8.1: + resolution: + { + integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==, + } + engines: { node: '>= 14.6' } + hasBin: true + yargs-parser@21.1.1: resolution: { @@ -10935,6 +11117,10 @@ snapshots: dependencies: type-fest: 0.21.3 + ansi-escapes@7.1.1: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -11329,6 +11515,10 @@ snapshots: dependencies: restore-cursor: 3.1.0 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + cli-spinners@2.9.2: {} cli-table3@0.6.5: @@ -11337,6 +11527,11 @@ snapshots: optionalDependencies: '@colors/colors': 1.5.0 + cli-truncate@5.1.0: + dependencies: + slice-ansi: 7.1.2 + string-width: 8.1.0 + cli-width@3.0.0: {} cli-width@4.1.0: {} @@ -11370,6 +11565,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@14.0.1: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -11626,6 +11823,8 @@ snapshots: emittery@0.13.1: {} + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -11653,6 +11852,8 @@ snapshots: env-paths@2.2.1: optional: true + environment@1.1.0: {} + err-code@2.0.3: optional: true @@ -11922,6 +12123,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@5.0.1: {} + events@3.3.0: {} execa@5.1.1: @@ -12190,6 +12393,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.4.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -12372,6 +12577,8 @@ snapshots: ms: 2.1.3 optional: true + husky@9.1.7: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -12518,6 +12725,10 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.4.0 + is-generator-fn@2.1.0: {} is-generator-function@1.1.2: @@ -13194,6 +13405,25 @@ snapshots: lines-and-columns@1.2.4: {} + lint-staged@16.2.4: + dependencies: + commander: 14.0.1 + listr2: 9.0.4 + micromatch: 4.0.8 + nano-spawn: 2.0.0 + pidtree: 0.6.0 + string-argv: 0.3.2 + yaml: 2.8.1 + + listr2@9.0.4: + dependencies: + cli-truncate: 5.1.0 + colorette: 2.0.20 + eventemitter3: 5.0.1 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + loader-runner@4.3.0: {} locate-path@5.0.0: @@ -13215,6 +13445,14 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + log-update@6.1.0: + dependencies: + ansi-escapes: 7.1.1 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.1.2 + wrap-ansi: 9.0.2 + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -13324,6 +13562,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} + mimic-response@3.1.0: optional: true @@ -13429,6 +13669,8 @@ snapshots: mute-stream@1.0.0: {} + nano-spawn@2.0.0: {} + nanoid@3.3.11: {} napi-build-utils@2.0.0: @@ -13596,6 +13838,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -13751,6 +13997,8 @@ snapshots: picomatch@4.0.3: optional: true + pidtree@0.6.0: {} + pino-abstract-transport@1.0.0: dependencies: readable-stream: 4.7.0 @@ -14082,6 +14330,11 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + ret@0.1.15: {} retry@0.12.0: @@ -14089,6 +14342,8 @@ snapshots: reusify@1.1.0: {} + rfdc@1.4.1: {} + rimraf@2.7.1: dependencies: glob: 7.2.3 @@ -14319,6 +14574,11 @@ snapshots: slash@3.0.0: {} + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + slow-redact@0.3.1: {} smart-buffer@4.2.0: @@ -14411,6 +14671,8 @@ snapshots: streamsearch@1.1.0: {} + string-argv@0.3.2: {} + string-length@4.0.2: dependencies: char-regex: 1.0.2 @@ -14428,6 +14690,17 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 + + string-width@8.1.0: + dependencies: + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 + string.prototype.trim@1.2.10: dependencies: call-bind: 1.0.8 @@ -15244,6 +15517,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.1.2 + wrappy@1.0.2: {} write-file-atomic@4.0.2: @@ -15273,6 +15552,8 @@ snapshots: yallist@5.0.0: {} + yaml@2.8.1: {} + yargs-parser@21.1.1: {} yargs@17.7.2: