Archive frontend POC to .claude/frontend-poc/

Preserves the working F3 Phaser demo implementation before resetting
the main frontend/ directory for a fresh start. The POC demonstrates:
- Vue 3 + Phaser 3 integration
- Real card rendering with images
- Vue-Phaser state sync via gameBridge
- Card interactions and damage counters

To restore: copy .claude/frontend-poc/ back to frontend/ and run npm install
This commit is contained in:
Cal Corum 2026-01-31 22:00:51 -06:00
parent 2986eed142
commit 8685e3e16e
208 changed files with 54556 additions and 1 deletions

View File

@ -0,0 +1,11 @@
# Development environment configuration
# These values are used when running `npm run dev`
# Backend API base URL (FastAPI server)
VITE_API_BASE_URL=http://localhost:8001
# WebSocket URL (Socket.IO server - same as API in development)
VITE_WS_URL=http://localhost:8001
# OAuth redirect URI (must match OAuth provider configuration)
VITE_OAUTH_REDIRECT_URI=http://localhost:3001/auth/callback

View File

@ -0,0 +1,11 @@
# Production environment configuration
# These values are used when running `npm run build`
# Backend API base URL
VITE_API_BASE_URL=https://api.pocket.manticorum.com
# WebSocket URL (Socket.IO server)
VITE_WS_URL=https://api.pocket.manticorum.com
# OAuth redirect URI (must match OAuth provider configuration)
VITE_OAUTH_REDIRECT_URI=https://pocket.manticorum.com/auth/callback

25
.claude/frontend-poc/.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
*.vite
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,553 @@
# Mantimon TCG Frontend - AI Agent Guidelines
Guidelines for AI agents working on the frontend codebase.
## Tech Stack
| Technology | Purpose |
|------------|---------|
| Vue 3 | UI framework (Composition API + `<script setup>`) |
| Phaser 3 | Game canvas (matches, pack opening) |
| TypeScript | Type safety (strict mode) |
| Pinia | State management |
| Tailwind CSS | Styling (mobile-first) |
| Socket.io-client | Real-time communication |
| Vite | Build tool |
| Vitest | Unit/component testing |
---
## Quick Commands
```bash
npm run dev # Start dev server
npm run build # Production build
npm run preview # Preview production build
npm run test # Run tests
npm run test:watch # Run tests in watch mode
npm run lint # ESLint
npm run typecheck # TypeScript check
```
---
## Project Structure
```
src/
├── api/ # API client and composables
│ ├── client.ts # Base HTTP client with auth
│ └── types.ts # API response types
├── assets/ # Static assets, global CSS
├── components/ # Reusable Vue components
│ ├── ui/ # Generic UI (Button, Modal, Toast)
│ ├── cards/ # Card display components
│ ├── deck/ # Deck builder components
│ └── game/ # Game UI overlays
├── composables/ # Vue composables (useAuth, useDecks, etc.)
├── game/ # Phaser game code
│ ├── scenes/ # Phaser scenes
│ ├── objects/ # Game objects (Card, Board)
│ ├── animations/ # Animation helpers
│ └── bridge.ts # Vue-Phaser communication
├── layouts/ # Layout components
├── pages/ # Route pages
├── router/ # Vue Router config
├── socket/ # Socket.IO client
├── stores/ # Pinia stores
├── types/ # TypeScript types
└── utils/ # Utility functions
```
---
## Code Style
### Vue Components
**Single File Component structure:**
```vue
<script setup lang="ts">
// 1. Imports (Vue, then external, then local)
import { ref, computed, onMounted } from 'vue'
import { useGameStore } from '@/stores/game'
import type { Card } from '@/types'
// 2. Props and emits
const props = defineProps<{
cardId: string
size?: 'sm' | 'md' | 'lg'
}>()
const emit = defineEmits<{
click: [cardId: string]
}>()
// 3. Composables and stores
const gameStore = useGameStore()
// 4. Reactive state
const isHovered = ref(false)
// 5. Computed
const card = computed(() => gameStore.getCard(props.cardId))
// 6. Methods
function handleClick() {
emit('click', props.cardId)
}
// 7. Lifecycle
onMounted(() => {
// ...
})
</script>
<template>
<!-- Single root element preferred -->
<div
class="card"
:class="{ 'card--hovered': isHovered }"
@click="handleClick"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<slot />
</div>
</template>
<style scoped>
/* Component-specific styles if Tailwind isn't enough */
</style>
```
### TypeScript
**Use strict typing:**
```typescript
// Good - explicit types
interface CardProps {
cardId: string
size?: 'sm' | 'md' | 'lg'
}
// Good - type imports
import type { Card, GameState } from '@/types'
// Good - const assertions for literals
const SIZES = ['sm', 'md', 'lg'] as const
type Size = typeof SIZES[number]
// Avoid - any
const data: any = response // NO
const data: unknown = response // OK if you'll narrow it
```
### Naming Conventions
| Type | Convention | Example |
|------|------------|---------|
| Components | PascalCase | `CardDisplay.vue`, `DeckBuilder.vue` |
| Composables | camelCase with `use` prefix | `useAuth.ts`, `useDeckValidation.ts` |
| Stores | camelCase with descriptive name | `auth.ts`, `game.ts` |
| Types/Interfaces | PascalCase | `Card`, `GameState`, `ApiResponse` |
| Constants | UPPER_SNAKE_CASE | `MAX_HAND_SIZE`, `API_BASE_URL` |
| CSS classes | kebab-case (BEM optional) | `card-display`, `card-display--active` |
---
## Pinia Stores
**Use setup store syntax:**
```typescript
// stores/game.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { GameState } from '@/types'
export const useGameStore = defineStore('game', () => {
// State
const gameState = ref<GameState | null>(null)
const isConnected = ref(false)
// Getters (computed)
const myHand = computed(() => gameState.value?.myHand ?? [])
const isMyTurn = computed(() => gameState.value?.currentPlayer === 'me')
// Actions
function setGameState(state: GameState) {
gameState.value = state
}
function clearGame() {
gameState.value = null
isConnected.value = false
}
return {
// State
gameState,
isConnected,
// Getters
myHand,
isMyTurn,
// Actions
setGameState,
clearGame,
}
})
```
---
## Composables
**Pattern for API composables:**
```typescript
// composables/useDecks.ts
import { ref } from 'vue'
import { apiClient } from '@/api/client'
import type { Deck, DeckCreate } from '@/types'
export function useDecks() {
const decks = ref<Deck[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
async function fetchDecks() {
isLoading.value = true
error.value = null
try {
decks.value = await apiClient.get<Deck[]>('/api/decks')
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to fetch decks'
} finally {
isLoading.value = false
}
}
async function createDeck(data: DeckCreate) {
isLoading.value = true
error.value = null
try {
const deck = await apiClient.post<Deck>('/api/decks', data)
decks.value.push(deck)
return deck
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to create deck'
throw e
} finally {
isLoading.value = false
}
}
return {
decks,
isLoading,
error,
fetchDecks,
createDeck,
}
}
```
---
## Vue-Phaser Integration
**Phaser is for rendering only. Game logic lives in backend.**
### Communication Pattern
```typescript
// Vue -> Phaser (intentions)
phaserGame.value?.events.emit('card:play', { cardId, targetZone })
phaserGame.value?.events.emit('attack:select', { attackIndex })
// Phaser -> Vue (completions/UI requests)
phaserGame.value?.events.on('animation:complete', handleAnimationComplete)
phaserGame.value?.events.on('card:clicked', handleCardClicked)
```
### State Sync
```typescript
// Phaser scene reads from Pinia store
import { useGameStore } from '@/stores/game'
class MatchScene extends Phaser.Scene {
private gameStore = useGameStore()
update() {
// React to store changes
if (this.gameStore.gameState) {
this.renderState(this.gameStore.gameState)
}
}
}
```
---
## Tailwind Guidelines
**Mobile-first responsive:**
```html
<!-- Base styles for mobile, override for larger screens -->
<div class="p-2 md:p-4 lg:p-6">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<!-- ... -->
</div>
</div>
```
**Use design system colors:**
```html
<!-- Pokemon type colors defined in tailwind.config.js -->
<div class="bg-type-fire text-white">Fire Type</div>
<div class="bg-type-water text-white">Water Type</div>
<!-- UI colors -->
<button class="bg-primary hover:bg-primary-dark">Action</button>
```
---
## Testing
**Component tests with Vitest + Vue Test Utils:**
```typescript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import CardDisplay from '@/components/cards/CardDisplay.vue'
describe('CardDisplay', () => {
it('renders card name', () => {
/**
* Test that the card component displays the card name.
*
* Card names must be visible for players to identify cards
* in their hand and on the board.
*/
const wrapper = mount(CardDisplay, {
props: { card: { id: '1', name: 'Pikachu', hp: 60 } }
})
expect(wrapper.text()).toContain('Pikachu')
})
it('emits click event with card id', async () => {
/**
* Test that clicking a card emits the correct event.
*
* Card clicks drive all game interactions - playing cards,
* selecting targets, viewing details.
*/
const wrapper = mount(CardDisplay, {
props: { card: { id: '1', name: 'Pikachu', hp: 60 } }
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')?.[0]).toEqual(['1'])
})
})
```
---
## Critical Rules
1. **Mobile-first** - Base styles for mobile, use `md:` and `lg:` for larger screens
2. **TypeScript strict** - No `any`, explicit types for props/emits/returns
3. **Composition API only** - No Options API, use `<script setup>`
4. **Phaser for rendering** - No game logic in Phaser, only visualization
5. **Backend is authoritative** - Never trust client state for game logic
6. **Test docstrings** - Every test needs a docstring explaining what and why
---
## Path Aliases
```typescript
// Use @/ for src/
import { useAuth } from '@/composables/useAuth'
import CardDisplay from '@/components/cards/CardDisplay.vue'
import type { Card } from '@/types'
// Don't use relative paths for deeply nested imports
import { useAuth } from '../../../composables/useAuth' // NO
```
---
## Environment Variables
```bash
# .env.development
VITE_API_BASE_URL=http://localhost:8000
VITE_WS_URL=http://localhost:8000
# .env.production
VITE_API_BASE_URL=https://api.pocket.manticorum.com
VITE_WS_URL=https://api.pocket.manticorum.com
```
**Access in code:**
```typescript
const apiUrl = import.meta.env.VITE_API_BASE_URL
```
---
## Common Patterns
### Loading States
```vue
<template>
<div v-if="isLoading" class="flex justify-center p-8">
<LoadingSpinner />
</div>
<div v-else-if="error" class="text-error p-4">
{{ error }}
</div>
<div v-else>
<!-- Content -->
</div>
</template>
```
### Route Guards
```typescript
// router/guards.ts
export function requireAuth(to, from, next) {
const auth = useAuthStore()
if (!auth.isAuthenticated) {
next({ name: 'Login', query: { redirect: to.fullPath } })
} else {
next()
}
}
```
### API Error Handling
```typescript
try {
await apiClient.post('/api/games', data)
} catch (e) {
if (e instanceof ApiError) {
if (e.status === 401) {
// Token expired, redirect to login
auth.logout()
} else {
toast.error(e.message)
}
}
}
```
---
## Backend API Reference
### REST Endpoints
**Auth** (`/api/auth/`)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/auth/google` | Start Google OAuth (redirect) |
| GET | `/auth/google/callback` | Google OAuth callback |
| GET | `/auth/discord` | Start Discord OAuth (redirect) |
| GET | `/auth/discord/callback` | Discord OAuth callback |
| POST | `/auth/refresh` | Refresh access token |
| POST | `/auth/logout` | Revoke refresh token |
| POST | `/auth/logout-all` | Revoke all user tokens |
| GET | `/auth/link/google` | Link Google account (authed) |
| GET | `/auth/link/discord` | Link Discord account (authed) |
**Users** (`/api/users/`)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/users/me` | Get current user profile |
| PATCH | `/users/me` | Update profile (display_name, avatar_url) |
| GET | `/users/me/linked-accounts` | List linked OAuth accounts |
| DELETE | `/users/me/link/{provider}` | Unlink OAuth provider |
| GET | `/users/me/sessions` | Get active session count |
| GET | `/users/me/starter-status` | Check if starter deck selected |
| POST | `/users/me/starter-deck` | Select starter deck |
**Collections** (`/api/collections/`)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/collections/me` | Get user's card collection |
| GET | `/collections/me/cards/{card_id}` | Get specific card quantity |
**Decks** (`/api/decks/`)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/decks` | List user's decks |
| POST | `/decks` | Create new deck |
| GET | `/decks/{id}` | Get deck by ID |
| PUT | `/decks/{id}` | Update deck |
| DELETE | `/decks/{id}` | Delete deck |
| POST | `/decks/validate` | Validate deck without saving |
**Games** (`/api/games/`)
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/games` | Create new game |
| GET | `/games/{id}` | Get game info |
| GET | `/games/me/active` | List user's active games |
| POST | `/games/{id}/resign` | Resign from game (HTTP fallback) |
### WebSocket Events (Socket.IO)
**Namespace:** `/game`
**Client → Server:**
| Event | Payload | Description |
|-------|---------|-------------|
| `game:join` | `{ game_id }` | Join/rejoin a game room |
| `game:action` | `{ action_type, ...data }` | Execute game action |
| `game:resign` | `{ game_id }` | Resign from game |
| `game:spectate` | `{ game_id }` | Join as spectator |
| `game:leave_spectate` | `{ game_id }` | Leave spectator mode |
**Server → Client:**
| Event | Payload | Description |
|-------|---------|-------------|
| `game:state` | `GameState` | Full game state (visibility filtered) |
| `game:action_result` | `ActionResult` | Result of player action |
| `game:error` | `{ message, code }` | Error notification |
| `game:turn_timeout_warning` | `{ remaining_seconds }` | Turn timer warning |
| `game:game_over` | `{ winner, reason }` | Game ended |
| `game:opponent_connected` | `{ connected }` | Opponent connection status |
| `game:reconnected` | `{ game_id, state }` | Auto-reconnected to game |
| `game:spectator_count` | `{ count }` | Spectator count update |
### Key Backend Files
| File | Description |
|------|-------------|
| `backend/app/api/auth.py` | Auth endpoints implementation |
| `backend/app/api/users.py` | User endpoints implementation |
| `backend/app/api/games.py` | Game REST endpoints |
| `backend/app/schemas/` | Pydantic request/response schemas |
| `backend/app/schemas/ws_messages.py` | WebSocket message schemas |
| `backend/app/socketio/game_namespace.py` | WebSocket event handlers |
### Type Definitions to Mirror
When creating frontend types, reference these backend schemas:
- `backend/app/schemas/user.py``UserResponse`, `UserUpdate`
- `backend/app/schemas/deck.py``DeckResponse`, `DeckCreate`, `DeckUpdate`
- `backend/app/schemas/game.py``GameCreateRequest`, `GameResponse`
- `backend/app/schemas/ws_messages.py` → All WebSocket message types
---
## See Also
- `PROJECT_PLAN_FRONTEND.json` - Phase plan and tasks
- `../backend/CLAUDE.md` - Backend guidelines
- `../backend/app/schemas/` - API schema definitions
- `../backend/app/socketio/README.md` - WebSocket documentation
- `../CLAUDE.md` - Project-wide guidelines

View File

@ -0,0 +1,989 @@
{
"meta": {
"version": "1.1.0",
"created": "2026-01-30",
"lastUpdated": "2026-01-31",
"planType": "master",
"projectName": "Mantimon TCG - Frontend",
"description": "Vue 3 + Phaser 3 frontend for pocket.manticorum.com - real-time multiplayer TCG with campaign mode",
"totalPhases": 8,
"completedPhases": 4,
"status": "Phase F3 COMPLETE - Ready for Phase F4 (Live Gameplay)"
},
"techStack": {
"framework": "Vue 3 (Composition API + <script setup>)",
"gameEngine": "Phaser 3",
"language": "TypeScript",
"stateManagement": "Pinia",
"styling": "Tailwind CSS",
"realtime": "Socket.io-client",
"buildTool": "Vite",
"testing": "Vitest + Vue Test Utils",
"e2e": "Playwright (future)"
},
"architectureDecisions": {
"vuePhaser": {
"pattern": "Phaser mounts as Vue component, communication via event bridge",
"rationale": "Keep Vue for UI/state, Phaser for game canvas rendering only",
"communication": "EventEmitter pattern - Vue emits intentions, Phaser emits completion"
},
"stateManagement": {
"pattern": "Pinia stores as single source of truth, Phaser reads from stores",
"stores": ["auth", "user", "game", "deck", "collection", "ui"]
},
"apiLayer": {
"pattern": "Composables for API calls, automatic token refresh",
"structure": "useApi() base, useAuth(), useDecks(), useGames() domain composables"
},
"socketIO": {
"pattern": "Singleton connection manager with Pinia integration",
"reconnection": "Auto-reconnect with exponential backoff, queue actions during disconnect"
},
"routing": {
"guards": "Auth guard redirects to login, game guard ensures valid game state",
"lazyLoading": "Route-level code splitting for Phaser scenes"
}
},
"sitePlan": {
"designApproach": "Mobile-first, desktop is first-class citizen",
"navigation": {
"desktop": "Left sidebar",
"mobile": "Bottom tabs",
"items": [
{"icon": "home", "label": "Home", "route": "/"},
{"icon": "cards", "label": "Collection", "route": "/collection"},
{"icon": "deck", "label": "Decks", "route": "/decks"},
{"icon": "play", "label": "Play", "route": "/play"},
{"icon": "user", "label": "Profile", "route": "/profile"}
]
},
"layouts": {
"minimal": {
"routes": ["/login", "/auth/callback"],
"description": "No navigation, centered content"
},
"game": {
"routes": ["/game/:id"],
"description": "Full viewport for Phaser, no nav"
},
"default": {
"routes": ["*"],
"description": "Sidebar (desktop) / bottom tabs (mobile)"
}
},
"routes": [
{"path": "/login", "name": "Login", "auth": "guest", "layout": "minimal", "notes": "OAuth buttons, redirect if logged in"},
{"path": "/auth/callback", "name": "AuthCallback", "auth": "none", "layout": "minimal", "notes": "Silent token handling"},
{"path": "/starter", "name": "StarterSelection", "auth": "required", "layout": "minimal", "notes": "Redirect here if no starter deck"},
{"path": "/", "name": "Dashboard", "auth": "required", "layout": "default", "notes": "Home with widgets (future)"},
{"path": "/collection", "name": "Collection", "auth": "required", "layout": "default", "notes": "Card grid with filters"},
{"path": "/decks", "name": "DeckList", "auth": "required", "layout": "default", "notes": "List + create button"},
{"path": "/decks/new", "name": "NewDeck", "auth": "required", "layout": "default", "notes": "Deck builder (empty)"},
{"path": "/decks/:id", "name": "EditDeck", "auth": "required", "layout": "default", "notes": "Deck builder (existing)"},
{"path": "/play", "name": "PlayMenu", "auth": "required", "layout": "default", "notes": "Game mode selection"},
{"path": "/game/:id", "name": "Game", "auth": "required", "layout": "game", "notes": "Phaser canvas, full viewport"},
{"path": "/profile", "name": "Profile", "auth": "required", "layout": "default", "notes": "Display name, avatar, linked accounts, logout"}
],
"authFlow": {
"loggedOut": "Redirect to /login",
"loggedInNoStarter": "Redirect to /starter",
"loggedIn": "Allow access to protected routes"
},
"futureRoutes": [
{"path": "/campaign", "backendDep": "Phase 5"},
{"path": "/campaign/club/:id", "backendDep": "Phase 5"},
{"path": "/matchmaking", "backendDep": "Phase 6"},
{"path": "/history", "backendDep": "Phase 6"},
{"path": "/packs", "backendDep": "Phase 5"}
],
"dashboardWidgets": {
"planned": [
"Active games (resume)",
"Quick play button",
"Recent matches",
"Collection stats",
"Daily rewards (Phase 5+)",
"News/announcements"
],
"v1": ["Quick play button", "Active games"]
},
"profilePage": {
"v1Features": [
"Avatar display",
"Display name (editable)",
"Linked accounts list",
"Logout button"
]
}
},
"phases": [
{
"id": "PHASE_F0",
"name": "Project Foundation",
"status": "COMPLETE",
"completedDate": "2026-01-30",
"description": "Scaffolding, tooling, core infrastructure, API client setup",
"estimatedDays": "3-5",
"dependencies": [],
"backendDependencies": ["PHASE_2"],
"deliverables": [
"Vite + Vue 3 + TypeScript project structure",
"Tailwind CSS configuration",
"Vue Router with lazy loading",
"Pinia store scaffolding",
"API client with auth header injection",
"Socket.IO client wrapper",
"Environment configuration (dev/staging/prod)",
"App shell with responsive layout",
"ESLint + Prettier configuration"
],
"tasks": [
{
"id": "F0-001",
"name": "Initialize Vite project",
"description": "Create Vue 3 + TypeScript project with Vite",
"files": ["package.json", "vite.config.ts", "tsconfig.json"],
"details": [
"npm create vite@latest . -- --template vue-ts",
"Configure path aliases (@/ for src/)",
"Set up TypeScript strict mode"
]
},
{
"id": "F0-002",
"name": "Install and configure Tailwind",
"description": "Set up Tailwind CSS with custom theme",
"files": ["tailwind.config.js", "src/assets/main.css"],
"details": [
"Install tailwindcss, postcss, autoprefixer",
"Configure content paths",
"Add Mantimon color palette (Pokemon-inspired)"
]
},
{
"id": "F0-003",
"name": "Set up Vue Router",
"description": "Configure routing with guards and lazy loading",
"files": ["src/router/index.ts", "src/router/guards.ts"],
"details": [
"Define route structure",
"Create auth guard placeholder",
"Configure lazy loading for heavy routes"
]
},
{
"id": "F0-004",
"name": "Set up Pinia stores",
"description": "Create store structure with persistence",
"files": ["src/stores/auth.ts", "src/stores/user.ts", "src/stores/ui.ts"],
"details": [
"Install pinia and pinia-plugin-persistedstate",
"Create auth store skeleton",
"Create user store skeleton",
"Create UI store (loading states, toasts)"
]
},
{
"id": "F0-005",
"name": "Create API client",
"description": "HTTP client with auth token injection and refresh",
"files": ["src/api/client.ts", "src/api/types.ts"],
"details": [
"Create fetch/axios wrapper",
"Inject Authorization header from auth store",
"Handle 401 responses with token refresh",
"Type API responses"
]
},
{
"id": "F0-006",
"name": "Create Socket.IO client",
"description": "WebSocket connection manager",
"files": ["src/socket/client.ts", "src/socket/types.ts"],
"details": [
"Install socket.io-client",
"Create connection manager singleton",
"Configure auth token in handshake",
"Set up reconnection with exponential backoff",
"Create typed event emitters"
]
},
{
"id": "F0-007",
"name": "Create app shell",
"description": "Basic layout with navigation",
"files": ["src/App.vue", "src/layouts/DefaultLayout.vue", "src/components/NavBar.vue"],
"details": [
"Responsive header/nav",
"Main content area with router-view",
"Loading overlay component",
"Toast notification area"
]
},
{
"id": "F0-008",
"name": "Environment configuration",
"description": "Configure environment variables",
"files": [".env.development", ".env.production", "src/config.ts"],
"details": [
"VITE_API_BASE_URL",
"VITE_WS_URL",
"VITE_OAUTH_REDIRECT_URI",
"Type-safe config access"
]
}
]
},
{
"id": "PHASE_F1",
"name": "Authentication",
"status": "COMPLETE",
"completedDate": "2026-01-30",
"description": "OAuth login flow, token management, protected routes",
"estimatedDays": "2-3",
"dependencies": ["PHASE_F0"],
"backendDependencies": ["PHASE_2"],
"deliverables": [
"Login page with OAuth buttons",
"OAuth callback handling",
"Token storage and auto-refresh",
"Auth store implementation",
"Protected route guards",
"User profile display",
"Logout functionality"
],
"tasks": [
{
"id": "F1-001",
"name": "Create login page",
"description": "OAuth login buttons for Google and Discord",
"files": ["src/pages/LoginPage.vue"],
"details": [
"Google OAuth button with icon",
"Discord OAuth button with icon",
"Redirect to backend /api/auth/{provider}",
"Handle loading states"
]
},
{
"id": "F1-002",
"name": "Handle OAuth callback",
"description": "Parse tokens from URL fragment after OAuth redirect",
"files": ["src/pages/AuthCallbackPage.vue", "src/composables/useAuth.ts"],
"details": [
"Extract access_token, refresh_token from URL fragment",
"Store tokens in auth store",
"Redirect to intended destination or home",
"Handle OAuth errors"
]
},
{
"id": "F1-003",
"name": "Implement token refresh",
"description": "Auto-refresh tokens before expiry",
"files": ["src/stores/auth.ts", "src/api/client.ts"],
"details": [
"Track token expiry time",
"Refresh proactively before expiry",
"Handle refresh failures (logout)",
"Queue requests during refresh"
]
},
{
"id": "F1-004",
"name": "Implement auth guards",
"description": "Protect routes requiring authentication",
"files": ["src/router/guards.ts"],
"details": [
"requireAuth guard - redirect to login",
"requireGuest guard - redirect authenticated users away from login",
"Store intended destination for post-login redirect"
]
},
{
"id": "F1-005",
"name": "Create user profile display",
"description": "Show logged-in user info in nav",
"files": ["src/components/UserMenu.vue", "src/stores/user.ts"],
"details": [
"Fetch user from /api/users/me on login",
"Display avatar and name in nav",
"Dropdown with profile link and logout"
]
},
{
"id": "F1-006",
"name": "Implement logout",
"description": "Clear tokens and redirect to login",
"files": ["src/stores/auth.ts"],
"details": [
"Call /api/auth/logout to revoke refresh token",
"Clear local token storage",
"Disconnect WebSocket",
"Redirect to login page"
]
}
]
},
{
"id": "PHASE_F2",
"name": "Deck Management",
"status": "COMPLETE",
"completedDate": "2026-01-31",
"auditStatus": "PASSED",
"auditDate": "2026-01-31",
"description": "Collection viewing, deck building, starter deck selection",
"estimatedDays": "5-7",
"dependencies": ["PHASE_F1"],
"backendDependencies": ["PHASE_3"],
"deliverables": [
"Starter deck selection flow",
"Collection grid view",
"Card display component",
"Deck list page",
"Deck builder with drag-and-drop",
"Real-time validation feedback",
"Card detail modal"
],
"tasks": [
{
"id": "F2-001",
"name": "Create card component",
"description": "Reusable card display component",
"files": ["src/components/cards/CardDisplay.vue", "src/components/cards/CardImage.vue"],
"details": [
"Display card image from CDN/local",
"Show card name, HP, type",
"Type-colored border/badge",
"Hover state with subtle animation",
"Support multiple sizes (thumbnail, medium, large)"
]
},
{
"id": "F2-002",
"name": "Create card detail modal",
"description": "Full card view with all details",
"files": ["src/components/cards/CardDetailModal.vue"],
"details": [
"Large card image",
"Full attack descriptions",
"Weakness/resistance/retreat",
"Owned quantity display",
"Close on backdrop click or Escape"
]
},
{
"id": "F2-003",
"name": "Implement starter deck selection",
"description": "First-time user flow to pick starter",
"files": ["src/pages/StarterSelectionPage.vue", "src/composables/useStarter.ts"],
"details": [
"Check starter status on app load",
"Display 5 starter deck options with preview",
"POST /api/users/me/starter-deck on selection",
"Redirect to main app after selection",
"Cannot proceed without selecting"
]
},
{
"id": "F2-004",
"name": "Create collection page",
"description": "Grid view of owned cards",
"files": ["src/pages/CollectionPage.vue", "src/stores/collection.ts"],
"details": [
"Fetch collection from /api/collections/me",
"Filterable grid (by type, rarity, set)",
"Search by card name",
"Show quantity owned",
"Click to open detail modal"
]
},
{
"id": "F2-005",
"name": "Create deck list page",
"description": "View all user decks",
"files": ["src/pages/DecksPage.vue", "src/stores/deck.ts"],
"details": [
"Fetch decks from /api/decks",
"Display deck cards with validation status",
"Create new deck button",
"Edit/delete deck actions",
"Show starter deck badge"
]
},
{
"id": "F2-006",
"name": "Create deck builder",
"description": "Add/remove cards from deck",
"files": ["src/pages/DeckBuilderPage.vue", "src/components/deck/DeckEditor.vue"],
"details": [
"Two-panel layout: collection picker + deck contents",
"Drag-and-drop or click to add/remove",
"Energy deck configuration",
"Live validation with error messages",
"Save/cancel buttons",
"Confirm discard unsaved changes"
]
},
{
"id": "F2-007",
"name": "Implement deck validation feedback",
"description": "Real-time validation as user edits",
"files": ["src/composables/useDeckValidation.ts"],
"details": [
"Debounced validation on changes",
"Call /api/decks/validate endpoint",
"Display errors inline",
"Disable save if invalid",
"Show card count progress (40/40)"
]
}
]
},
{
"id": "PHASE_F3",
"name": "Phaser Integration",
"status": "COMPLETE",
"completedDate": "2026-01-31",
"description": "Game rendering foundation - Phaser setup, board layout, card objects",
"estimatedDays": "7-10",
"dependencies": ["PHASE_F2"],
"backendDependencies": ["PHASE_4"],
"deliverables": [
"Phaser mounted as Vue component",
"Vue-Phaser event bridge",
"Scene structure (Preload, Match)",
"Asset loading system",
"Game board layout with all zones",
"Card game objects with interactions",
"Responsive canvas scaling"
],
"tasks": [
{
"id": "F3-001",
"name": "Install and configure Phaser",
"description": "Add Phaser 3 to project",
"files": ["package.json", "src/game/config.ts"],
"details": [
"Install phaser",
"Create Phaser game configuration",
"Configure WebGL with canvas fallback",
"Set up asset base path"
]
},
{
"id": "F3-002",
"name": "Create Phaser Vue component",
"description": "Mount Phaser game in Vue component",
"files": ["src/components/game/PhaserGame.vue"],
"details": [
"Create/destroy Phaser game on mount/unmount",
"Pass game instance to parent via ref",
"Handle resize events",
"Emit ready event when game initialized"
]
},
{
"id": "F3-003",
"name": "Create Vue-Phaser event bridge",
"description": "Bidirectional communication system",
"files": ["src/game/bridge.ts", "src/composables/useGameBridge.ts"],
"details": [
"Vue -> Phaser: game.events.emit('card:play', data)",
"Phaser -> Vue: game.events.on('animation:complete', cb)",
"Type-safe event definitions",
"Automatic cleanup on unmount"
]
},
{
"id": "F3-004",
"name": "Create scene structure",
"description": "Phaser scenes for game flow",
"files": ["src/game/scenes/PreloadScene.ts", "src/game/scenes/MatchScene.ts"],
"details": [
"PreloadScene: Load all assets with progress bar",
"MatchScene: Main game rendering",
"Scene transitions",
"Scene data passing"
]
},
{
"id": "F3-005",
"name": "Implement asset loading",
"description": "Load card images and UI assets",
"files": ["src/game/assets/loader.ts", "src/game/assets/manifest.ts"],
"details": [
"Card image loading (lazy load as needed)",
"UI sprite atlas",
"Board background",
"Type icons",
"Loading progress display"
]
},
{
"id": "F3-006",
"name": "Create board layout",
"description": "Position all game zones",
"files": ["src/game/objects/Board.ts", "src/game/layout.ts"],
"details": [
"Player zones: active, bench (5 slots), deck, discard, prizes (6)",
"Opponent zones: mirrored layout",
"Hand area (bottom)",
"Zone highlighting for valid targets",
"Responsive positioning"
]
},
{
"id": "F3-007",
"name": "Create card game objects",
"description": "Interactive card sprites",
"files": ["src/game/objects/Card.ts", "src/game/objects/CardBack.ts"],
"details": [
"Card face with image",
"Card back for hidden cards",
"Hover effects (scale, glow)",
"Click/tap handling",
"Drag support for hand cards",
"Damage counters display"
]
},
{
"id": "F3-008",
"name": "Implement responsive scaling",
"description": "Canvas adapts to screen size",
"files": ["src/game/scale.ts"],
"details": [
"Maintain aspect ratio",
"Scale to fit container",
"Handle window resize",
"Mobile-friendly touch areas"
]
}
]
},
{
"id": "PHASE_F4",
"name": "Live Gameplay",
"status": "NOT_STARTED",
"description": "WebSocket integration, game state sync, action handling, complete game flow",
"estimatedDays": "10-14",
"dependencies": ["PHASE_F3"],
"backendDependencies": ["PHASE_4"],
"deliverables": [
"Game creation flow",
"WebSocket connection with auth",
"Game state rendering",
"Hand and card interactions",
"Attack selection UI",
"Turn phase management",
"Action dispatch to server",
"Opponent state display",
"Game over handling"
],
"tasks": [
{
"id": "F4-001",
"name": "Create game lobby page",
"description": "Start or join a game",
"files": ["src/pages/GameLobbyPage.vue"],
"details": [
"Create new game with deck selection",
"Show active games (for rejoining)",
"Simple invite flow (share game ID for now)",
"POST /api/games to create"
]
},
{
"id": "F4-002",
"name": "Implement WebSocket game connection",
"description": "Connect to game via Socket.IO",
"files": ["src/socket/gameSocket.ts", "src/stores/game.ts"],
"details": [
"Connect with JWT auth",
"Emit game:join on connect",
"Handle game:state events",
"Handle game:error events",
"Store game state in Pinia"
]
},
{
"id": "F4-003",
"name": "Render game state to Phaser",
"description": "Sync Pinia state to Phaser objects",
"files": ["src/game/sync/StateRenderer.ts"],
"details": [
"Watch game store for changes",
"Update card positions",
"Show/hide cards based on visibility",
"Update HP, status, damage counters",
"Highlight active Pokemon"
]
},
{
"id": "F4-004",
"name": "Implement hand interactions",
"description": "Play cards from hand",
"files": ["src/game/interactions/HandManager.ts"],
"details": [
"Fan cards in hand area",
"Drag to play Pokemon to bench",
"Click to play Trainer cards",
"Attach energy to Pokemon",
"Visual feedback for valid/invalid plays"
]
},
{
"id": "F4-005",
"name": "Implement attack selection",
"description": "UI for choosing attacks",
"files": ["src/components/game/AttackMenu.vue", "src/game/ui/AttackOverlay.ts"],
"details": [
"Show available attacks for active Pokemon",
"Display energy cost, damage, effect text",
"Disable attacks without enough energy",
"Target selection for attacks that require it",
"Confirm attack action"
]
},
{
"id": "F4-006",
"name": "Implement turn phase UI",
"description": "Show current phase and valid actions",
"files": ["src/components/game/TurnIndicator.vue", "src/components/game/PhaseActions.vue"],
"details": [
"Display current phase (Draw, Main, Attack)",
"Show whose turn it is",
"End Turn button",
"Retreat button",
"Phase-appropriate action hints"
]
},
{
"id": "F4-007",
"name": "Implement action dispatch",
"description": "Send actions to server",
"files": ["src/composables/useGameActions.ts"],
"details": [
"Emit game:action for each action type",
"Handle action results",
"Optimistic UI updates (optional)",
"Error handling and retry",
"Action confirmation for important moves"
]
},
{
"id": "F4-008",
"name": "Display opponent state",
"description": "Render opponent's visible information",
"files": ["src/game/objects/OpponentArea.ts"],
"details": [
"Show opponent active and bench",
"Display card backs for hand (count only)",
"Show deck and discard counts",
"Prize card display (face down)",
"Opponent name and avatar"
]
},
{
"id": "F4-009",
"name": "Implement forced action handling",
"description": "Handle required actions (select prize, new active)",
"files": ["src/components/game/ForcedActionModal.vue"],
"details": [
"Prize card selection after KO",
"New active Pokemon selection when active KO'd",
"Discard selection for certain effects",
"Block other actions until resolved"
]
},
{
"id": "F4-010",
"name": "Implement game over screen",
"description": "Display results and return to menu",
"files": ["src/components/game/GameOverModal.vue"],
"details": [
"Victory/Defeat display",
"Show win reason (prizes, deck out, etc.)",
"Game statistics (turns, cards played)",
"Return to lobby button",
"Rematch option (future)"
]
}
]
},
{
"id": "PHASE_F5",
"name": "Polish & UX",
"status": "NOT_STARTED",
"description": "Reconnection handling, animations, turn timer, error states",
"estimatedDays": "5-7",
"dependencies": ["PHASE_F4"],
"backendDependencies": ["PHASE_4"],
"deliverables": [
"Reconnection with state recovery",
"Turn timer display",
"Opponent connection status",
"Card play animations",
"Attack animations",
"Loading and error states",
"Sound effects (optional)"
],
"tasks": [
{
"id": "F5-001",
"name": "Implement reconnection flow",
"description": "Handle disconnects gracefully",
"files": ["src/socket/reconnection.ts", "src/components/game/ReconnectingOverlay.vue"],
"details": [
"Detect disconnect",
"Show reconnecting overlay",
"Auto-reconnect with backoff",
"Restore game state on reconnect",
"Handle failed reconnection"
]
},
{
"id": "F5-002",
"name": "Implement turn timer display",
"description": "Visual countdown for turn time",
"files": ["src/components/game/TurnTimer.vue"],
"details": [
"Countdown display from game state",
"Warning color at thresholds",
"Pulse animation when low",
"Handle timeout notification"
]
},
{
"id": "F5-003",
"name": "Show opponent connection status",
"description": "Indicate if opponent is connected",
"files": ["src/components/game/OpponentStatus.vue"],
"details": [
"Online/offline indicator",
"Handle game:opponent_connected events",
"Show 'waiting for opponent' if disconnected"
]
},
{
"id": "F5-004",
"name": "Add card play animations",
"description": "Animate cards moving between zones",
"files": ["src/game/animations/CardAnimations.ts"],
"details": [
"Draw card animation (deck to hand)",
"Play to bench animation",
"Evolve animation",
"Attach energy animation",
"Discard animation"
]
},
{
"id": "F5-005",
"name": "Add attack animations",
"description": "Visual feedback for attacks",
"files": ["src/game/animations/AttackAnimations.ts"],
"details": [
"Attack motion (active toward opponent)",
"Damage numbers",
"KO animation (fade out)",
"Status effect indicators",
"Type-specific visual effects (optional)"
]
},
{
"id": "F5-006",
"name": "Add loading and error states",
"description": "Feedback for async operations",
"files": ["src/components/ui/LoadingSpinner.vue", "src/components/ui/Toast.vue"],
"details": [
"Skeleton loaders for lists",
"Button loading states",
"Error toast notifications",
"Retry prompts for failed requests"
]
},
{
"id": "F5-007",
"name": "Add sound effects (optional)",
"description": "Audio feedback for game events",
"files": ["src/game/audio/SoundManager.ts"],
"details": [
"Card play sound",
"Attack sound",
"Turn change chime",
"Victory/defeat music",
"Volume controls",
"Mute option"
]
}
]
},
{
"id": "PHASE_F6",
"name": "Campaign Mode",
"status": "NOT_STARTED",
"description": "Single-player campaign UI - clubs, NPCs, progression, rewards",
"estimatedDays": "7-10",
"dependencies": ["PHASE_F5"],
"backendDependencies": ["PHASE_5"],
"blocked": true,
"blockedReason": "Requires backend Phase 5 (Campaign Mode)",
"deliverables": [
"Campaign world map",
"Club selection",
"NPC opponent display",
"Medal/progression tracking",
"Reward notifications",
"Difficulty indicators"
]
},
{
"id": "PHASE_F7",
"name": "Multiplayer & Matchmaking",
"status": "NOT_STARTED",
"description": "PvP matchmaking queue, invite links, match history",
"estimatedDays": "5-7",
"dependencies": ["PHASE_F5"],
"backendDependencies": ["PHASE_6"],
"blocked": true,
"blockedReason": "Requires backend Phase 6 (Multiplayer)",
"deliverables": [
"Matchmaking queue UI",
"Queue status and cancel",
"Invite link generation",
"Join via invite link",
"Match history page",
"Player stats display"
]
},
{
"id": "PHASE_F8",
"name": "Pack Opening & Rewards",
"status": "NOT_STARTED",
"description": "Animated pack opening experience",
"estimatedDays": "5-7",
"dependencies": ["PHASE_F6"],
"backendDependencies": ["PHASE_5"],
"blocked": true,
"blockedReason": "Requires backend Phase 5 reward system",
"deliverables": [
"Pack opening Phaser scene",
"Card reveal animations",
"Rarity celebration effects",
"New card indicators in collection"
]
}
],
"criticalPath": ["PHASE_F0", "PHASE_F1", "PHASE_F3", "PHASE_F4"],
"parallelDevelopment": {
"note": "F0-F5 can proceed while backend completes Phase 5-6",
"tracks": [
{
"name": "Frontend Core",
"phases": ["PHASE_F0", "PHASE_F1", "PHASE_F2", "PHASE_F3", "PHASE_F4", "PHASE_F5"]
},
{
"name": "Backend Campaign",
"phases": ["PHASE_5"]
},
{
"name": "Backend Multiplayer",
"phases": ["PHASE_6"]
}
],
"joinPoints": [
{
"frontend": "PHASE_F6",
"backend": "PHASE_5",
"description": "Campaign UI requires campaign backend"
},
{
"frontend": "PHASE_F7",
"backend": "PHASE_6",
"description": "Matchmaking UI requires multiplayer backend"
}
]
},
"designSystem": {
"colors": {
"note": "Pokemon type-inspired palette",
"types": {
"grass": "#78C850",
"fire": "#F08030",
"water": "#6890F0",
"lightning": "#F8D030",
"psychic": "#F85888",
"fighting": "#C03028",
"darkness": "#705848",
"metal": "#B8B8D0",
"fairy": "#EE99AC",
"dragon": "#7038F8",
"colorless": "#A8A878"
},
"ui": {
"primary": "#3B82F6",
"secondary": "#6366F1",
"success": "#22C55E",
"warning": "#F59E0B",
"error": "#EF4444",
"background": "#1F2937",
"surface": "#374151",
"text": "#F9FAFB"
}
},
"typography": {
"headings": "Press Start 2P or similar pixel font for retro feel",
"body": "Inter or system font stack"
}
},
"testingStrategy": {
"unit": {
"tool": "Vitest",
"scope": "Composables, stores, utilities",
"location": "src/**/*.test.ts"
},
"component": {
"tool": "Vitest + Vue Test Utils",
"scope": "Vue components in isolation",
"location": "src/**/*.test.ts"
},
"e2e": {
"tool": "Playwright",
"scope": "Full user flows",
"location": "e2e/",
"deferred": "After F4 complete"
}
},
"risks": [
{
"risk": "Phaser learning curve",
"mitigation": "Start F3 with simple prototypes, iterate",
"priority": "high"
},
{
"risk": "Vue-Phaser state sync complexity",
"mitigation": "Establish clear patterns in F3, document bridge API",
"priority": "high"
},
{
"risk": "Mobile touch interactions",
"mitigation": "Design for touch from start, test on real devices",
"priority": "medium"
},
{
"risk": "Asset loading performance",
"mitigation": "Lazy load cards, use sprite atlases, implement caching",
"priority": "medium"
}
]
}

View File

@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

View File

@ -0,0 +1,37 @@
import js from '@eslint/js'
import globals from 'globals'
import vue from 'eslint-plugin-vue'
import tseslint from 'typescript-eslint'
export default [
js.configs.recommended,
...tseslint.configs.recommended,
...vue.configs['flat/recommended'],
{
files: ['**/*.{ts,vue}'],
languageOptions: {
globals: {
...globals.browser,
},
parserOptions: {
parser: tseslint.parser,
},
},
rules: {
// Vue rules
'vue/multi-word-component-names': 'off',
'vue/require-default-prop': 'off',
// TypeScript rules
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
// General rules - allow console in dev guards
'no-console': 'off',
},
},
{
ignores: ['dist/', 'node_modules/', '*.config.js', '*.config.ts'],
},
]

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>mantimon-frontend-scaffold</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

5683
.claude/frontend-poc/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,45 @@
{
"name": "mantimon-tcg-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"test": "vitest",
"test:watch": "vitest --watch",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@vueuse/core": "^14.2.0",
"mitt": "^3.0.1",
"phaser": "^3.90.0",
"pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.2.0",
"socket.io-client": "^4.8.1",
"vue": "^3.5.24",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.21",
"eslint": "^9.21.0",
"eslint-plugin-vue": "^10.1.0",
"globals": "^17.2.0",
"jsdom": "^27.4.0",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.4",
"typescript": "~5.9.3",
"typescript-eslint": "^8.54.0",
"vite": "^7.2.4",
"vitest": "^3.1.2",
"vue-tsc": "^3.1.4"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,195 @@
{
"meta": {
"phaseId": "PHASE_F0",
"name": "Project Foundation",
"version": "1.0.0",
"created": "2026-01-30",
"lastUpdated": "2026-01-30",
"totalTasks": 8,
"completedTasks": 8,
"status": "completed"
},
"tasks": [
{
"id": "F0-001",
"name": "Initialize Vite project",
"description": "Create Vue 3 + TypeScript project with Vite",
"category": "setup",
"priority": 1,
"completed": true,
"tested": true,
"dependencies": [],
"files": [
{"path": "package.json", "status": "create"},
{"path": "vite.config.ts", "status": "create"},
{"path": "tsconfig.json", "status": "create"}
],
"details": [
"npm create vite@latest . -- --template vue-ts",
"Configure path aliases (@/ for src/)",
"Set up TypeScript strict mode"
],
"notes": "Completed in initial scaffold commit b9b803d"
},
{
"id": "F0-002",
"name": "Install and configure Tailwind",
"description": "Set up Tailwind CSS with custom theme",
"category": "styling",
"priority": 2,
"completed": true,
"tested": true,
"dependencies": ["F0-001"],
"files": [
{"path": "tailwind.config.js", "status": "create"},
{"path": "src/assets/main.css", "status": "create"}
],
"details": [
"Install tailwindcss, postcss, autoprefixer",
"Configure content paths",
"Add Mantimon color palette (Pokemon-inspired)"
],
"notes": "Completed in initial scaffold - using Tailwind v4 with @tailwindcss/postcss"
},
{
"id": "F0-003",
"name": "Set up Vue Router",
"description": "Configure routing with guards and lazy loading",
"category": "setup",
"priority": 3,
"completed": true,
"tested": true,
"dependencies": ["F0-001"],
"files": [
{"path": "src/router/index.ts", "status": "modify"},
{"path": "src/router/guards.ts", "status": "create"}
],
"details": [
"Define route structure per sitePlan",
"Create auth guard (requireAuth, requireGuest)",
"Create starter guard (redirect if no starter deck)",
"Configure lazy loading for heavy routes",
"Extract guards to separate file"
],
"notes": "Partial - router/index.ts exists with basic routes and inline guard. Needs guards.ts extraction and starter deck guard."
},
{
"id": "F0-004",
"name": "Set up Pinia stores",
"description": "Create store structure with persistence",
"category": "stores",
"priority": 4,
"completed": true,
"tested": true,
"dependencies": ["F0-001"],
"files": [
{"path": "src/stores/auth.ts", "status": "modify"},
{"path": "src/stores/user.ts", "status": "create"},
{"path": "src/stores/ui.ts", "status": "create"}
],
"details": [
"Install pinia and pinia-plugin-persistedstate (done)",
"Update auth store for OAuth flow (not username/password)",
"Create user store skeleton (display_name, avatar, linked accounts)",
"Create UI store (loading states, toasts, modals)"
],
"notes": "Partial - auth.ts exists but uses username/password pattern instead of OAuth. Needs user.ts and ui.ts."
},
{
"id": "F0-005",
"name": "Create API client",
"description": "HTTP client with auth token injection and refresh",
"category": "api",
"priority": 5,
"completed": true,
"tested": true,
"dependencies": ["F0-001", "F0-004"],
"files": [
{"path": "src/api/client.ts", "status": "create"},
{"path": "src/api/types.ts", "status": "create"}
],
"details": [
"Create fetch wrapper with base URL from config",
"Inject Authorization header from auth store",
"Handle 401 responses with automatic token refresh",
"Type API responses (ApiError, ApiResponse<T>)",
"Add request/response interceptor pattern"
],
"notes": "Not started. Critical for all backend communication."
},
{
"id": "F0-006",
"name": "Create Socket.IO client",
"description": "WebSocket connection manager",
"category": "api",
"priority": 6,
"completed": true,
"tested": true,
"dependencies": ["F0-001", "F0-004"],
"files": [
{"path": "src/socket/client.ts", "status": "create"},
{"path": "src/socket/types.ts", "status": "create"}
],
"details": [
"Install socket.io-client (done)",
"Create connection manager singleton",
"Configure auth token in handshake",
"Set up reconnection with exponential backoff",
"Create typed event emitters for game namespace"
],
"notes": "Not started. socket.io-client is installed."
},
{
"id": "F0-007",
"name": "Create app shell",
"description": "Basic layout with navigation",
"category": "components",
"priority": 7,
"completed": true,
"tested": true,
"dependencies": ["F0-001", "F0-002", "F0-003"],
"files": [
{"path": "src/App.vue", "status": "modify"},
{"path": "src/layouts/DefaultLayout.vue", "status": "create"},
{"path": "src/layouts/MinimalLayout.vue", "status": "create"},
{"path": "src/layouts/GameLayout.vue", "status": "create"},
{"path": "src/components/NavSidebar.vue", "status": "create"},
{"path": "src/components/NavBottomTabs.vue", "status": "create"},
{"path": "src/components/ui/LoadingOverlay.vue", "status": "create"},
{"path": "src/components/ui/ToastContainer.vue", "status": "create"},
{"path": "src/types/vue-router.d.ts", "status": "create"}
],
"details": [
"Create DefaultLayout with sidebar (desktop) / bottom tabs (mobile)",
"Create MinimalLayout for login/auth pages (centered, no nav)",
"Create GameLayout for match page (full viewport, no nav)",
"Responsive nav: NavSidebar for md+, NavBottomTabs for mobile",
"Loading overlay component tied to UI store",
"Toast notification container tied to UI store"
],
"notes": "Completed - App.vue uses dynamic layout switching based on route meta. All layouts and navigation components created with tests."
},
{
"id": "F0-008",
"name": "Environment configuration",
"description": "Configure environment variables",
"category": "setup",
"priority": 8,
"completed": true,
"tested": true,
"dependencies": ["F0-001"],
"files": [
{"path": ".env.development", "status": "create"},
{"path": ".env.production", "status": "create"},
{"path": "src/config.ts", "status": "create"}
],
"details": [
"Create .env.development with local API URLs",
"Create .env.production with production API URLs",
"Create type-safe config.ts that reads VITE_* vars",
"Define: VITE_API_BASE_URL, VITE_WS_URL, VITE_OAUTH_REDIRECT_URI"
],
"notes": "Not started. Needed before API client can work properly."
}
]
}

View File

@ -0,0 +1,382 @@
{
"meta": {
"phaseId": "PHASE_F1",
"name": "Authentication Flow",
"version": "1.0.0",
"created": "2026-01-30",
"lastUpdated": "2026-01-30T23:35:00Z",
"totalTasks": 10,
"completedTasks": 10,
"status": "COMPLETE",
"description": "Complete OAuth authentication flow including login, callback handling, starter deck selection, profile management, and app initialization."
},
"dependencies": {
"phases": ["PHASE_F0"],
"backend": [
"GET /api/auth/google - Start Google OAuth",
"GET /api/auth/discord - Start Discord OAuth",
"GET /api/auth/{provider}/callback - OAuth callback (returns tokens in URL fragment)",
"POST /api/auth/refresh - Refresh access token",
"POST /api/auth/logout - Revoke refresh token",
"POST /api/auth/logout-all - Revoke all tokens (requires auth)",
"GET /api/users/me - Get current user profile",
"PATCH /api/users/me - Update profile",
"GET /api/users/me/linked-accounts - List linked OAuth accounts",
"GET /api/users/me/starter-status - Check if user has starter deck",
"POST /api/users/me/starter-deck - Select starter deck",
"GET /api/auth/link/google - Link Google account (requires auth)",
"GET /api/auth/link/discord - Link Discord account (requires auth)",
"DELETE /api/users/me/link/{provider} - Unlink OAuth provider"
]
},
"tasks": [
{
"id": "F1-001",
"name": "Update LoginPage for OAuth",
"description": "Replace username/password form with OAuth provider buttons",
"category": "pages",
"priority": 1,
"completed": true,
"tested": true,
"dependencies": [],
"files": [
{"path": "src/pages/LoginPage.vue", "status": "modify"}
],
"details": [
"Remove username/password form (not used - OAuth only)",
"Add Google OAuth button with branded styling",
"Add Discord OAuth button with branded styling",
"Handle redirect to OAuth provider via auth store",
"Show error messages from URL query params (oauth_failed)",
"Responsive design for mobile/desktop",
"Add loading state during redirect"
],
"acceptance": [
"Page shows two OAuth buttons: Google and Discord",
"Clicking button redirects to backend OAuth endpoint",
"Error messages from failed OAuth are displayed",
"No username/password fields visible"
]
},
{
"id": "F1-002",
"name": "Implement AuthCallbackPage",
"description": "Handle OAuth callback and extract tokens from URL fragment",
"category": "pages",
"priority": 2,
"completed": true,
"tested": true,
"dependencies": ["F1-001"],
"files": [
{"path": "src/pages/AuthCallbackPage.vue", "status": "modify"}
],
"details": [
"Parse URL hash fragment for access_token, refresh_token, expires_in",
"Handle error query params (error, message)",
"Store tokens in auth store using setTokens()",
"Fetch user profile after successful auth",
"Check if user needs starter deck selection",
"Redirect to starter selection if no starter, else to dashboard",
"Show appropriate loading/error states",
"Handle edge cases (missing tokens, network errors)"
],
"acceptance": [
"Successfully extracts tokens from URL fragment",
"Stores tokens in auth store (persisted)",
"Fetches user profile after auth",
"Redirects to /starter if user has no starter deck",
"Redirects to / (dashboard) if user has starter deck",
"Shows error message if OAuth failed"
]
},
{
"id": "F1-003",
"name": "Create useAuth composable",
"description": "Vue composable for auth operations with loading/error states",
"category": "composables",
"priority": 3,
"completed": true,
"tested": true,
"dependencies": ["F1-002"],
"files": [
{"path": "src/composables/useAuth.ts", "status": "create"},
{"path": "src/composables/useAuth.spec.ts", "status": "create"}
],
"details": [
"Wrap auth store operations with loading/error handling",
"Provide initiateOAuth(provider) helper",
"Provide handleCallback() for AuthCallbackPage",
"Provide logout() with redirect to login",
"Provide logoutAll() for all-device logout",
"Track isInitialized state for app startup",
"Auto-fetch profile on initialization if tokens exist"
],
"acceptance": [
"initiateOAuth() redirects to correct OAuth URL",
"handleCallback() extracts tokens and fetches profile",
"logout() clears state and redirects to login",
"Loading and error states are properly tracked"
]
},
{
"id": "F1-004",
"name": "Implement app auth initialization",
"description": "Initialize auth state on app startup",
"category": "setup",
"priority": 4,
"completed": true,
"tested": true,
"dependencies": ["F1-003"],
"files": [
{"path": "src/App.vue", "status": "modify"},
{"path": "src/main.ts", "status": "modify"}
],
"details": [
"Call auth.init() on app startup (in main.ts or App.vue)",
"Show loading state while initializing auth",
"Validate existing tokens by refreshing if expired",
"Fetch user profile if authenticated",
"Handle initialization errors gracefully",
"Block navigation until auth is initialized"
],
"acceptance": [
"App shows loading spinner during auth init",
"Expired tokens are refreshed automatically",
"User profile is fetched if authenticated",
"Invalid/expired refresh tokens trigger logout",
"Navigation guards work after init completes"
]
},
{
"id": "F1-005",
"name": "Implement StarterSelectionPage",
"description": "Complete starter deck selection with API integration",
"category": "pages",
"priority": 5,
"completed": true,
"tested": true,
"dependencies": ["F1-003"],
"files": [
{"path": "src/pages/StarterSelectionPage.vue", "status": "modify"},
{"path": "src/composables/useStarter.ts", "status": "create"},
{"path": "src/composables/useStarter.spec.ts", "status": "create"}
],
"details": [
"Display 5 starter deck options: grass, fire, water, psychic, lightning",
"Show deck preview (card count, theme description)",
"Handle deck selection with confirmation",
"Call POST /api/users/me/starter-deck on selection",
"Show loading state during API call",
"Handle errors (already selected, network error)",
"Redirect to dashboard on success",
"Update auth store hasStarterDeck flag"
],
"starterTypes": [
{"type": "grass", "name": "Forest Guardians", "description": "Growth and healing focused deck"},
{"type": "fire", "name": "Flame Warriors", "description": "Aggressive damage-focused deck"},
{"type": "water", "name": "Tidal Force", "description": "Balanced control and damage"},
{"type": "psychic", "name": "Mind Masters", "description": "Status effects and manipulation"},
{"type": "lightning", "name": "Storm Riders", "description": "Fast, high-damage strikes"}
],
"acceptance": [
"5 starter deck options displayed with themes",
"Selection calls API with correct starter_type",
"Success updates user state and redirects to /",
"Errors are displayed to user",
"Already-selected error handled gracefully"
]
},
{
"id": "F1-006",
"name": "Implement ProfilePage",
"description": "User profile management with linked accounts",
"category": "pages",
"priority": 6,
"completed": true,
"tested": true,
"dependencies": ["F1-003"],
"files": [
{"path": "src/pages/ProfilePage.vue", "status": "modify"},
{"path": "src/composables/useProfile.ts", "status": "create"},
{"path": "src/composables/useProfile.spec.ts", "status": "create"},
{"path": "src/components/profile/LinkedAccountCard.vue", "status": "create"},
{"path": "src/components/profile/DisplayNameEditor.vue", "status": "create"}
],
"details": [
"Display user avatar and display name",
"Editable display name with save button",
"List linked OAuth accounts (Google, Discord)",
"Link additional OAuth provider button",
"Unlink OAuth provider (if not primary)",
"Logout button (current session)",
"Logout All button (all devices)",
"Show active session count"
],
"acceptance": [
"Profile displays user info correctly",
"Display name can be edited and saved",
"Linked accounts are displayed",
"Can link additional OAuth provider",
"Can unlink non-primary provider",
"Logout works correctly",
"Logout All works correctly"
]
},
{
"id": "F1-007",
"name": "Update navigation for auth state",
"description": "Update NavSidebar and NavBottomTabs for auth state",
"category": "components",
"priority": 7,
"completed": true,
"tested": true,
"dependencies": ["F1-003"],
"files": [
{"path": "src/components/NavSidebar.vue", "status": "modify"},
{"path": "src/components/NavBottomTabs.vue", "status": "modify"},
{"path": "src/components/NavSidebar.spec.ts", "status": "create"},
{"path": "src/components/NavBottomTabs.spec.ts", "status": "create"}
],
"details": [
"Show user avatar in nav if available",
"Use actual display name instead of placeholder",
"Ensure logout button triggers proper logout flow",
"Handle loading state during logout"
],
"acceptance": [
"Nav shows actual user avatar if available",
"Nav shows actual display name",
"Logout triggers full logout flow with redirect"
]
},
{
"id": "F1-008",
"name": "Implement account linking flow",
"description": "Allow users to link additional OAuth providers",
"category": "features",
"priority": 8,
"completed": true,
"tested": true,
"dependencies": ["F1-006"],
"files": [
{"path": "src/composables/useAccountLinking.ts", "status": "create"},
{"path": "src/composables/useAccountLinking.spec.ts", "status": "create"},
{"path": "src/pages/LinkCallbackPage.vue", "status": "create"},
{"path": "src/router/index.ts", "status": "modify"}
],
"details": [
"Add route for /auth/link/callback to handle linking callbacks",
"Initiate linking via GET /api/auth/link/{provider}",
"Handle success/error query params on callback",
"Refresh linked accounts list after linking",
"Show success toast on link complete",
"Handle errors (already linked, etc.)"
],
"acceptance": [
"Can initiate link from profile page",
"Link callback handles success and error",
"Linked accounts list updates after linking",
"Appropriate feedback shown to user"
]
},
{
"id": "F1-009",
"name": "Add requireStarter guard implementation",
"description": "Implement the starter deck navigation guard",
"category": "router",
"priority": 9,
"completed": true,
"tested": true,
"dependencies": ["F1-005"],
"files": [
{"path": "src/router/guards.ts", "status": "modify"},
{"path": "src/router/guards.spec.ts", "status": "modify"}
],
"details": [
"requireStarter checks if user has selected starter deck",
"If no starter, redirect to /starter page",
"Check auth.user?.hasStarterDeck flag",
"If flag is undefined, fetch starter status from API",
"Cache result to avoid repeated API calls"
],
"acceptance": [
"Users without starter deck are redirected to /starter",
"Users with starter deck can access protected routes",
"Guard waits for auth initialization before checking",
"API is called only when needed"
]
},
{
"id": "F1-010",
"name": "Write integration tests for auth flow",
"description": "End-to-end tests for complete auth flow",
"category": "testing",
"priority": 10,
"completed": true,
"tested": true,
"dependencies": ["F1-001", "F1-002", "F1-003", "F1-004", "F1-005"],
"files": [
{"path": "src/pages/LoginPage.spec.ts", "status": "create"},
{"path": "src/pages/AuthCallbackPage.spec.ts", "status": "create"},
{"path": "src/pages/StarterSelectionPage.spec.ts", "status": "create"},
{"path": "src/pages/ProfilePage.spec.ts", "status": "create"}
],
"details": [
"Test LoginPage OAuth button redirects",
"Test AuthCallbackPage token extraction",
"Test AuthCallbackPage error handling",
"Test StarterSelectionPage deck selection flow",
"Test ProfilePage display and edit operations",
"Test navigation guards with various auth states",
"Mock API responses for all tests"
],
"acceptance": [
"All page components have test files",
"Tests cover happy path and error cases",
"Tests mock API calls appropriately",
"All tests pass"
]
}
],
"apiContracts": {
"oauthCallback": {
"description": "Backend redirects to frontend with tokens in URL fragment",
"format": "/auth/callback#access_token={token}&refresh_token={token}&expires_in={seconds}",
"errorFormat": "/auth/callback?error={code}&message={message}"
},
"tokenResponse": {
"accessToken": "JWT access token (short-lived)",
"refreshToken": "Opaque refresh token (long-lived)",
"expiresIn": "Access token expiry in seconds"
},
"userProfile": {
"id": "UUID",
"display_name": "string",
"avatar_url": "string | null",
"has_starter_deck": "boolean",
"created_at": "ISO datetime",
"linked_accounts": [
{
"provider": "google | discord",
"email": "string | null",
"linked_at": "ISO datetime"
}
]
},
"starterDeck": {
"request": {
"starter_type": "grass | fire | water | psychic | lightning"
},
"response": "DeckResponse with is_starter=true"
}
},
"notes": [
"OAuth flow uses URL fragment (hash) for tokens, not query params, for security",
"Tokens are persisted via pinia-plugin-persistedstate",
"Auth store already has most functionality, composables add loading/error handling",
"LoginPage currently has username/password form which needs to be replaced",
"AuthCallbackPage currently just redirects to home - needs full implementation",
"StarterSelectionPage has placeholder UI - needs API integration",
"ProfilePage needs to be created from scratch"
]
}

View File

@ -0,0 +1,624 @@
{
"meta": {
"phaseId": "PHASE_F2",
"name": "Deck Management",
"version": "1.3.0",
"created": "2026-01-30",
"lastUpdated": "2026-01-31",
"totalTasks": 12,
"completedTasks": 12,
"status": "COMPLETE",
"completedDate": "2026-01-31",
"auditStatus": "PASSED",
"auditDate": "2026-01-31",
"auditNotes": "0 issues, 3 minor warnings. All previous issues resolved: DeckContents refactored (527->292 lines), hardcoded values replaced with useGameConfig, toast feedback added, validation errors surfaced.",
"description": "Collection viewing, deck building, and card management with drag-and-drop deck editor and real-time validation.",
"designReference": "src/styles/DESIGN_REFERENCE.md"
},
"stylingPrinciples": [
"Follow patterns in DESIGN_REFERENCE.md for visual consistency",
"Type-colored accents on all card components (borders, badges)",
"Hover states with scale/shadow transitions on interactive cards",
"Skeleton loaders for all loading states (not spinners)",
"Empty states with icon, message, and CTA",
"Mobile-first responsive grids",
"Business rules (deck size, card limits) from constants/API, never hardcoded"
],
"dependencies": {
"phases": ["PHASE_F0", "PHASE_F1"],
"backend": [
"GET /api/collections/me - Get user's card collection",
"GET /api/collections/me/cards/{card_id} - Get specific card quantity",
"GET /api/decks - List user's decks",
"POST /api/decks - Create new deck",
"GET /api/decks/{id} - Get deck by ID",
"PUT /api/decks/{id} - Update deck",
"DELETE /api/decks/{id} - Delete deck",
"POST /api/decks/validate - Validate deck without saving",
"GET /api/cards/definitions - Get card definitions (for display)"
]
},
"tasks": [
{
"id": "F2-001",
"name": "Create collection and deck stores",
"description": "Create Pinia stores for managing collection and deck state",
"category": "stores",
"priority": 1,
"completed": true,
"tested": true,
"dependencies": [],
"files": [
{"path": "src/stores/collection.ts", "status": "create"},
{"path": "src/stores/collection.spec.ts", "status": "create"},
{"path": "src/stores/deck.ts", "status": "create"},
{"path": "src/stores/deck.spec.ts", "status": "create"}
],
"details": [
"Create useCollectionStore with state: cards (CollectionCard[]), isLoading, error",
"Add getters: totalCards, uniqueCards, getCardQuantity(definitionId)",
"Add actions: fetchCollection(), clearCollection()",
"Create useDeckStore with state: decks (Deck[]), currentDeck, isLoading, error",
"Add getters: deckCount, getDeckById(id), starterDeck",
"Add actions: fetchDecks(), fetchDeck(id), createDeck(), updateDeck(), deleteDeck(), setCurrentDeck()",
"Follow setup store pattern from auth.ts",
"Do NOT fetch via apiClient directly in store - just state management"
],
"acceptance": [
"Both stores created with typed state",
"Stores follow setup store pattern",
"Unit tests cover all getters and actions",
"Stores integrate with existing type definitions"
]
},
{
"id": "F2-002",
"name": "Create useCollection composable",
"description": "Composable for fetching and managing the user's card collection",
"category": "composables",
"priority": 2,
"completed": true,
"tested": true,
"dependencies": ["F2-001"],
"files": [
{"path": "src/composables/useCollection.ts", "status": "create"},
{"path": "src/composables/useCollection.spec.ts", "status": "create"}
],
"details": [
"Wrap collection store with loading/error handling",
"Implement fetchCollection() calling GET /api/collections/me",
"Parse response into CollectionCard[] format",
"Handle empty collection gracefully",
"Provide filtering helpers: byType(type), byCategory(category), byRarity(rarity)",
"Provide search helper: searchByName(query)",
"Return readonly state wrappers per composable pattern",
"Handle network errors with user-friendly messages"
],
"acceptance": [
"Composable fetches collection from API",
"Filtering and search work correctly",
"Loading and error states properly managed",
"Tests cover API success, empty collection, and errors"
]
},
{
"id": "F2-003",
"name": "Create useDecks composable",
"description": "Composable for CRUD operations on user decks",
"category": "composables",
"priority": 3,
"completed": true,
"tested": true,
"dependencies": ["F2-001"],
"files": [
{"path": "src/composables/useDecks.ts", "status": "create"},
{"path": "src/composables/useDecks.spec.ts", "status": "create"}
],
"details": [
"Wrap deck store with loading/error handling",
"Implement fetchDecks() calling GET /api/decks",
"Implement fetchDeck(id) calling GET /api/decks/{id}",
"Implement createDeck(data) calling POST /api/decks",
"Implement updateDeck(id, data) calling PUT /api/decks/{id}",
"Implement deleteDeck(id) calling DELETE /api/decks/{id} with confirmation",
"Return result objects: { success, error?, data? }",
"Handle 404 (deck not found), 403 (not owner), validation errors",
"Track separate loading states for different operations if needed"
],
"acceptance": [
"All CRUD operations work correctly",
"Error handling covers common cases",
"Result objects returned consistently",
"Tests cover success and error paths for each operation"
]
},
{
"id": "F2-004",
"name": "Create card display components",
"description": "Reusable components for displaying cards at various sizes",
"category": "components",
"priority": 4,
"completed": true,
"tested": true,
"dependencies": [],
"files": [
{"path": "src/components/cards/CardImage.vue", "status": "create"},
{"path": "src/components/cards/CardDisplay.vue", "status": "create"},
{"path": "src/components/cards/CardDisplay.spec.ts", "status": "create"},
{"path": "src/components/cards/TypeBadge.vue", "status": "create"}
],
"details": [
"CardImage: Simple img wrapper with loading state and fallback",
"CardImage props: src (string), alt (string), size ('sm' | 'md' | 'lg')",
"CardImage: Handle image load error with placeholder",
"CardDisplay: Full card component with name, HP, type badge",
"CardDisplay props: card (CardDefinition), size, showQuantity (number), selectable, selected, disabled",
"CardDisplay emits: click, select",
"TypeBadge: Reusable type indicator with icon and colored background",
"Apply type-colored border based on card.type (use type color mapping from DESIGN_REFERENCE)",
"Mobile-friendly touch targets (min 44px)",
"Support thumbnail (grid), medium (list), large (detail) sizes"
],
"styling": [
"Use 'Card with Type Accent' pattern from DESIGN_REFERENCE.md",
"Hover: scale-[1.02], -translate-y-1, shadow-xl with transition-all duration-200",
"Selected: ring-2 ring-primary ring-offset-2 ring-offset-background",
"Disabled: opacity-50 grayscale cursor-not-allowed",
"Quantity badge: absolute positioned, bg-primary, rounded-full (see 'Quantity Badge' pattern)",
"Type badge: inline-flex with type background color (see 'Type Badge' pattern)",
"Card container: bg-surface rounded-xl border-2 shadow-md",
"Image loading: pulse animation placeholder, smooth fade-in on load"
],
"acceptance": [
"CardImage handles loading and errors gracefully",
"CardDisplay shows card info with type-appropriate styling",
"Three sizes work correctly",
"Hover and selection states visible and smooth",
"Disabled state clearly distinguishable",
"Tests verify rendering and events"
]
},
{
"id": "F2-005",
"name": "Create card detail modal",
"description": "Full card view with all details in a modal overlay",
"category": "components",
"priority": 5,
"completed": true,
"tested": true,
"dependencies": ["F2-004"],
"files": [
{"path": "src/components/cards/CardDetailModal.vue", "status": "create"},
{"path": "src/components/cards/CardDetailModal.spec.ts", "status": "create"},
{"path": "src/components/cards/AttackDisplay.vue", "status": "create"},
{"path": "src/components/cards/EnergyCost.vue", "status": "create"}
],
"details": [
"Props: card (CardDefinition | null), isOpen (boolean), ownedQuantity (number)",
"Emits: close",
"Large card image with CardImage component",
"Display all card fields: name, HP, type, category, rarity",
"AttackDisplay: Reusable attack row with energy cost, name, damage, effect",
"EnergyCost: Row of energy type icons for attack costs",
"Display weakness, resistance, retreat cost",
"Display owned quantity badge",
"Display set info (setId, setNumber)",
"Use Teleport to render in body",
"Close on backdrop click, Escape key, or close button",
"Trap focus within modal for accessibility",
"Animate in/out with transition"
],
"styling": [
"Backdrop: bg-black/60 backdrop-blur-sm (see 'Modal Backdrop' pattern)",
"Container: bg-surface rounded-2xl shadow-2xl max-w-lg (see 'Modal Container' pattern)",
"Header: border-b border-surface-light with close button (see 'Modal Header' pattern)",
"Enter animation: fade-in zoom-in-95 duration-200",
"Card image: centered with type-colored border glow effect",
"Attack rows: bg-surface-light/50 rounded-lg p-3 with hover highlight",
"Energy icons: small colored circles or actual energy symbols",
"Stats section: grid layout for weakness/resistance/retreat",
"Owned quantity: prominent badge in corner of card image area"
],
"acceptance": [
"Modal displays all card information",
"Attacks rendered with energy costs",
"Owned quantity visible",
"Close methods all work",
"Smooth enter/exit animations",
"Keyboard accessible (Escape closes, focus trapped)"
]
},
{
"id": "F2-006",
"name": "Create collection page",
"description": "Grid view of owned cards with filtering and search",
"category": "pages",
"priority": 6,
"completed": true,
"tested": true,
"dependencies": ["F2-002", "F2-004", "F2-005"],
"files": [
{"path": "src/pages/CollectionPage.vue", "status": "create"},
{"path": "src/pages/CollectionPage.spec.ts", "status": "create"},
{"path": "src/components/ui/FilterBar.vue", "status": "create"},
{"path": "src/components/ui/SkeletonCard.vue", "status": "create"},
{"path": "src/components/ui/EmptyState.vue", "status": "create"}
],
"details": [
"Fetch collection on mount using useCollection()",
"Display cards in responsive grid (2 cols mobile, 3 md, 4 lg, 5 xl)",
"FilterBar component: type dropdown, category dropdown, rarity dropdown, search input",
"Show card count: 'Showing X of Y cards'",
"Each card shows quantity badge overlay",
"Click card to open CardDetailModal",
"EmptyState component: reusable empty state with icon, message, CTA",
"SkeletonCard component: reusable loading placeholder",
"Loading state: grid of SkeletonCards (match expected count or 8-12)",
"Error state: inline error with retry button",
"Debounce search input (300ms)",
"Filters persist in URL query params for sharing/bookmarking"
],
"styling": [
"Page header: text-2xl font-bold with card count subtitle",
"Filter bar: bg-surface rounded-xl p-4 mb-6 (see 'Filter Bar' pattern)",
"Search input: with SearchIcon, focus:border-primary focus:ring-1",
"Dropdowns: matching input styling for consistency",
"Grid: gap-3 md:gap-4, cards fill width of column",
"Skeleton cards: pulse animation, match card aspect ratio (see 'Skeleton Card' pattern)",
"Empty state: centered, py-16, icon + message + CTA (see 'Empty States' pattern)",
"Error state: bg-error/10 border-error/20 with AlertIcon (see 'Inline Error' pattern)",
"Smooth fade-in when cards load (transition-opacity)"
],
"acceptance": [
"Collection grid displays all owned cards",
"All filters work correctly",
"Search filters by card name",
"Card modal opens on click",
"Loading state shows skeleton grid",
"Empty state is visually polished",
"Error state allows retry",
"Mobile responsive"
]
},
{
"id": "F2-007",
"name": "Create deck list page",
"description": "View all user decks with create/edit/delete actions",
"category": "pages",
"priority": 7,
"completed": true,
"tested": true,
"dependencies": ["F2-003"],
"files": [
{"path": "src/pages/DecksPage.vue", "status": "create"},
{"path": "src/pages/DecksPage.spec.ts", "status": "create"},
{"path": "src/components/deck/DeckCard.vue", "status": "create"},
{"path": "src/components/deck/DeckCard.spec.ts", "status": "create"},
{"path": "src/components/ui/ConfirmDialog.vue", "status": "create"}
],
"details": [
"Fetch decks on mount using useDecks()",
"Display decks as cards in a grid (1 col mobile, 2 sm, 3 lg)",
"DeckCard component shows: name, card count (from API), validation status icon, starter badge",
"DeckCard shows mini preview of 3-4 Pokemon thumbnails from deck",
"Click deck to navigate to deck editor (/decks/:id)",
"'Create New Deck' button in header -> navigate to /decks/new",
"Delete button (trash icon) with ConfirmDialog",
"ConfirmDialog: reusable confirmation modal component",
"Show deck limit from API response: 'X / Y decks'",
"Empty state: 'No decks yet. Create your first deck!' with + button",
"Loading state: skeleton DeckCards",
"Starter deck: show badge, hide/disable delete button"
],
"styling": [
"Page header: flex justify-between with title and 'New Deck' button",
"New Deck button: btn-primary with PlusIcon",
"Grid: gap-4, cards have consistent height",
"DeckCard: bg-surface rounded-xl p-4 shadow-md hover:shadow-lg transition",
"DeckCard hover: subtle lift effect (translate-y, shadow)",
"Card preview: row of 3-4 mini card images (32x44px) with overlap",
"Validity icon: CheckCircle (text-success) or XCircle (text-error)",
"Starter badge: small pill 'Starter' with bg-primary/20 text-primary",
"Delete button: icon-only, text-text-muted hover:text-error, positioned top-right",
"ConfirmDialog: danger variant with red confirm button",
"Skeleton: match DeckCard dimensions with pulse animation",
"Empty state: centered with DeckIcon, use EmptyState component"
],
"acceptance": [
"All user decks displayed in polished cards",
"Can create new deck (navigates to builder)",
"Can delete non-starter decks with confirmation",
"Deck validity status clearly visible",
"Starter deck visually distinguished",
"Loading and empty states polished"
]
},
{
"id": "F2-008",
"name": "Create useDeckBuilder composable",
"description": "Composable for deck editing state and validation",
"category": "composables",
"priority": 8,
"completed": true,
"tested": true,
"dependencies": ["F2-003"],
"files": [
{"path": "src/composables/useDeckBuilder.ts", "status": "create"},
{"path": "src/composables/useDeckBuilder.spec.ts", "status": "create"}
],
"details": [
"Manage draft deck state (not yet saved)",
"State: deckName, deckCards (Map<cardDefinitionId, quantity>), energyConfig",
"Track isDirty (has unsaved changes)",
"addCard(cardDefinitionId, quantity=1) - respect 4-card limit per card (except basic energy)",
"removeCard(cardDefinitionId, quantity=1)",
"setDeckName(name)",
"clearDeck() - reset to empty",
"loadDeck(deck) - populate from existing deck for editing",
"Computed: totalCards, cardList (sorted), canAddCard(cardId)",
"Validation: call POST /api/decks/validate with debounce (500ms)",
"Track validationErrors array, isValid computed",
"save() - calls createDeck or updateDeck based on isNew flag"
],
"acceptance": [
"Can add/remove cards with limits enforced",
"Draft state separate from persisted decks",
"Validation runs on changes with debounce",
"isDirty tracks unsaved changes",
"save() creates or updates correctly"
]
},
{
"id": "F2-009",
"name": "Create deck builder page",
"description": "Two-panel deck editor with collection picker and deck contents",
"category": "pages",
"priority": 9,
"completed": true,
"tested": true,
"dependencies": ["F2-002", "F2-004", "F2-008"],
"files": [
{"path": "src/pages/DeckBuilderPage.vue", "status": "create"},
{"path": "src/pages/DeckBuilderPage.spec.ts", "status": "create"},
{"path": "src/components/deck/DeckEditor.vue", "status": "create"},
{"path": "src/components/deck/DeckContents.vue", "status": "create"},
{"path": "src/components/deck/CollectionPicker.vue", "status": "create"},
{"path": "src/components/deck/DeckCardRow.vue", "status": "create"},
{"path": "src/components/ui/ProgressBar.vue", "status": "create"}
],
"details": [
"Route: /decks/new (create) or /decks/:id (edit)",
"Load existing deck if editing (from route param)",
"Two-panel layout: CollectionPicker (left/top) + DeckContents (right/bottom)",
"Mobile: Stack panels vertically with tab switcher (Collection | Deck)",
"CollectionPicker: Filterable collection grid, click/tap to add card to deck",
"DeckContents: Scrollable list of cards with quantity controls (+/-)",
"DeckCardRow: Single card row with thumbnail, name, quantity stepper",
"Show cards grayed out if not available (quantity in collection exhausted)",
"Deck name input at top (editable)",
"ProgressBar: Reusable progress component with current/target props",
"Card count progress bar showing current/target from API/config",
"Validation errors displayed inline below progress bar",
"Save button (disabled if invalid or not dirty)",
"Cancel button with unsaved changes confirmation",
"Energy configuration section (collapsible, basic energy type buttons)"
],
"styling": [
"Page layout: sticky header with name input + actions, scrollable content below",
"Header: bg-surface border-b, flex items-center justify-between p-4",
"Deck name input: text-xl font-bold, minimal border (border-transparent focus:border-primary)",
"Two-panel: lg:flex lg:gap-6, CollectionPicker lg:w-2/3, DeckContents lg:w-1/3",
"Mobile tabs: sticky below header, bg-surface-light rounded-lg p-1, active tab bg-surface",
"CollectionPicker: bg-surface rounded-xl p-4, includes FilterBar and card grid",
"Cards in picker: show remaining quantity badge, disabled state if exhausted",
"DeckContents: bg-surface rounded-xl p-4, sticky on desktop",
"DeckCardRow: flex items-center gap-3, small thumbnail, name, quantity stepper",
"Quantity stepper: rounded-lg border, - and + buttons with number between",
"Progress bar: use 'Card Count Bar' pattern from DESIGN_REFERENCE (current/target props)",
"Validation errors: use 'Inline Error' pattern, list format if multiple",
"Save button: btn-primary, Cancel: btn-secondary",
"Energy section: collapsible with ChevronIcon, grid of energy type buttons"
],
"acceptance": [
"Can create new deck from scratch",
"Can edit existing deck",
"Collection filtering works in picker",
"Cannot add more cards than owned",
"Card limits enforced (from API validation)",
"Validation errors shown clearly with proper styling",
"Save/cancel work correctly with appropriate feedback",
"Unsaved changes prompt on navigation",
"Mobile tab switching works smoothly"
]
},
{
"id": "F2-010",
"name": "Add drag-and-drop support",
"description": "Optional drag-and-drop for adding/removing cards",
"category": "components",
"priority": 10,
"completed": true,
"tested": true,
"dependencies": ["F2-009"],
"files": [
{"path": "src/composables/useDragDrop.ts", "status": "create"},
{"path": "src/composables/useDragDrop.spec.ts", "status": "create"},
{"path": "src/components/deck/DeckEditor.vue", "status": "modify"},
{"path": "src/components/deck/CollectionPicker.vue", "status": "modify"},
{"path": "src/components/deck/DeckContents.vue", "status": "modify"}
],
"details": [
"Use HTML5 Drag and Drop API (no library needed)",
"Create useDragDrop composable for drag state management",
"Track: isDragging, draggedCard, dropTarget",
"Make collection cards draggable (draggable='true')",
"DeckContents as drop target",
"Touch support: use long-press (500ms) to initiate drag on mobile",
"Fallback: click-to-add still works (drag is enhancement)",
"Drop on collection area to remove from deck (or drag out of drop zone)",
"Accessible: all actions available via click as well"
],
"styling": [
"Dragging card: opacity-50 on original, cursor-grabbing",
"Drag ghost: slightly rotated (rotate-3), scale-105, shadow-2xl",
"Valid drop target: ring-2 ring-primary ring-dashed, bg-primary/5",
"Invalid drop target (card limit reached): ring-2 ring-error ring-dashed, bg-error/5",
"Drop zone highlight: animate pulse when dragging over",
"Card being dragged over deck: subtle insert indicator line",
"Long-press feedback on touch: scale down slightly before drag starts",
"Transition all visual states smoothly (duration-150)"
],
"acceptance": [
"Can drag cards from collection to deck",
"Can drag cards out of deck to remove",
"Visual feedback clearly indicates valid/invalid drops",
"Click-to-add still works as fallback",
"Touch devices can use long-press",
"All animations are smooth"
],
"notes": "This is an enhancement - if time constrained, click-to-add/remove is sufficient for MVP"
},
{
"id": "F2-011",
"name": "Add routes and navigation",
"description": "Configure routes for collection and deck pages",
"category": "setup",
"priority": 11,
"completed": true,
"tested": true,
"dependencies": ["F2-006", "F2-007", "F2-009"],
"files": [
{"path": "src/router/index.ts", "status": "modify"}
],
"details": [
"Add /collection route -> CollectionPage",
"Add /decks route -> DecksPage",
"Add /decks/new route -> DeckBuilderPage (create mode)",
"Add /decks/:id route -> DeckBuilderPage (edit mode)",
"All routes require auth and starter deck (meta: requiresAuth, requiresStarter)",
"Lazy load pages for code splitting",
"Add navigation guard for unsaved deck changes"
],
"acceptance": [
"All routes work correctly",
"Routes protected by auth",
"Lazy loading configured",
"Navigation warns about unsaved changes in deck builder"
]
},
{
"id": "F2-012",
"name": "Update types and API contracts",
"description": "Ensure frontend types match backend API contracts",
"category": "api",
"priority": 12,
"completed": true,
"tested": true,
"dependencies": ["F2-001"],
"files": [
{"path": "src/types/index.ts", "status": "modify"},
{"path": "src/types/api.ts", "status": "create"}
],
"details": [
"Review backend schemas: deck.py, collection.py, card.py",
"Ensure Deck, DeckCard, CollectionCard types match response shapes",
"Add API request/response types: DeckCreateRequest, DeckUpdateRequest",
"Add validation types: DeckValidationResponse, ValidationError",
"Add energy configuration types: EnergyConfig, EnergyCard",
"Export all types from index.ts",
"Document any frontend-only derived types"
],
"acceptance": [
"Frontend types match backend API exactly",
"All API operations are type-safe",
"No runtime type mismatches"
]
}
],
"apiContracts": {
"collection": {
"get": {
"endpoint": "GET /api/collections/me",
"response": {
"total_unique_cards": "number",
"total_card_count": "number",
"entries": [
{
"card_definition_id": "string",
"quantity": "number",
"source": "string",
"obtained_at": "ISO datetime"
}
]
}
}
},
"decks": {
"list": {
"endpoint": "GET /api/decks",
"response": {
"decks": ["DeckResponse[]"],
"deck_count": "number",
"deck_limit": "number"
}
},
"get": {
"endpoint": "GET /api/decks/{id}",
"response": "DeckResponse"
},
"create": {
"endpoint": "POST /api/decks",
"request": {
"name": "string",
"description": "string | null",
"cards": "Record<cardDefinitionId, quantity>",
"energy_cards": "Record<energyType, quantity>",
"deck_config": "DeckConfig | null"
},
"response": "DeckResponse"
},
"update": {
"endpoint": "PUT /api/decks/{id}",
"request": "Partial<DeckCreateRequest>",
"response": "DeckResponse"
},
"delete": {
"endpoint": "DELETE /api/decks/{id}",
"response": "204 No Content"
},
"validate": {
"endpoint": "POST /api/decks/validate",
"request": {
"cards": "Record<cardDefinitionId, quantity>",
"energy_cards": "Record<energyType, quantity>"
},
"response": {
"is_valid": "boolean",
"errors": ["string[]"]
}
}
},
"deckResponse": {
"id": "UUID",
"name": "string",
"description": "string | null",
"cards": "Record<cardDefinitionId, quantity>",
"energy_cards": "Record<energyType, quantity>",
"is_valid": "boolean",
"validation_errors": ["string[]"],
"is_starter": "boolean",
"starter_type": "string | null",
"created_at": "ISO datetime",
"updated_at": "ISO datetime"
}
},
"notes": [
"F2-003 (starter selection) was already implemented in F1 - this phase focuses on post-starter deck management",
"Drag-and-drop (F2-010) is an enhancement - click-based editing is sufficient for MVP",
"Energy configuration may be simplified if backend doesn't require it yet",
"Card images may need placeholder/fallback handling if CDN not yet configured",
"Consider virtualized scrolling for large collections in future optimization",
"All UI tasks include 'styling' arrays - follow patterns in src/styles/DESIGN_REFERENCE.md",
"Create reusable UI components (EmptyState, SkeletonCard, ConfirmDialog, ProgressBar, FilterBar) for consistency"
]
}

View File

@ -0,0 +1,580 @@
{
"meta": {
"phaseId": "PHASE_F3",
"name": "Phaser Integration",
"version": "1.0.0",
"created": "2026-01-31",
"lastUpdated": "2026-01-31",
"totalTasks": 12,
"completedTasks": 12,
"status": "COMPLETED",
"description": "Game rendering foundation - Phaser setup, board layout, card objects, Vue-Phaser communication bridge. This phase establishes the core infrastructure for rendering TCG matches.",
"designReference": "src/styles/DESIGN_REFERENCE.md"
},
"stylingPrinciples": [
"Game canvas fills viewport on game page (no scrollbars during match)",
"Card objects use type-colored borders matching design system",
"All interactive elements have clear hover/focus states",
"Touch targets minimum 44px for mobile play",
"Smooth 60fps animations - use Phaser tweens, not CSS",
"Loading states with progress indicator during asset loading",
"Responsive scaling maintains playable experience on all devices"
],
"dependencies": {
"phases": ["PHASE_F0", "PHASE_F1", "PHASE_F2"],
"backend": [
"POST /api/games - Create new game (for testing)",
"GET /api/games/{id} - Get game info",
"WebSocket game:state events - For state rendering tests"
],
"external": [
"Phaser 3 library",
"Card image assets (placeholder/CDN)"
]
},
"architectureNotes": {
"vuePhaser": {
"principle": "Phaser handles RENDERING ONLY. All game logic lives on backend.",
"pattern": "Vue component mounts Phaser game, communicates via typed EventEmitter bridge",
"state": "Pinia game store is source of truth. Phaser reads from store, never modifies.",
"cleanup": "Phaser game instance destroyed on component unmount to prevent memory leaks"
},
"eventBridge": {
"vueToPhaser": [
"game:state_updated - New state from server, Phaser should re-render",
"card:highlight - Highlight specific card (for tutorials, hints)",
"animation:request - Request Phaser to play specific animation",
"resize - Viewport resized, Phaser should rescale"
],
"phaserToVue": [
"card:clicked - User clicked a card (cardId, zone)",
"zone:clicked - User clicked a zone (zoneType, slotIndex)",
"animation:complete - Animation finished, Vue can proceed",
"ready - Phaser scene is ready and loaded"
]
},
"sceneFlow": {
"PreloadScene": "Load all required assets, show progress bar",
"MatchScene": "Main gameplay rendering, handles all card/board display"
},
"fileStructure": {
"src/game/": "All Phaser code lives here",
"src/game/config.ts": "Phaser game configuration",
"src/game/scenes/": "Scene classes",
"src/game/objects/": "Game objects (Card, Board, Zone)",
"src/game/bridge.ts": "Vue-Phaser event bridge",
"src/game/assets/": "Asset loading utilities",
"src/game/layout.ts": "Board layout calculations",
"src/game/scale.ts": "Responsive scaling logic"
}
},
"tasks": [
{
"id": "F3-001",
"name": "Install and configure Phaser",
"description": "Add Phaser 3 to the project with proper TypeScript configuration",
"category": "setup",
"priority": 1,
"completed": true,
"tested": true,
"dependencies": [],
"files": [
{"path": "package.json", "status": "modify"},
{"path": "src/game/config.ts", "status": "create"},
{"path": "src/game/index.ts", "status": "create"},
{"path": "tsconfig.json", "status": "modify"}
],
"details": [
"Install phaser: npm install phaser",
"Create src/game/config.ts with Phaser.Types.Core.GameConfig",
"Configure WebGL renderer with canvas fallback",
"Set transparent: true to allow CSS background styling",
"Configure physics: 'arcade' (minimal, for tween support)",
"Set parent element ID for mounting ('phaser-game')",
"Configure scale mode: Phaser.Scale.RESIZE for responsive",
"Export createGame(container: HTMLElement) factory function",
"Add DOM config to allow DOM elements in Phaser if needed"
],
"acceptance": [
"Phaser installed and importable",
"Config creates valid Phaser game instance",
"TypeScript types work correctly",
"No console errors on import"
],
"estimatedHours": 1
},
{
"id": "F3-002",
"name": "Create PhaserGame Vue component",
"description": "Vue component that mounts and manages Phaser game lifecycle",
"category": "components",
"priority": 2,
"completed": true,
"tested": true,
"dependencies": ["F3-001"],
"files": [
{"path": "src/components/game/PhaserGame.vue", "status": "create"},
{"path": "src/components/game/PhaserGame.spec.ts", "status": "create"}
],
"details": [
"Template: single div with ref='container' for Phaser mounting",
"Props: none initially (scenes loaded via config)",
"Emits: ready, error",
"onMounted: create Phaser game instance with createGame(container)",
"onUnmounted: call game.destroy(true) to cleanup",
"Expose game instance via defineExpose for parent access",
"Handle creation errors with error emit and console.error",
"Use CSS: width: 100%, height: 100%, position: relative",
"Add ResizeObserver to detect container size changes",
"Emit 'ready' when Phaser emits READY event"
],
"styling": [
"Container: w-full h-full relative overflow-hidden",
"Canvas will be absolutely positioned by Phaser",
"Background: bg-background (matches design system)"
],
"acceptance": [
"Component renders empty Phaser canvas",
"Game instance properly destroyed on unmount",
"No memory leaks (verified via dev tools)",
"ready event fires when Phaser initializes",
"Test verifies mount/unmount lifecycle"
],
"estimatedHours": 2
},
{
"id": "F3-003",
"name": "Create Vue-Phaser event bridge",
"description": "Typed bidirectional communication system between Vue and Phaser",
"category": "composables",
"priority": 3,
"completed": true,
"tested": true,
"dependencies": ["F3-002"],
"files": [
{"path": "src/game/bridge.ts", "status": "create"},
{"path": "src/game/bridge.spec.ts", "status": "create"},
{"path": "src/composables/useGameBridge.ts", "status": "create"},
{"path": "src/composables/useGameBridge.spec.ts", "status": "create"}
],
"details": [
"Create GameBridge class extending Phaser.Events.EventEmitter",
"Define typed events interface: GameBridgeEvents",
"Vue->Phaser events: state_updated, card_highlight, animation_request, resize",
"Phaser->Vue events: card_clicked, zone_clicked, animation_complete, ready, error",
"Export singleton: gameBridge = new GameBridge()",
"Create useGameBridge() composable for Vue components",
"Composable provides: emit(event, data), on(event, handler), off(event, handler)",
"Composable auto-cleans up listeners on component unmount (onUnmounted)",
"Phaser scenes access gameBridge directly via import",
"All event data types explicitly defined in bridge.ts"
],
"acceptance": [
"Events flow Vue -> Phaser correctly",
"Events flow Phaser -> Vue correctly",
"TypeScript enforces correct event payloads",
"Listeners auto-removed on Vue component unmount",
"Tests verify bidirectional communication"
],
"estimatedHours": 3
},
{
"id": "F3-004",
"name": "Create PreloadScene",
"description": "Phaser scene for loading game assets with progress display",
"category": "game",
"priority": 4,
"completed": true,
"tested": true,
"dependencies": ["F3-001"],
"files": [
{"path": "src/game/scenes/PreloadScene.ts", "status": "create"},
{"path": "src/game/assets/manifest.ts", "status": "create"},
{"path": "src/game/assets/loader.ts", "status": "create"}
],
"details": [
"Extend Phaser.Scene with key 'PreloadScene'",
"preload(): Load assets defined in manifest.ts",
"Assets to load: card back image, board background, UI sprites, type icons",
"Create loading progress bar using Phaser graphics",
"Listen to 'progress' event to update progress bar",
"Listen to 'complete' event to transition to MatchScene",
"manifest.ts: Export ASSET_MANIFEST with paths and keys",
"loader.ts: Helper functions for dynamic card image loading",
"Card images loaded lazily in MatchScene (not all upfront)",
"Handle load errors gracefully with fallback placeholder"
],
"acceptance": [
"Progress bar displays during loading",
"All core assets load successfully",
"Scene transitions to MatchScene after load",
"Load errors handled without crash",
"Progress reaches 100% before transition"
],
"estimatedHours": 3,
"notes": "Card images may use placeholders initially if CDN not configured"
},
{
"id": "F3-005",
"name": "Create MatchScene foundation",
"description": "Main game scene that renders the board and responds to state changes",
"category": "game",
"priority": 5,
"completed": true,
"tested": true,
"dependencies": ["F3-003", "F3-004"],
"files": [
{"path": "src/game/scenes/MatchScene.ts", "status": "create"},
{"path": "src/game/scenes/index.ts", "status": "create"}
],
"details": [
"Extend Phaser.Scene with key 'MatchScene'",
"create(): Set up board layout, subscribe to bridge events",
"Subscribe to gameBridge 'state_updated' event",
"Implement renderState(gameState: GameState) method",
"Track game objects in Maps: cards, zones, etc.",
"update(): Minimal - most updates are event-driven",
"Handle resize events from bridge to rescale board",
"Emit 'ready' to bridge when scene is fully initialized",
"Implement clearBoard() for state reset",
"scenes/index.ts: Export array of scene classes for game config"
],
"acceptance": [
"Scene creates and displays empty board area",
"Scene responds to state_updated events",
"Resize events properly handled",
"ready event emitted after initialization",
"Scene can be destroyed and recreated cleanly"
],
"estimatedHours": 4
},
{
"id": "F3-006",
"name": "Create board layout system",
"description": "Calculate positions for all game zones based on screen size",
"category": "game",
"priority": 6,
"completed": true,
"tested": true,
"dependencies": ["F3-005"],
"files": [
{"path": "src/game/layout.ts", "status": "create"},
{"path": "src/game/layout.spec.ts", "status": "create"},
{"path": "src/game/objects/Board.ts", "status": "create"}
],
"details": [
"Define BoardLayout interface with zone positions",
"Zones: myActive, myBench (5 slots), myDeck, myDiscard, myPrizes (6)",
"Mirror zones for opponent: oppActive, oppBench, oppDeck, oppDiscard, oppPrizes",
"Hand zone: bottom of screen, fan layout",
"calculateLayout(width: number, height: number): BoardLayout",
"Layout adapts to portrait (mobile) vs landscape (desktop)",
"Each zone has: x, y, width, height, angle (for fanning)",
"Board.ts: Game object that renders zone backgrounds/outlines",
"Highlight zones when they're valid drop/play targets",
"Zone positions exported as constants for consistent reference"
],
"styling": [
"Zone outlines: subtle border, slightly lighter than background",
"Active zones: prominent position, larger card area",
"Bench: horizontal row of 5 slots with spacing",
"Prizes: 2x3 grid or stacked display",
"Opponent zones: mirrored at top of screen (inverted orientation)"
],
"acceptance": [
"Layout calculation produces valid positions",
"All zones have distinct, non-overlapping areas",
"Layout works for both portrait and landscape",
"Board renders zone outlines correctly",
"Tests verify layout calculations"
],
"estimatedHours": 4
},
{
"id": "F3-007",
"name": "Create Card game object",
"description": "Phaser game object representing a card that can be displayed and interacted with",
"category": "game",
"priority": 7,
"completed": true,
"tested": true,
"dependencies": ["F3-005"],
"files": [
{"path": "src/game/objects/Card.ts", "status": "create"},
{"path": "src/game/objects/CardBack.ts", "status": "create"},
{"path": "src/game/objects/DamageCounter.ts", "status": "create"}
],
"details": [
"Card extends Phaser.GameObjects.Container",
"Contains: card image sprite, type border, optional damage counter",
"Constructor params: scene, x, y, cardData (from game state)",
"setCard(cardData): Update displayed card",
"setFaceDown(isFaceDown): Toggle card back vs face",
"CardBack: Simple game object for face-down cards",
"DamageCounter: Displays damage on card (red circle with number)",
"Interactive: setInteractive(), on('pointerdown'), on('pointerover'), etc.",
"Click emits event to bridge: card_clicked with cardId and zone",
"Support multiple sizes: hand (medium), board (large), thumbnail (small)",
"Lazy load card images - use placeholder until image loads"
],
"styling": [
"Card border: 3px colored border matching card type",
"Hover: scale to 1.05, slight shadow, raise z-index",
"Selected: pulsing glow effect (tween)",
"Disabled: grayscale + reduced alpha",
"Damage counter: red circle, white text, positioned bottom-right"
],
"acceptance": [
"Card displays correctly with image and border",
"Face-down cards show card back",
"Click events fire and reach bridge",
"Hover effects work on desktop",
"Damage counter displays when card has damage"
],
"estimatedHours": 5
},
{
"id": "F3-008",
"name": "Create zone game objects",
"description": "Game objects for each board zone that contain and arrange cards",
"category": "game",
"priority": 8,
"completed": true,
"tested": true,
"dependencies": ["F3-006", "F3-007"],
"files": [
{"path": "src/game/objects/Zone.ts", "status": "create"},
{"path": "src/game/objects/ActiveZone.ts", "status": "create"},
{"path": "src/game/objects/BenchZone.ts", "status": "create"},
{"path": "src/game/objects/HandZone.ts", "status": "create"},
{"path": "src/game/objects/PileZone.ts", "status": "create"},
{"path": "src/game/objects/PrizeZone.ts", "status": "create"}
],
"details": [
"Zone: Base class extending Phaser.GameObjects.Container",
"Zone tracks contained Card objects",
"ActiveZone: Single card slot, larger display",
"BenchZone: Row of 5 card slots, evenly spaced",
"HandZone: Fan of cards at bottom, cards slightly overlap",
"PileZone: For deck/discard - shows top card and count",
"PrizeZone: 6 face-down cards in 2x3 grid",
"setCards(cards[]): Update zone contents, animate changes",
"highlight(enabled: boolean): Visual indicator for valid targets",
"Each zone type handles its own card arrangement logic",
"Zones are interactive for targeting (emit zone_clicked)"
],
"acceptance": [
"All zone types render correctly",
"Zones arrange cards appropriately",
"Zone highlights toggle correctly",
"Zone click events reach bridge",
"Card updates animate smoothly"
],
"estimatedHours": 6
},
{
"id": "F3-009",
"name": "Implement responsive canvas scaling",
"description": "Make game canvas scale properly to any screen size",
"category": "game",
"priority": 9,
"completed": true,
"tested": true,
"dependencies": ["F3-006"],
"files": [
{"path": "src/game/scale.ts", "status": "create"},
{"path": "src/game/scale.spec.ts", "status": "create"},
{"path": "src/game/config.ts", "status": "modify"}
],
"details": [
"Use Phaser.Scale.RESIZE mode (or FIT for fixed aspect)",
"Define design resolution: 1920x1080 (landscape base)",
"calculateScale(width, height): Get scale factor and offsets",
"Handle orientation changes on mobile",
"ResizeManager class to coordinate resize events",
"Emit resize event to bridge when size changes",
"Debounce resize handling (100ms) to prevent excessive recalc",
"Update BoardLayout on resize",
"Ensure touch areas scale appropriately (min 44px equivalent)"
],
"acceptance": [
"Canvas scales to fill container",
"Aspect ratio maintained (letterbox if needed)",
"Resize events trigger layout recalculation",
"Touch targets remain usable on mobile",
"Tests verify scale calculations"
],
"estimatedHours": 3
},
{
"id": "F3-010",
"name": "Implement state rendering",
"description": "Render game state from Pinia store to Phaser scene",
"category": "game",
"priority": 10,
"completed": true,
"tested": true,
"dependencies": ["F3-008"],
"files": [
{"path": "src/game/sync/StateRenderer.ts", "status": "create"},
{"path": "src/game/sync/index.ts", "status": "create"}
],
"details": [
"StateRenderer class coordinates state -> scene updates",
"render(state: GameState): Main entry point",
"Compare previous state to new state to minimize updates",
"Update my zones: active, bench, hand, deck, discard, prizes",
"Update opponent zones: active, bench, deck (count), discard (count), prizes",
"Handle visibility: opponent hand shows card backs only",
"updateCard(card, zone): Position card in correct zone",
"createCard(card): Create new Card game object",
"removeCard(cardId): Destroy Card game object",
"Track cards by ID in Map for efficient updates"
],
"acceptance": [
"State renders correctly to scene",
"All zones populated from state",
"Opponent hidden zones show appropriate info",
"State updates are efficient (diff-based)",
"Cards created/removed as state changes"
],
"estimatedHours": 5
},
{
"id": "F3-011",
"name": "Create GamePage with Phaser integration",
"description": "Vue page that hosts the Phaser game during matches",
"category": "pages",
"priority": 11,
"completed": true,
"tested": true,
"dependencies": ["F3-002", "F3-003", "F3-010"],
"files": [
{"path": "src/pages/GamePage.vue", "status": "create"},
{"path": "src/pages/GamePage.spec.ts", "status": "create"},
{"path": "src/router/index.ts", "status": "modify"}
],
"details": [
"Full viewport page (no nav) using game layout",
"Mount PhaserGame component filling container",
"Route: /game/:id with id param",
"On mount: connect to socket, join game room",
"Subscribe to game:state events -> update store",
"Watch game store state -> emit to bridge",
"Handle game:error events with toast/overlay",
"Handle disconnect/reconnect (overlay during reconnect)",
"Exit button -> confirm, leave game, navigate away",
"Route guard: redirect if not authenticated"
],
"styling": [
"Container: fixed inset-0, bg-background",
"No scrolling, overflow-hidden",
"Exit button: absolute top-right, icon-only",
"Reconnecting overlay: centered with spinner"
],
"acceptance": [
"Page renders Phaser game full viewport",
"WebSocket connection established",
"Game state flows to Phaser",
"Can exit game with confirmation",
"Auth guard works correctly"
],
"estimatedHours": 4
},
{
"id": "F3-012",
"name": "Add game types and API types",
"description": "TypeScript types for game state, events, and Phaser integration",
"category": "api",
"priority": 12,
"completed": true,
"tested": true,
"dependencies": [],
"files": [
{"path": "src/types/game.ts", "status": "create"},
{"path": "src/types/phaser.ts", "status": "create"},
{"path": "src/types/index.ts", "status": "modify"}
],
"details": [
"game.ts: GameState, Player, Card, Zone, Phase types (match backend)",
"Card types: id, definitionId, name, hp, currentHp, type, attacks, etc.",
"Zone types: ZoneType enum, ZoneState interface",
"Phase types: TurnPhase enum (draw, main, attack, end)",
"phaser.ts: GameBridgeEvents interface for typed bridge events",
"phaser.ts: CardClickEvent, ZoneClickEvent payloads",
"phaser.ts: Scene data interfaces for scene transitions",
"Ensure types align with backend/app/schemas/game.py",
"Export all from types/index.ts"
],
"acceptance": [
"All game types defined and exported",
"Types match backend API contracts",
"Bridge events fully typed",
"No any types in game code"
],
"estimatedHours": 2,
"notes": "This task can be done early and in parallel with others"
}
],
"testingApproach": {
"unitTests": [
"Bridge event emission and reception",
"Layout calculations for various screen sizes",
"Scale calculations",
"State renderer diffing logic"
],
"componentTests": [
"PhaserGame mount/unmount lifecycle",
"GamePage integration with mocked store/socket"
],
"manualTests": [
"Visual inspection of board layout",
"Card rendering at different sizes",
"Touch interactions on mobile",
"Resize behavior",
"Scene transitions"
],
"note": "Phaser rendering is hard to unit test - focus on logic tests and manual visual verification"
},
"assetRequirements": {
"required": [
{"name": "card_back.png", "size": "500x700", "description": "Generic card back for face-down cards"},
{"name": "board_background.png", "size": "1920x1080", "description": "Optional board background texture"}
],
"optional": [
{"name": "type_icons.png", "description": "Sprite atlas of type icons"},
{"name": "ui_sprites.png", "description": "UI elements (buttons, indicators)"}
],
"cardImages": {
"source": "CDN or local /public/cards/ directory",
"pattern": "{setId}/{cardNumber}.png or {cardDefinitionId}.png",
"fallback": "placeholder_card.png for missing images"
}
},
"riskMitigation": [
{
"risk": "Phaser learning curve",
"mitigation": "Start with F3-001 and F3-002 to get basic rendering working quickly. Reference Phaser docs and examples."
},
{
"risk": "Vue-Phaser state sync complexity",
"mitigation": "Keep bridge events minimal and explicit. Phaser only reads state, never modifies."
},
{
"risk": "Memory leaks from game objects",
"mitigation": "Always call destroy() on game objects when removing. Test with Chrome DevTools memory profiler."
},
{
"risk": "Mobile performance",
"mitigation": "Use object pooling for cards if performance issues arise. Lazy load card images."
}
],
"notes": [
"F3-012 (types) can be worked on early and in parallel",
"Card images may use placeholders until CDN is configured",
"This phase focuses on RENDERING infrastructure - actual gameplay interactions come in F4",
"All Phaser code should be in src/game/ to keep it isolated from Vue code",
"The game store already exists from F0 - this phase adds the Phaser visualization layer",
"Consider using Phaser's built-in tween system for all animations (smoother than CSS)"
]
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,234 @@
/**
* Tests for App.vue root component.
*
* Verifies that auth initialization happens on app startup and that
* the loading state is properly displayed during initialization.
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { defineComponent, h, nextTick } from 'vue'
// Track initialization calls and control resolution
let initializeResolve: ((value: boolean) => void) | null = null
let initializeCalled = false
// Mock useAuth composable
vi.mock('@/composables/useAuth', () => ({
useAuth: () => ({
initialize: vi.fn(() => {
initializeCalled = true
return new Promise<boolean>((resolve) => {
initializeResolve = resolve
})
}),
isInitialized: { value: false },
isAuthenticated: { value: false },
user: { value: null },
isLoading: { value: false },
error: { value: null },
}),
}))
// Mock vue-router
vi.mock('vue-router', () => ({
useRoute: () => ({
meta: { layout: 'default' },
name: 'Dashboard',
path: '/',
}),
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
}),
}))
import App from './App.vue'
// Create stub components for layouts
const DefaultLayoutStub = defineComponent({
name: 'DefaultLayout',
setup(_, { slots }) {
return () => h('div', { class: 'default-layout' }, slots.default?.())
},
})
const MinimalLayoutStub = defineComponent({
name: 'MinimalLayout',
setup(_, { slots }) {
return () => h('div', { class: 'minimal-layout' }, slots.default?.())
},
})
const GameLayoutStub = defineComponent({
name: 'GameLayout',
setup(_, { slots }) {
return () => h('div', { class: 'game-layout' }, slots.default?.())
},
})
const LoadingOverlayStub = defineComponent({
name: 'LoadingOverlay',
setup() {
return () => h('div', { class: 'loading-overlay-mock' })
},
})
const ToastContainerStub = defineComponent({
name: 'ToastContainer',
setup() {
return () => h('div', { class: 'toast-container-mock' })
},
})
describe('App.vue', () => {
beforeEach(() => {
setActivePinia(createPinia())
initializeCalled = false
initializeResolve = null
})
afterEach(() => {
vi.clearAllMocks()
})
const mountApp = () => {
return mount(App, {
global: {
stubs: {
RouterView: true,
DefaultLayout: DefaultLayoutStub,
MinimalLayout: MinimalLayoutStub,
GameLayout: GameLayoutStub,
LoadingOverlay: LoadingOverlayStub,
ToastContainer: ToastContainerStub,
// Also stub async component wrappers
AsyncComponentWrapper: true,
Suspense: false,
},
},
})
}
describe('auth initialization', () => {
it('shows loading spinner while auth is initializing', async () => {
/**
* Test loading state during auth initialization.
*
* When the app starts, it should show a loading spinner while
* auth tokens are being validated. This prevents navigation guards
* from running before auth state is known.
*/
const wrapper = mountApp()
// Should show loading text initially
expect(wrapper.text()).toContain('Loading...')
wrapper.unmount()
})
it('calls initialize on mount', async () => {
/**
* Test that auth initialization is triggered on mount.
*
* The initialize() function must be called to validate tokens,
* refresh if needed, and fetch the user profile.
*/
const wrapper = mountApp()
// Wait for onMounted to run
await nextTick()
expect(initializeCalled).toBe(true)
wrapper.unmount()
})
it('hides loading spinner after initialization completes', async () => {
/**
* Test transition from loading to content.
*
* Once auth initialization completes, the loading spinner should
* be hidden and the main app content should be rendered.
*/
const wrapper = mountApp()
// Wait for onMounted
await nextTick()
// Should show loading initially
expect(wrapper.text()).toContain('Loading...')
// Resolve initialization (successful)
expect(initializeResolve).not.toBeNull()
initializeResolve!(true)
// Wait for state update
await flushPromises()
await nextTick()
// Loading text should be gone
expect(wrapper.text()).not.toContain('Loading...')
wrapper.unmount()
})
})
describe('initialization state machine', () => {
it('starts in initializing state and transitions to initialized', async () => {
/**
* Test the auth initialization state machine.
*
* The app should start showing the loading state and transition
* to the main content after initialization completes.
*/
const wrapper = mountApp()
await nextTick()
// Before initialization completes
expect(wrapper.find('.bg-gray-900').exists()).toBe(true)
// Complete initialization (successful)
initializeResolve!(true)
await flushPromises()
await nextTick()
// After initialization - the fixed loading div should be gone
// (checking that the loading screen specific class combo is gone)
const loadingScreen = wrapper.find('.fixed.inset-0.z-50.bg-gray-900')
expect(loadingScreen.exists()).toBe(false)
wrapper.unmount()
})
it('renders app content even when initialization returns false (auth failed)', async () => {
/**
* Test that app renders correctly when auth initialization fails.
*
* When initialize() returns false (e.g., tokens invalid, network error
* during profile fetch), the app should still transition out of the
* loading state and render the main content. Navigation guards will
* handle redirecting unauthenticated users to login.
*/
const wrapper = mountApp()
await nextTick()
// Should show loading initially
expect(wrapper.text()).toContain('Loading...')
// Complete initialization with failure (auth failed)
initializeResolve!(false)
await flushPromises()
await nextTick()
// Loading screen should be gone - app renders even on auth failure
const loadingScreen = wrapper.find('.fixed.inset-0.z-50.bg-gray-900')
expect(loadingScreen.exists()).toBe(false)
// Loading text should be gone
expect(wrapper.text()).not.toContain('Loading...')
wrapper.unmount()
})
})
})

View File

@ -0,0 +1,91 @@
<script setup lang="ts">
/**
* Root application component.
*
* Handles auth initialization on startup and renders the appropriate layout
* based on the current route's meta.layout property.
*
* Auth initialization:
* - Shows loading spinner while validating tokens
* - Refreshes expired tokens automatically
* - Fetches user profile if authenticated
* - Blocks navigation until initialization completes
*/
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import LoadingOverlay from '@/components/ui/LoadingOverlay.vue'
import ToastContainer from '@/components/ui/ToastContainer.vue'
import { useAuth } from '@/composables/useAuth'
import { useGameConfig } from '@/composables/useGameConfig'
// Lazy-load layouts to reduce initial bundle size
const DefaultLayout = defineAsyncComponent(() => import('@/layouts/DefaultLayout.vue'))
const MinimalLayout = defineAsyncComponent(() => import('@/layouts/MinimalLayout.vue'))
const GameLayout = defineAsyncComponent(() => import('@/layouts/GameLayout.vue'))
const route = useRoute()
const { initialize } = useAuth()
const { fetchDeckConfig } = useGameConfig()
// Track if auth initialization is in progress
const isAuthInitializing = ref(true)
type LayoutType = 'default' | 'minimal' | 'game'
const layoutComponents = {
default: DefaultLayout,
minimal: MinimalLayout,
game: GameLayout,
} as const
const currentLayout = computed(() => {
const layout = (route.meta.layout as LayoutType) || 'default'
return layoutComponents[layout] || layoutComponents.default
})
// Initialize app on mount
onMounted(async () => {
try {
// Initialize auth and fetch config in parallel
await Promise.all([
initialize(),
fetchDeckConfig(),
])
} finally {
isAuthInitializing.value = false
}
})
</script>
<template>
<!-- Show loading spinner while auth initializes -->
<div
v-if="isAuthInitializing"
class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900"
>
<div class="flex flex-col items-center gap-4">
<!-- Spinner -->
<div class="relative">
<div class="w-12 h-12 border-4 border-primary/30 rounded-full" />
<div
class="absolute inset-0 w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin"
/>
</div>
<p class="text-gray-300 text-sm">
Loading...
</p>
</div>
</div>
<!-- Main app content (only after auth is initialized) -->
<template v-else>
<component :is="currentLayout">
<RouterView />
</component>
</template>
<!-- Global UI components -->
<LoadingOverlay />
<ToastContainer />
</template>

View File

@ -0,0 +1,304 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import { apiClient } from './client'
import { ApiError } from './types'
// Mock fetch globally
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
describe('apiClient', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockFetch.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('get', () => {
it('makes GET request to correct URL', async () => {
/**
* Test that GET requests are sent to the correct URL.
*
* The API client must correctly build URLs from the base URL
* and path, which is fundamental to all API operations.
*/
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ id: '1', name: 'Test' }),
})
const result = await apiClient.get('/api/users/me')
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/users/me'),
expect.objectContaining({ method: 'GET' })
)
expect(result).toEqual({ id: '1', name: 'Test' })
})
it('adds query parameters to URL', async () => {
/**
* Test that query parameters are correctly appended.
*
* Many API endpoints accept query parameters for filtering,
* pagination, etc. These must be properly encoded.
*/
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ items: [] }),
})
await apiClient.get('/api/cards', { params: { page: 1, limit: 20 } })
const calledUrl = mockFetch.mock.calls[0][0] as string
expect(calledUrl).toContain('page=1')
expect(calledUrl).toContain('limit=20')
})
it('skips undefined query parameters', async () => {
/**
* Test that undefined parameters are not included in URL.
*
* Optional parameters should be omitted entirely rather than
* being sent as "undefined" strings.
*/
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({}),
})
await apiClient.get('/api/cards', {
params: { page: 1, search: undefined },
})
const calledUrl = mockFetch.mock.calls[0][0] as string
expect(calledUrl).toContain('page=1')
expect(calledUrl).not.toContain('search')
})
})
describe('post', () => {
it('makes POST request with JSON body', async () => {
/**
* Test that POST requests include JSON body.
*
* POST requests typically send data to create resources.
* The body must be JSON serialized.
*/
mockFetch.mockResolvedValueOnce({
ok: true,
status: 201,
json: async () => ({ id: '1' }),
})
const body = { name: 'New Deck', cards: [] }
await apiClient.post('/api/decks', body)
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'POST',
body: JSON.stringify(body),
})
)
})
})
describe('authentication', () => {
it('adds Authorization header when authenticated', async () => {
/**
* Test that auth header is automatically added.
*
* Authenticated requests must include the Bearer token
* so the backend can identify the user.
*/
const auth = useAuthStore()
auth.setTokens({
accessToken: 'test-token',
refreshToken: 'refresh-token',
expiresAt: Date.now() + 3600000,
})
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({}),
})
await apiClient.get('/api/users/me')
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer test-token',
}),
})
)
})
it('skips auth header when skipAuth is true', async () => {
/**
* Test that skipAuth option prevents auth header.
*
* Some endpoints (like login/register) don't need auth
* and should not send the token.
*/
const auth = useAuthStore()
auth.setTokens({
accessToken: 'test-token',
refreshToken: 'refresh-token',
expiresAt: Date.now() + 3600000,
})
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({}),
})
await apiClient.get('/api/public/health', { skipAuth: true })
const calledOptions = mockFetch.mock.calls[0][1] as RequestInit
expect(calledOptions.headers).not.toHaveProperty('Authorization')
})
it('does not add auth header when not authenticated', async () => {
/**
* Test that unauthenticated requests have no auth header.
*
* Without a token, there's nothing to send.
*/
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({}),
})
await apiClient.get('/api/public/status')
const calledOptions = mockFetch.mock.calls[0][1] as RequestInit
const headers = calledOptions.headers as Record<string, string>
expect(headers['Authorization']).toBeUndefined()
})
})
describe('error handling', () => {
it('throws ApiError on non-2xx response', async () => {
/**
* Test that API errors are properly thrown.
*
* Non-successful responses should throw ApiError with
* status code and message for proper error handling.
*/
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
json: async () => ({ detail: 'Deck not found' }),
})
try {
await apiClient.get('/api/decks/999')
expect.fail('Should have thrown')
} catch (e) {
expect(e).toBeInstanceOf(ApiError)
const error = e as ApiError
expect(error.status).toBe(404)
expect(error.detail).toBe('Deck not found')
expect(error.isNotFound).toBe(true)
}
})
it('handles non-JSON error responses', async () => {
/**
* Test that non-JSON errors are handled gracefully.
*
* Some server errors may return HTML or plain text.
* The client should still create a proper ApiError.
*/
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: async () => {
throw new Error('Not JSON')
},
})
await expect(apiClient.get('/api/crash')).rejects.toThrow(ApiError)
})
it('handles 204 No Content responses', async () => {
/**
* Test that 204 responses return undefined.
*
* DELETE and some PUT/PATCH endpoints return 204 with no body.
* The client should handle this without trying to parse JSON.
*/
mockFetch.mockResolvedValueOnce({
ok: true,
status: 204,
})
const result = await apiClient.delete('/api/decks/1')
expect(result).toBeUndefined()
})
})
describe('token refresh', () => {
it('retries request after successful token refresh on 401', async () => {
/**
* Test that 401 triggers token refresh and retry.
*
* When the access token expires, the client should automatically
* refresh it and retry the failed request transparently.
*/
const auth = useAuthStore()
auth.setTokens({
accessToken: 'expired-token',
refreshToken: 'valid-refresh-token',
expiresAt: Date.now() + 3600000,
})
// First call returns 401
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
json: async () => ({ detail: 'Token expired' }),
})
// Refresh token call succeeds
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
access_token: 'new-token',
expires_in: 3600,
}),
})
// Retry succeeds
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ id: '1', name: 'Test User' }),
})
const result = await apiClient.get('/api/users/me')
expect(mockFetch).toHaveBeenCalledTimes(3)
expect(result).toEqual({ id: '1', name: 'Test User' })
})
})
})

View File

@ -0,0 +1,180 @@
/**
* HTTP API client with authentication and error handling.
*
* Provides typed fetch wrapper that automatically:
* - Injects Authorization header from auth store
* - Refreshes tokens on 401 responses
* - Parses JSON responses
* - Throws typed ApiError on failures
*/
import { config } from '@/config'
import { useAuthStore } from '@/stores/auth'
import { ApiError } from './types'
import type { RequestOptions, ErrorResponse } from './types'
// Re-export ApiError for convenience
export { ApiError }
/**
* Build URL with query parameters.
*/
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
const base = config.apiBaseUrl.replace(/\/$/, '')
const cleanPath = path.startsWith('/') ? path : `/${path}`
const url = new URL(`${base}${cleanPath}`)
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.set(key, String(value))
}
})
}
return url.toString()
}
/**
* Parse error response from backend.
*/
async function parseErrorResponse(response: Response): Promise<ApiError> {
let detail: string | undefined
let code: string | undefined
try {
const data: ErrorResponse = await response.json()
detail = data.detail || data.message
code = data.code
} catch {
// Response body is not JSON or empty
}
return new ApiError(response.status, response.statusText, detail, code)
}
/**
* Make an authenticated API request.
*
* @param method - HTTP method
* @param path - API path (e.g., '/api/users/me')
* @param options - Request options
* @returns Parsed JSON response
* @throws ApiError on non-2xx responses
*/
async function request<T>(
method: string,
path: string,
options: RequestOptions = {}
): Promise<T> {
const { skipAuth = false, headers = {}, body, params, signal } = options
const auth = useAuthStore()
// Build headers
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...headers,
}
// Add auth header if authenticated and not skipped
if (!skipAuth && auth.isAuthenticated) {
const token = await auth.getValidToken()
if (token) {
requestHeaders['Authorization'] = `Bearer ${token}`
}
}
// Make request
const url = buildUrl(path, params)
const response = await fetch(url, {
method,
headers: requestHeaders,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal,
})
// Handle 401 - try to refresh and retry once
if (response.status === 401 && !skipAuth && auth.refreshToken) {
const refreshed = await auth.refreshAccessToken()
if (refreshed) {
// Retry with new token
const newToken = await auth.getValidToken()
if (newToken) {
requestHeaders['Authorization'] = `Bearer ${newToken}`
const retryResponse = await fetch(url, {
method,
headers: requestHeaders,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal,
})
if (!retryResponse.ok) {
throw await parseErrorResponse(retryResponse)
}
// Handle 204 No Content
if (retryResponse.status === 204) {
return undefined as T
}
return retryResponse.json()
}
}
// Refresh failed, throw the original 401
throw await parseErrorResponse(response)
}
// Handle other errors
if (!response.ok) {
throw await parseErrorResponse(response)
}
// Handle 204 No Content
if (response.status === 204) {
return undefined as T
}
return response.json()
}
/**
* API client with typed methods for each HTTP verb.
*/
export const apiClient = {
/**
* Make a GET request.
*/
get<T>(path: string, options?: RequestOptions): Promise<T> {
return request<T>('GET', path, options)
},
/**
* Make a POST request.
*/
post<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
return request<T>('POST', path, { ...options, body })
},
/**
* Make a PUT request.
*/
put<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
return request<T>('PUT', path, { ...options, body })
},
/**
* Make a PATCH request.
*/
patch<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
return request<T>('PATCH', path, { ...options, body })
},
/**
* Make a DELETE request.
*/
delete<T>(path: string, options?: RequestOptions): Promise<T> {
return request<T>('DELETE', path, options)
},
}
export default apiClient

View File

@ -0,0 +1,11 @@
/**
* API module exports.
*/
export { apiClient, default } from './client'
export { ApiError } from './types'
export type {
RequestOptions,
ApiResponse,
PaginatedResponse,
ErrorResponse,
} from './types'

View File

@ -0,0 +1,100 @@
/**
* API client types and error classes.
*/
/**
* Custom error class for API errors.
*
* Provides structured error information including HTTP status code,
* error message, and optional detail from the backend.
*/
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly statusText: string,
public readonly detail?: string,
public readonly code?: string
) {
super(detail || statusText)
this.name = 'ApiError'
}
/**
* Check if this is an authentication error (401).
*/
get isUnauthorized(): boolean {
return this.status === 401
}
/**
* Check if this is a forbidden error (403).
*/
get isForbidden(): boolean {
return this.status === 403
}
/**
* Check if this is a not found error (404).
*/
get isNotFound(): boolean {
return this.status === 404
}
/**
* Check if this is a validation error (422).
*/
get isValidationError(): boolean {
return this.status === 422
}
/**
* Check if this is a server error (5xx).
*/
get isServerError(): boolean {
return this.status >= 500
}
}
/**
* Options for API requests.
*/
export interface RequestOptions {
/** Skip automatic auth header injection */
skipAuth?: boolean
/** Custom headers to merge with defaults */
headers?: Record<string, string>
/** Request body (will be JSON serialized) */
body?: unknown
/** Query parameters */
params?: Record<string, string | number | boolean | undefined>
/** AbortSignal for request cancellation */
signal?: AbortSignal
}
/**
* Standard API response wrapper from backend.
*/
export interface ApiResponse<T> {
data: T
message?: string
}
/**
* Paginated response from backend.
*/
export interface PaginatedResponse<T> {
items: T[]
total: number
page: number
pageSize: number
totalPages: number
}
/**
* Backend error response shape.
*/
export interface ErrorResponse {
detail?: string
message?: string
code?: string
}

View File

@ -0,0 +1,74 @@
@import "tailwindcss";
/* Import pixel font for retro GBC aesthetic */
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
/* Custom theme */
@theme {
/* Pokemon type colors */
--color-type-grass: #78C850;
--color-type-fire: #F08030;
--color-type-water: #6890F0;
--color-type-lightning: #F8D030;
--color-type-psychic: #F85888;
--color-type-fighting: #C03028;
--color-type-darkness: #705848;
--color-type-metal: #B8B8D0;
--color-type-fairy: #EE99AC;
--color-type-dragon: #7038F8;
--color-type-colorless: #A8A878;
/* UI colors */
--color-primary: #3B82F6;
--color-primary-dark: #2563EB;
--color-primary-light: #60A5FA;
--color-secondary: #6366F1;
--color-secondary-dark: #4F46E5;
--color-secondary-light: #818CF8;
--color-success: #22C55E;
--color-warning: #F59E0B;
--color-error: #EF4444;
/* Dark theme surfaces */
--color-surface: #374151;
--color-surface-dark: #1F2937;
--color-surface-light: #4B5563;
/* Font family */
--font-pixel: "Press Start 2P", cursive;
}
@layer base {
html {
@apply bg-gray-900 text-gray-100;
}
body {
@apply min-h-screen;
}
}
@layer components {
/* Card styling */
.card {
@apply bg-surface rounded-lg shadow-lg overflow-hidden;
}
/* Button variants */
.btn {
@apply px-4 py-2 rounded font-medium transition-colors duration-200;
}
.btn-primary {
@apply bg-primary hover:bg-primary-dark text-white;
}
.btn-secondary {
@apply bg-secondary hover:bg-secondary-dark text-white;
}
/* Type badges */
.type-badge {
@apply px-2 py-1 rounded text-xs font-bold uppercase;
}
}

View File

@ -0,0 +1,307 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { ref } from 'vue'
// Mock vue-router
const mockRoute = ref({ path: '/', name: 'Dashboard' } as { path: string; name: string })
vi.mock('vue-router', () => ({
useRoute: () => mockRoute.value,
RouterLink: {
name: 'RouterLink',
props: ['to'],
template: '<a :href="to"><slot /></a>',
},
}))
// Shared state for mocks - plain refs
const mockAvatarUrl = ref<string | null>(null)
const mockDisplayName = ref('Test User')
// Mock user store - use getters to return unwrapped values like real Pinia store
vi.mock('@/stores/user', () => ({
useUserStore: () => ({
get avatarUrl() { return mockAvatarUrl.value },
get displayName() { return mockDisplayName.value },
}),
}))
import NavBottomTabs from './NavBottomTabs.vue'
describe('NavBottomTabs', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockAvatarUrl.value = null
mockDisplayName.value = 'Test User'
mockRoute.value = { path: '/', name: 'Dashboard' }
})
afterEach(() => {
vi.clearAllMocks()
})
describe('navigation', () => {
it('renders all navigation tabs', () => {
/**
* Test navigation tab rendering.
*
* The bottom tabs should display all main navigation items so users
* can easily navigate to different sections on mobile.
*/
const wrapper = mount(NavBottomTabs)
expect(wrapper.text()).toContain('Home')
expect(wrapper.text()).toContain('Play')
expect(wrapper.text()).toContain('Decks')
expect(wrapper.text()).toContain('Cards')
expect(wrapper.text()).toContain('Profile')
})
it('includes correct navigation paths', () => {
/**
* Test navigation link paths.
*
* Each tab should link to the correct route so navigation
* works as expected.
*/
const wrapper = mount(NavBottomTabs)
const links = wrapper.findAll('a')
const hrefs = links.map(l => l.attributes('href'))
expect(hrefs).toContain('/')
expect(hrefs).toContain('/play')
expect(hrefs).toContain('/decks')
expect(hrefs).toContain('/collection')
expect(hrefs).toContain('/profile')
})
it('highlights active tab for home route', () => {
/**
* Test home tab highlighting.
*
* When on the home page, the Home tab should be highlighted
* to show the current location.
*/
mockRoute.value = { path: '/', name: 'Dashboard' }
const wrapper = mount(NavBottomTabs)
const links = wrapper.findAll('a')
const homeLink = links.find(l => l.attributes('href') === '/')
expect(homeLink?.classes()).toContain('text-primary-light')
})
it('highlights active tab for nested routes', () => {
/**
* Test nested route highlighting.
*
* When on a nested route like /decks/123, the parent tab (Decks)
* should be highlighted to show the user's location.
*/
mockRoute.value = { path: '/decks/123', name: 'EditDeck' }
const wrapper = mount(NavBottomTabs)
const links = wrapper.findAll('a')
const decksLink = links.find(l => l.attributes('href') === '/decks')
expect(decksLink?.classes()).toContain('text-primary-light')
})
})
describe('profile tab with avatar', () => {
it('displays avatar image when avatarUrl is provided', () => {
/**
* Test avatar display in profile tab.
*
* When the user has an avatar, it should be displayed in the
* profile tab instead of the generic icon.
*/
mockDisplayName.value = 'Cal'
mockAvatarUrl.value = 'https://example.com/avatar.jpg'
const wrapper = mount(NavBottomTabs)
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('https://example.com/avatar.jpg')
expect(img.attributes('alt')).toBe('Cal')
})
it('displays initial fallback when no avatar is provided', () => {
/**
* Test avatar fallback in profile tab.
*
* When users don't have an avatar, we show their first initial
* in the profile tab as a placeholder.
*/
mockDisplayName.value = 'Player'
mockAvatarUrl.value = null
const wrapper = mount(NavBottomTabs)
// Profile tab should show the initial
const profileTab = wrapper.findAll('a').find(a => a.attributes('href') === '/profile')
expect(profileTab?.text()).toContain('P')
})
it('capitalizes initial in fallback avatar', () => {
/**
* Test initial capitalization in profile tab.
*
* The avatar initial should be uppercase for visual consistency,
* regardless of the display name casing.
*/
mockDisplayName.value = 'testUser'
mockAvatarUrl.value = null
const wrapper = mount(NavBottomTabs)
const profileTab = wrapper.findAll('a').find(a => a.attributes('href') === '/profile')
expect(profileTab?.text()).toContain('T')
})
it('adds ring highlight to avatar when profile is active', () => {
/**
* Test active state styling for avatar.
*
* When on the profile page, the avatar should have a ring border
* to indicate the tab is active.
*/
mockRoute.value = { path: '/profile', name: 'Profile' }
mockAvatarUrl.value = 'https://example.com/avatar.jpg'
const wrapper = mount(NavBottomTabs)
const img = wrapper.find('img')
expect(img.classes()).toContain('ring-2')
expect(img.classes()).toContain('ring-primary-light')
})
it('styles initial fallback differently when active', () => {
/**
* Test active state styling for initial fallback.
*
* When on the profile page without an avatar, the initial circle
* should be styled differently to indicate the active state.
*/
mockRoute.value = { path: '/profile', name: 'Profile' }
mockAvatarUrl.value = null
const wrapper = mount(NavBottomTabs)
const profileTab = wrapper.findAll('a').find(a => a.attributes('href') === '/profile')
const initialSpan = profileTab?.find('.rounded-full')
expect(initialSpan?.classes()).toContain('bg-primary/30')
expect(initialSpan?.classes()).toContain('text-primary-light')
})
it('uses gray styling for initial when not active', () => {
/**
* Test inactive state styling for initial fallback.
*
* When not on the profile page, the initial circle should
* use muted gray colors.
*/
mockRoute.value = { path: '/', name: 'Dashboard' }
mockAvatarUrl.value = null
const wrapper = mount(NavBottomTabs)
const profileTab = wrapper.findAll('a').find(a => a.attributes('href') === '/profile')
const initialSpan = profileTab?.find('.rounded-full')
expect(initialSpan?.classes()).toContain('bg-gray-600')
expect(initialSpan?.classes()).toContain('text-gray-300')
})
})
describe('other tabs', () => {
it('displays emoji icons for non-profile tabs', () => {
/**
* Test icon display for regular tabs.
*
* Non-profile tabs should display their emoji icons rather
* than avatars or initials.
*/
const wrapper = mount(NavBottomTabs)
// Check that emoji icons are present (not rendered as images)
const links = wrapper.findAll('a')
// Home tab should have home emoji
const homeLink = links.find(l => l.attributes('href') === '/')
expect(homeLink?.html()).not.toContain('<img')
})
})
describe('responsiveness', () => {
it('is hidden on desktop (md and above)', () => {
/**
* Test desktop visibility.
*
* The bottom tabs should be hidden on desktop where the sidebar
* navigation is used instead.
*/
const wrapper = mount(NavBottomTabs)
const nav = wrapper.find('nav')
expect(nav.classes()).toContain('md:hidden')
})
it('is fixed at the bottom of the screen', () => {
/**
* Test fixed positioning.
*
* The bottom tabs should be fixed at the bottom of the viewport
* so they're always accessible on mobile.
*/
const wrapper = mount(NavBottomTabs)
const nav = wrapper.find('nav')
expect(nav.classes()).toContain('fixed')
expect(nav.classes()).toContain('bottom-0')
})
it('has safe area padding for iOS devices', () => {
/**
* Test iOS safe area support.
*
* On iOS devices with home indicator bars, the navigation
* should have extra padding to avoid overlap.
*/
const wrapper = mount(NavBottomTabs)
const nav = wrapper.find('nav')
expect(nav.classes()).toContain('safe-area-inset-bottom')
})
})
describe('accessibility', () => {
it('uses semantic nav element', () => {
/**
* Test semantic HTML usage.
*
* Using a <nav> element helps screen readers identify this as
* a navigation region.
*/
const wrapper = mount(NavBottomTabs)
expect(wrapper.find('nav').exists()).toBe(true)
})
it('provides alt text for avatar images', () => {
/**
* Test image accessibility.
*
* Avatar images should have alt text for screen reader users.
*/
mockAvatarUrl.value = 'https://example.com/avatar.jpg'
mockDisplayName.value = 'Cal'
const wrapper = mount(NavBottomTabs)
const img = wrapper.find('img')
expect(img.attributes('alt')).toBe('Cal')
})
})
})

View File

@ -0,0 +1,95 @@
<script setup lang="ts">
/**
* Mobile bottom tab navigation.
*
* Displayed on small screens only (below md:). Contains the main
* navigation links as a fixed bottom tab bar. Hidden on desktop
* where NavSidebar is used instead.
*/
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
const route = useRoute()
const user = useUserStore()
const avatarUrl = computed(() => user.avatarUrl)
const displayName = computed(() => user.displayName || 'Player')
interface NavTab {
path: string
name: string
label: string
icon: string
isProfile?: boolean
}
const tabs: NavTab[] = [
{ path: '/', name: 'Dashboard', label: 'Home', icon: '🏠' },
{ path: '/play', name: 'PlayMenu', label: 'Play', icon: '⚔️' },
{ path: '/decks', name: 'DeckList', label: 'Decks', icon: '🃏' },
{ path: '/collection', name: 'Collection', label: 'Cards', icon: '📚' },
{ path: '/profile', name: 'Profile', label: 'Profile', icon: '👤', isProfile: true },
]
function isActive(tab: NavTab): boolean {
if (tab.path === '/') {
return route.path === '/'
}
return route.path.startsWith(tab.path)
}
</script>
<template>
<nav class="md:hidden fixed bottom-0 left-0 right-0 z-40 bg-surface-dark border-t border-gray-700 safe-area-inset-bottom">
<div class="flex justify-around">
<RouterLink
v-for="tab in tabs"
:key="tab.name"
:to="tab.path"
class="flex flex-col items-center py-2 px-3 min-w-[64px] transition-colors"
:class="[
isActive(tab)
? 'text-primary-light'
: 'text-gray-400 hover:text-gray-200'
]"
>
<!-- Profile tab with avatar -->
<template v-if="tab.isProfile">
<img
v-if="avatarUrl"
:src="avatarUrl"
:alt="displayName"
class="w-6 h-6 rounded-full object-cover"
:class="{ 'ring-2 ring-primary-light': isActive(tab) }"
>
<span
v-else
class="w-6 h-6 rounded-full flex items-center justify-center text-xs"
:class="[
isActive(tab)
? 'bg-primary/30 text-primary-light'
: 'bg-gray-600 text-gray-300'
]"
>
{{ displayName.charAt(0).toUpperCase() }}
</span>
</template>
<!-- Regular tab with icon -->
<span
v-else
class="text-xl"
>{{ tab.icon }}</span>
<span class="text-xs mt-1">{{ tab.label }}</span>
</RouterLink>
</div>
</nav>
</template>
<style scoped>
/* Safe area for iOS devices with home indicator */
.safe-area-inset-bottom {
padding-bottom: env(safe-area-inset-bottom, 0);
}
</style>

View File

@ -0,0 +1,269 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { ref } from 'vue'
// Mock vue-router
const mockRoute = ref({ path: '/', name: 'Dashboard' } as { path: string; name: string })
vi.mock('vue-router', () => ({
useRoute: () => mockRoute.value,
RouterLink: {
name: 'RouterLink',
props: ['to'],
template: '<a :href="to"><slot /></a>',
},
}))
// Shared state for mocks - plain refs
const mockIsLoggingOut = ref(false)
const mockLogout = vi.fn()
const mockAvatarUrl = ref<string | null>(null)
const mockDisplayName = ref('Test User')
// Mock useAuth composable
vi.mock('@/composables/useAuth', () => ({
useAuth: () => ({
logout: () => mockLogout(),
isLoading: mockIsLoggingOut,
}),
}))
// Mock user store - use computed to return unwrapped values like real Pinia store
vi.mock('@/stores/user', () => ({
useUserStore: () => ({
get avatarUrl() { return mockAvatarUrl.value },
get displayName() { return mockDisplayName.value },
}),
}))
import NavSidebar from './NavSidebar.vue'
describe('NavSidebar', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockLogout.mockReset()
mockIsLoggingOut.value = false
mockAvatarUrl.value = null
mockDisplayName.value = 'Test User'
mockRoute.value = { path: '/', name: 'Dashboard' }
})
afterEach(() => {
vi.clearAllMocks()
})
describe('user display', () => {
it('displays user display name', () => {
/**
* Test that the sidebar shows the user's display name.
*
* The display name is important for users to confirm they're
* logged in as the correct account.
*/
mockDisplayName.value = 'Cal'
const wrapper = mount(NavSidebar)
expect(wrapper.text()).toContain('Cal')
})
it('displays fallback initial when no avatar is provided', () => {
/**
* Test fallback avatar display.
*
* When users don't have an avatar, we show their first initial
* in a colored circle as a placeholder.
*/
mockDisplayName.value = 'Player'
mockAvatarUrl.value = null
const wrapper = mount(NavSidebar)
// Should show the first letter of the display name in the user menu section
const userMenu = wrapper.find('.border-t')
const avatarFallback = userMenu.find('.rounded-full')
expect(avatarFallback.text()).toBe('P')
})
it('displays avatar image when avatarUrl is provided', () => {
/**
* Test avatar image rendering.
*
* When users have an avatar URL (from OAuth provider), we should
* display their actual profile picture.
*/
mockDisplayName.value = 'Cal'
mockAvatarUrl.value = 'https://example.com/avatar.jpg'
const wrapper = mount(NavSidebar)
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('https://example.com/avatar.jpg')
expect(img.attributes('alt')).toBe('Cal')
})
it('shows user initial when display name starts with lowercase', () => {
/**
* Test initial capitalization.
*
* Even if the display name starts with lowercase, the avatar
* initial should be capitalized for consistency.
*/
mockDisplayName.value = 'testUser'
mockAvatarUrl.value = null
const wrapper = mount(NavSidebar)
// Find the avatar fallback in the user menu section
const userMenu = wrapper.find('.border-t')
const avatarFallback = userMenu.find('.rounded-full')
expect(avatarFallback.text()).toBe('T')
})
})
describe('navigation', () => {
it('renders all navigation items', () => {
/**
* Test navigation link rendering.
*
* The sidebar should display all main navigation items so users
* can easily navigate to different sections of the app.
*/
const wrapper = mount(NavSidebar)
expect(wrapper.text()).toContain('Home')
expect(wrapper.text()).toContain('Play')
expect(wrapper.text()).toContain('Decks')
expect(wrapper.text()).toContain('Collection')
expect(wrapper.text()).toContain('Campaign')
})
it('links to profile page', () => {
/**
* Test profile link.
*
* Users should be able to click on their profile section to
* navigate to the profile page.
*/
const wrapper = mount(NavSidebar)
const profileLink = wrapper.findAll('a').find(a => a.attributes('href') === '/profile')
expect(profileLink).toBeDefined()
})
it('highlights active route', () => {
/**
* Test active route highlighting.
*
* The current page should be visually highlighted in the nav
* so users know where they are in the app.
*/
mockRoute.value = { path: '/decks', name: 'DeckList' }
const wrapper = mount(NavSidebar)
// Find the Decks link
const links = wrapper.findAll('a')
const decksLink = links.find(a => a.attributes('href') === '/decks')
expect(decksLink?.classes()).toContain('bg-primary/20')
})
})
describe('logout', () => {
it('calls logout when logout button is clicked', async () => {
/**
* Test logout button functionality.
*
* Clicking the logout button should trigger the logout flow,
* which clears tokens and redirects to login.
*/
const wrapper = mount(NavSidebar)
const logoutButton = wrapper.find('button')
await logoutButton.trigger('click')
expect(mockLogout).toHaveBeenCalled()
})
it('shows loading state during logout', async () => {
/**
* Test logout loading indicator.
*
* During logout, the button should show a spinner and be disabled
* to prevent double-clicks and provide user feedback.
*/
mockIsLoggingOut.value = true
const wrapper = mount(NavSidebar)
const logoutButton = wrapper.find('button')
expect(logoutButton.attributes('disabled')).toBeDefined()
expect(wrapper.text()).toContain('Logging out...')
})
it('shows spinner icon during logout', () => {
/**
* Test logout spinner visibility.
*
* A spinning indicator should replace the logout icon
* while the logout operation is in progress.
*/
mockIsLoggingOut.value = true
const wrapper = mount(NavSidebar)
const spinner = wrapper.find('.animate-spin')
expect(spinner.exists()).toBe(true)
})
it('disables logout button during logout', () => {
/**
* Test logout button disabled state.
*
* The logout button should be disabled during logout to prevent
* multiple logout requests which could cause issues.
*/
mockIsLoggingOut.value = true
const wrapper = mount(NavSidebar)
const logoutButton = wrapper.find('button')
expect(logoutButton.classes()).toContain('disabled:opacity-50')
expect(logoutButton.attributes('disabled')).toBeDefined()
})
it('shows normal logout text when not logging out', () => {
/**
* Test normal logout button state.
*
* When not in the process of logging out, the button should
* show "Logout" and be enabled.
*/
mockIsLoggingOut.value = false
const wrapper = mount(NavSidebar)
const logoutButton = wrapper.find('button')
expect(logoutButton.text()).toContain('Logout')
expect(logoutButton.text()).not.toContain('Logging out...')
expect(logoutButton.attributes('disabled')).toBeUndefined()
})
})
describe('responsiveness', () => {
it('has hidden class for mobile', () => {
/**
* Test mobile visibility.
*
* The sidebar should be hidden on mobile (small screens) where
* the bottom tab navigation is used instead.
*/
const wrapper = mount(NavSidebar)
const aside = wrapper.find('aside')
expect(aside.classes()).toContain('hidden')
expect(aside.classes()).toContain('md:flex')
})
})
})

View File

@ -0,0 +1,123 @@
<script setup lang="ts">
/**
* Desktop sidebar navigation.
*
* Displayed on medium screens and above (md:). Contains the main
* navigation links and user menu. Hidden on mobile where NavBottomTabs
* is used instead.
*/
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/stores/user'
const { logout, isLoading: isLoggingOut } = useAuth()
const user = useUserStore()
const route = useRoute()
const displayName = computed(() => user.displayName || 'Player')
const avatarUrl = computed(() => user.avatarUrl)
interface NavItem {
path: string
name: string
label: string
icon: string
}
const navItems: NavItem[] = [
{ path: '/', name: 'Dashboard', label: 'Home', icon: '🏠' },
{ path: '/play', name: 'PlayMenu', label: 'Play', icon: '⚔️' },
{ path: '/decks', name: 'DeckList', label: 'Decks', icon: '🃏' },
{ path: '/collection', name: 'Collection', label: 'Collection', icon: '📚' },
{ path: '/campaign', name: 'Campaign', label: 'Campaign', icon: '🏆' },
]
function isActive(item: NavItem): boolean {
if (item.path === '/') {
return route.path === '/'
}
return route.path.startsWith(item.path)
}
async function handleLogout(): Promise<void> {
await logout()
}
</script>
<template>
<aside class="hidden md:flex flex-col w-64 bg-surface-dark border-r border-gray-700 h-screen">
<!-- Logo -->
<div class="p-4 border-b border-gray-700">
<RouterLink
to="/"
class="text-xl font-bold text-primary-light hover:text-primary transition-colors"
>
Mantimon TCG
</RouterLink>
</div>
<!-- Navigation -->
<nav class="flex-1 p-4">
<ul class="space-y-2">
<li
v-for="item in navItems"
:key="item.name"
>
<RouterLink
:to="item.path"
class="flex items-center gap-3 px-4 py-3 rounded-lg transition-colors"
:class="[
isActive(item)
? 'bg-primary/20 text-primary-light'
: 'text-gray-300 hover:bg-surface-light hover:text-white'
]"
>
<span class="text-lg">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</RouterLink>
</li>
</ul>
</nav>
<!-- User menu -->
<div class="p-4 border-t border-gray-700">
<RouterLink
to="/profile"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-surface-light hover:text-white transition-colors"
:class="{ 'bg-primary/20 text-primary-light': route.path === '/profile' }"
>
<img
v-if="avatarUrl"
:src="avatarUrl"
:alt="displayName"
class="w-6 h-6 rounded-full object-cover"
>
<span
v-else
class="w-6 h-6 rounded-full bg-primary/30 flex items-center justify-center text-sm text-primary-light"
>
{{ displayName.charAt(0).toUpperCase() }}
</span>
<span class="flex-1 truncate">{{ displayName }}</span>
</RouterLink>
<button
class="flex items-center gap-3 w-full px-4 py-3 mt-2 rounded-lg text-gray-400 hover:bg-surface-light hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLoggingOut"
@click="handleLogout"
>
<span
v-if="isLoggingOut"
class="w-5 h-5 border-2 border-gray-400 border-t-transparent rounded-full animate-spin"
/>
<span
v-else
class="text-lg"
>🚪</span>
<span>{{ isLoggingOut ? 'Logging out...' : 'Logout' }}</span>
</button>
</div>
</aside>
</template>

View File

@ -0,0 +1,59 @@
<script setup lang="ts">
/**
* Attack display component for showing Pokemon attack details.
*
* Renders a single attack row with:
* - Energy cost (using EnergyCost component)
* - Attack name
* - Damage value (if any)
* - Effect description (if any)
*
* Used in CardDetailModal to display all attacks for a Pokemon card.
* Styled with a subtle background and hover highlight for interactivity.
*/
import type { Attack } from '@/types'
import EnergyCost from './EnergyCost.vue'
defineProps<{
/** The attack to display */
attack: Attack
}>()
</script>
<template>
<div
class="rounded-lg bg-surface-light/50 p-3 transition-colors duration-150 hover:bg-surface-light"
>
<!-- Top row: Energy cost, name, and damage -->
<div class="flex items-center gap-3">
<!-- Energy cost -->
<EnergyCost
:cost="attack.cost"
size="sm"
class="shrink-0"
/>
<!-- Attack name -->
<span class="flex-1 font-medium text-text">
{{ attack.name }}
</span>
<!-- Damage value (if present) -->
<span
v-if="attack.damage > 0"
class="shrink-0 text-lg font-bold text-error"
>
{{ attack.damage }}
</span>
</div>
<!-- Effect description (if present) -->
<p
v-if="attack.effect"
class="mt-2 text-sm text-text-muted leading-relaxed"
>
{{ attack.effect }}
</p>
</div>
</template>

View File

@ -0,0 +1,822 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { nextTick } from 'vue'
import type { CardDefinition } from '@/types'
import CardDetailModal from './CardDetailModal.vue'
import CardImage from './CardImage.vue'
import TypeBadge from './TypeBadge.vue'
import AttackDisplay from './AttackDisplay.vue'
import EnergyCost from './EnergyCost.vue'
/**
* Test fixtures for card detail modal tests.
*/
const mockPokemonCard: CardDefinition = {
id: 'pikachu-base-001',
name: 'Pikachu',
category: 'pokemon',
type: 'lightning',
hp: 60,
attacks: [
{ name: 'Thunder Shock', cost: ['lightning'], damage: 20, effect: 'Flip a coin. If heads, the Defending Pokemon is now Paralyzed.' },
{ name: 'Thunder', cost: ['lightning', 'lightning', 'colorless'], damage: 50 },
],
imageUrl: 'https://example.com/pikachu.png',
rarity: 'common',
setId: 'base',
setNumber: 25,
}
const mockTrainerCard: CardDefinition = {
id: 'professor-oak-base-001',
name: 'Professor Oak',
category: 'trainer',
imageUrl: 'https://example.com/oak.png',
rarity: 'uncommon',
setId: 'base',
setNumber: 88,
}
const mockEnergyCard: CardDefinition = {
id: 'fire-energy-base-001',
name: 'Fire Energy',
category: 'energy',
type: 'fire',
imageUrl: 'https://example.com/fire-energy.png',
rarity: 'common',
setId: 'base',
setNumber: 98,
}
/**
* Helper to mount the modal with Teleport stubbed.
*/
function mountModal(props: {
card: CardDefinition | null
isOpen: boolean
ownedQuantity?: number
}): VueWrapper {
return mount(CardDetailModal, {
props,
global: {
stubs: {
Teleport: true,
},
},
})
}
describe('CardDetailModal', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('rendering', () => {
it('does not render when isOpen is false', () => {
/**
* Test modal visibility control.
*
* The modal should not render any content when closed to avoid
* unnecessary DOM elements and potential accessibility issues.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: false,
})
expect(wrapper.find('[role="dialog"]').exists()).toBe(false)
})
it('does not render when card is null', () => {
/**
* Test modal with null card.
*
* Even if isOpen is true, the modal should handle null card
* gracefully and not render incomplete content.
*/
const wrapper = mountModal({
card: null,
isOpen: true,
})
expect(wrapper.find('[role="dialog"]').exists()).toBe(false)
})
it('renders modal dialog when open with card', () => {
/**
* Test modal renders when open with valid card.
*
* The modal should display with proper ARIA attributes for
* accessibility when both conditions are met.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
const dialog = wrapper.find('[role="dialog"]')
expect(dialog.exists()).toBe(true)
expect(dialog.attributes('aria-modal')).toBe('true')
})
it('renders card name in header', () => {
/**
* Test card name display.
*
* The card name should be prominently displayed in the modal
* header so users immediately know which card they're viewing.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
const header = wrapper.find('h2')
expect(header.text()).toBe('Pikachu')
})
it('renders CardImage component with correct props', () => {
/**
* Test card image rendering.
*
* The modal should display a large card image using the
* CardImage component with the lg size variant.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
const cardImage = wrapper.findComponent(CardImage)
expect(cardImage.exists()).toBe(true)
expect(cardImage.props('src')).toBe(mockPokemonCard.imageUrl)
expect(cardImage.props('alt')).toBe(mockPokemonCard.name)
expect(cardImage.props('size')).toBe('lg')
})
it('renders owned quantity badge when provided', () => {
/**
* Test owned quantity display.
*
* In collection views, users need to see how many copies
* they own prominently displayed on the card image.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
ownedQuantity: 4,
})
expect(wrapper.text()).toContain('4x')
})
it('does not render quantity badge when quantity is 0', () => {
/**
* Test zero quantity handling.
*
* A zero quantity badge would be confusing, so it should
* not be displayed when the user owns no copies.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
ownedQuantity: 0,
})
expect(wrapper.text()).not.toContain('x')
})
it('renders TypeBadge for Pokemon cards', () => {
/**
* Test type badge for Pokemon.
*
* The type is essential information for deck building and
* gameplay, so it must be displayed in the modal.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
const typeBadge = wrapper.findComponent(TypeBadge)
expect(typeBadge.exists()).toBe(true)
expect(typeBadge.props('type')).toBe('lightning')
})
it('renders HP badge for Pokemon cards', () => {
/**
* Test HP display for Pokemon.
*
* HP is a critical gameplay stat that must be visible
* in the card detail view.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
expect(wrapper.text()).toContain('60 HP')
})
it('does not render HP for non-Pokemon cards', () => {
/**
* Test HP absence for non-Pokemon cards.
*
* Trainer and Energy cards don't have HP, so the HP
* element should not appear.
*/
const wrapper = mountModal({
card: mockTrainerCard,
isOpen: true,
})
expect(wrapper.text()).not.toContain('HP')
})
it('renders category badge', () => {
/**
* Test category display.
*
* The card category (pokemon, trainer, energy) helps users
* understand what type of card they're viewing.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
expect(wrapper.text()).toContain('pokemon')
})
it('renders rarity label', () => {
/**
* Test rarity display.
*
* Rarity is important for collection value and deck building
* rules (some formats restrict rarity).
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
expect(wrapper.text()).toContain('Common')
})
it('renders set information', () => {
/**
* Test set info display.
*
* Set ID and number help identify specific card printings
* for collectors and tournament legality.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
expect(wrapper.text()).toContain('Set: base')
expect(wrapper.text()).toContain('#25')
})
})
describe('Pokemon-specific content', () => {
it('renders attacks section for Pokemon cards', () => {
/**
* Test attacks section presence.
*
* Pokemon cards have attacks that are essential for gameplay,
* so they must be displayed in the detail view.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
expect(wrapper.text()).toContain('Attacks')
})
it('renders AttackDisplay for each attack', () => {
/**
* Test attack rendering.
*
* Each attack should be rendered using the AttackDisplay
* component for consistent formatting.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
const attacks = wrapper.findAllComponents(AttackDisplay)
expect(attacks).toHaveLength(2)
})
it('passes correct attack props to AttackDisplay', () => {
/**
* Test attack prop passing.
*
* The AttackDisplay component needs the full attack object
* to render energy cost, name, damage, and effect.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
const attacks = wrapper.findAllComponents(AttackDisplay)
expect(attacks[0].props('attack')).toEqual(mockPokemonCard.attacks![0])
expect(attacks[1].props('attack')).toEqual(mockPokemonCard.attacks![1])
})
it('does not render attacks section for Trainer cards', () => {
/**
* Test attack section absence for non-Pokemon.
*
* Trainer cards don't have attacks, so the section
* should not appear to avoid confusion.
*/
const wrapper = mountModal({
card: mockTrainerCard,
isOpen: true,
})
expect(wrapper.text()).not.toContain('Attacks')
})
it('does not render attacks section for Energy cards', () => {
/**
* Test attack section absence for Energy.
*
* Energy cards don't have attacks either.
*/
const wrapper = mountModal({
card: mockEnergyCard,
isOpen: true,
})
expect(wrapper.text()).not.toContain('Attacks')
})
it('renders weakness/resistance/retreat grid for Pokemon', () => {
/**
* Test stats grid for Pokemon.
*
* These stats affect gameplay decisions and must be
* visible in the detail view.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
expect(wrapper.text()).toContain('Weakness')
expect(wrapper.text()).toContain('Resistance')
expect(wrapper.text()).toContain('Retreat')
})
it('does not render stats grid for non-Pokemon cards', () => {
/**
* Test stats grid absence for non-Pokemon.
*
* Trainer and Energy cards don't have these stats.
*/
const wrapper = mountModal({
card: mockTrainerCard,
isOpen: true,
})
expect(wrapper.text()).not.toContain('Weakness')
expect(wrapper.text()).not.toContain('Resistance')
expect(wrapper.text()).not.toContain('Retreat')
})
})
describe('close behavior', () => {
it('emits close event when close button is clicked', async () => {
/**
* Test close button functionality.
*
* The close button is the primary way to dismiss the modal
* and must emit the close event for the parent to handle.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
const closeButton = wrapper.find('button[aria-label="Close modal"]')
await closeButton.trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('emits close event when backdrop is clicked', async () => {
/**
* Test backdrop click to close.
*
* Clicking outside the modal content (on the backdrop) is
* a common UX pattern for dismissing modals.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
const backdrop = wrapper.find('[role="dialog"]')
// Simulate clicking directly on the backdrop (event.target === event.currentTarget)
await backdrop.trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
})
it('does not emit close when modal content is clicked', async () => {
/**
* Test that content clicks don't close modal.
*
* Clicking on the modal content itself should not close
* the modal - only backdrop clicks should.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
// Click on the modal container (not the backdrop)
const modalContent = wrapper.find('.bg-surface.rounded-2xl')
await modalContent.trigger('click')
// The click should bubble up but the handler checks target === currentTarget
// Since we clicked on a child, it should not close
// Note: Due to the way Vue test utils handles events, we need to verify
// that the close event is NOT emitted from content clicks
const closeEvents = wrapper.emitted('close')
// If close was emitted, it should only be from the backdrop
if (closeEvents) {
// This is expected because the event bubbles - the actual component
// checks target === currentTarget which we can't easily test here
// The important thing is the logic exists in the component
}
})
it('emits close event when Escape key is pressed', async () => {
/**
* Test Escape key to close.
*
* Escape is the standard keyboard shortcut for closing modals
* and is essential for accessibility.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
// Dispatch a global keydown event
const event = new KeyboardEvent('keydown', { key: 'Escape' })
document.dispatchEvent(event)
await nextTick()
expect(wrapper.emitted('close')).toBeTruthy()
})
it('does not respond to other keys', async () => {
/**
* Test that other keys don't close modal.
*
* Only Escape should close the modal to avoid accidental dismissal.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
const event = new KeyboardEvent('keydown', { key: 'Enter' })
document.dispatchEvent(event)
await nextTick()
expect(wrapper.emitted('close')).toBeFalsy()
})
})
describe('accessibility', () => {
it('has correct ARIA attributes', () => {
/**
* Test ARIA attributes for screen readers.
*
* The modal must have proper ARIA attributes so screen reader
* users understand they're in a modal context.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
const dialog = wrapper.find('[role="dialog"]')
expect(dialog.attributes('aria-modal')).toBe('true')
expect(dialog.attributes('aria-label')).toContain('Pikachu')
})
it('close button has aria-label', () => {
/**
* Test close button accessibility.
*
* The close button only has an icon, so it needs an aria-label
* for screen readers to understand its purpose.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
const closeButton = wrapper.find('button[aria-label="Close modal"]')
expect(closeButton.exists()).toBe(true)
})
it('focuses close button when modal opens', async () => {
/**
* Test initial focus placement.
*
* When a modal opens, focus should move into the modal
* to help keyboard and screen reader users.
*/
// Mount with isOpen false first
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: false,
})
// Update to open
await wrapper.setProps({ isOpen: true })
await nextTick()
// The component should try to focus the close button
// We can't easily test actual focus in jsdom, but we verify
// the ref exists and the watch is set up
const closeButton = wrapper.find('button[aria-label="Close modal"]')
expect(closeButton.exists()).toBe(true)
})
})
describe('styling', () => {
it('applies type-colored border to card image container', () => {
/**
* Test type-colored styling.
*
* The card image should have a border matching the card's type
* for visual consistency with the design system.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
const imageContainer = wrapper.find('.rounded-xl.border-2.shadow-xl')
expect(imageContainer.classes()).toContain('border-type-lightning')
})
it('applies default border for cards without type', () => {
/**
* Test fallback border color.
*
* Cards without a type (like some trainers) should use
* a neutral border color.
*/
const wrapper = mountModal({
card: mockTrainerCard,
isOpen: true,
})
const imageContainer = wrapper.find('.rounded-xl.border-2.shadow-xl')
expect(imageContainer.classes()).toContain('border-surface-light')
})
it('has backdrop blur styling', () => {
/**
* Test backdrop styling.
*
* The backdrop should have blur effect for visual depth
* as specified in the design reference.
*/
const wrapper = mountModal({
card: mockPokemonCard,
isOpen: true,
})
const backdrop = wrapper.find('.backdrop-blur-sm')
expect(backdrop.exists()).toBe(true)
})
})
})
describe('EnergyCost', () => {
it('renders energy circles for each cost', () => {
/**
* Test energy circle rendering.
*
* Each energy type in the cost array should be rendered
* as a colored circle for visual recognition.
*/
const wrapper = mount(EnergyCost, {
props: { cost: ['lightning', 'lightning', 'colorless'] },
})
const circles = wrapper.findAll('.rounded-full')
expect(circles).toHaveLength(3)
})
it('applies correct type colors', () => {
/**
* Test type color application.
*
* Each energy circle should have the background color
* matching its type for quick visual identification.
*/
const wrapper = mount(EnergyCost, {
props: { cost: ['fire', 'water'] },
})
const circles = wrapper.findAll('.rounded-full')
expect(circles[0].classes()).toContain('bg-type-fire')
expect(circles[1].classes()).toContain('bg-type-water')
})
it('renders "Free" text when cost is empty', () => {
/**
* Test empty cost handling.
*
* Some attacks have no cost - display "Free" text
* so users know the attack can be used without energy.
*/
const wrapper = mount(EnergyCost, {
props: { cost: [] },
})
expect(wrapper.text()).toContain('Free')
})
it('has aria-label for accessibility', () => {
/**
* Test accessibility label.
*
* The energy cost needs a text description for screen
* readers since the visual is just colored circles.
*/
const wrapper = mount(EnergyCost, {
props: { cost: ['fire', 'fire'] },
})
expect(wrapper.attributes('aria-label')).toContain('fire')
})
it('applies size variants correctly', () => {
/**
* Test size variant styling.
*
* Different contexts need different sizes - small for
* attack rows, medium for detail views.
*/
const wrapperSm = mount(EnergyCost, {
props: { cost: ['fire'], size: 'sm' },
})
const wrapperLg = mount(EnergyCost, {
props: { cost: ['fire'], size: 'lg' },
})
expect(wrapperSm.find('.rounded-full').classes()).toContain('w-4')
expect(wrapperLg.find('.rounded-full').classes()).toContain('w-6')
})
})
describe('AttackDisplay', () => {
const mockAttack = {
name: 'Thunder Shock',
cost: ['lightning'] as const,
damage: 20,
effect: 'Flip a coin. If heads, the Defending Pokemon is now Paralyzed.',
}
const mockAttackNoEffect = {
name: 'Tackle',
cost: ['colorless'] as const,
damage: 10,
}
const mockAttackNoDamage = {
name: 'Growl',
cost: ['colorless'] as const,
damage: 0,
effect: 'During your opponent\'s next turn, the Defending Pokemon\'s attacks do 20 less damage.',
}
it('renders attack name', () => {
/**
* Test attack name display.
*
* The attack name is the primary identifier for the attack
* and must be visible.
*/
const wrapper = mount(AttackDisplay, {
props: { attack: mockAttack },
})
expect(wrapper.text()).toContain('Thunder Shock')
})
it('renders EnergyCost component', () => {
/**
* Test energy cost display.
*
* Players need to see the energy cost to know if they
* can use the attack.
*/
const wrapper = mount(AttackDisplay, {
props: { attack: mockAttack },
})
const energyCost = wrapper.findComponent(EnergyCost)
expect(energyCost.exists()).toBe(true)
expect(energyCost.props('cost')).toEqual(['lightning'])
})
it('renders damage value when present', () => {
/**
* Test damage display.
*
* Damage is a critical stat for choosing attacks.
*/
const wrapper = mount(AttackDisplay, {
props: { attack: mockAttack },
})
expect(wrapper.text()).toContain('20')
})
it('does not render damage when zero', () => {
/**
* Test zero damage handling.
*
* Some attacks don't deal damage - don't show "0" as
* it would be confusing.
*/
const wrapper = mount(AttackDisplay, {
props: { attack: mockAttackNoDamage },
})
// The text should not contain a standalone "0" for damage
// (it might contain 0 in the effect text, but not as damage)
const damageElement = wrapper.find('.text-error')
expect(damageElement.exists()).toBe(false)
})
it('renders effect description when present', () => {
/**
* Test effect text display.
*
* Attack effects provide important gameplay information
* about what the attack does beyond damage.
*/
const wrapper = mount(AttackDisplay, {
props: { attack: mockAttack },
})
expect(wrapper.text()).toContain('Flip a coin')
})
it('does not render effect when absent', () => {
/**
* Test missing effect handling.
*
* Simple attacks without effects shouldn't have an
* empty effect section.
*/
const wrapper = mount(AttackDisplay, {
props: { attack: mockAttackNoEffect },
})
// The effect paragraph should not exist
const effectParagraph = wrapper.find('p')
expect(effectParagraph.exists()).toBe(false)
})
it('has hover styling class', () => {
/**
* Test interactive styling.
*
* Attack rows should have subtle hover feedback to
* feel interactive even in a read-only context.
*/
const wrapper = mount(AttackDisplay, {
props: { attack: mockAttack },
})
expect(wrapper.classes()).toContain('hover:bg-surface-light')
})
})

View File

@ -0,0 +1,370 @@
<script setup lang="ts">
/**
* Card detail modal component for displaying full card information.
*
* A modal overlay that shows complete details for a Pokemon, Trainer, or Energy card:
* - Large card image with type-colored border glow
* - Full stats: HP, type, category, rarity
* - All attacks with energy costs and effects (Pokemon)
* - Weakness, resistance, retreat cost (Pokemon)
* - Set information
* - Owned quantity badge
*
* Accessibility features:
* - Traps focus within modal when open
* - Closes on Escape key press
* - Closes on backdrop click
* - Proper ARIA attributes
*
* Uses Teleport to render in document body for proper z-index stacking.
*/
import { computed, onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
import type { CardDefinition, CardType } from '@/types'
import AttackDisplay from './AttackDisplay.vue'
import CardImage from './CardImage.vue'
import EnergyCost from './EnergyCost.vue'
import TypeBadge from './TypeBadge.vue'
const props = withDefaults(
defineProps<{
/** The card to display (null when modal is closed) */
card: CardDefinition | null
/** Whether the modal is open */
isOpen: boolean
/** Number of copies owned (for collection display) */
ownedQuantity?: number
}>(),
{
ownedQuantity: 0,
}
)
const emit = defineEmits<{
/** Emitted when the modal should close */
close: []
}>()
/** Reference to the modal container for focus trapping */
const modalRef = ref<HTMLElement | null>(null)
/** Reference to the close button for initial focus */
const closeButtonRef = ref<HTMLButtonElement | null>(null)
/**
* Type-based glow color classes for the card image container.
* Creates a subtle colored shadow effect matching the card's type.
*/
const glowColors: Record<CardType | 'default', string> = {
grass: 'shadow-type-grass/30',
fire: 'shadow-type-fire/30',
water: 'shadow-type-water/30',
lightning: 'shadow-type-lightning/30',
psychic: 'shadow-type-psychic/30',
fighting: 'shadow-type-fighting/30',
darkness: 'shadow-type-darkness/30',
metal: 'shadow-type-metal/30',
fairy: 'shadow-type-fairy/30',
dragon: 'shadow-type-dragon/30',
colorless: 'shadow-type-colorless/30',
default: 'shadow-surface-light/30',
}
/**
* Border color classes based on card type.
*/
const borderColors: Record<CardType | 'default', string> = {
grass: 'border-type-grass',
fire: 'border-type-fire',
water: 'border-type-water',
lightning: 'border-type-lightning',
psychic: 'border-type-psychic',
fighting: 'border-type-fighting',
darkness: 'border-type-darkness',
metal: 'border-type-metal',
fairy: 'border-type-fairy',
dragon: 'border-type-dragon',
colorless: 'border-type-colorless',
default: 'border-surface-light',
}
/** Get glow class for the current card's type */
const glowClass = computed(() => {
const type = props.card?.type
return type ? glowColors[type] : glowColors.default
})
/** Get border class for the current card's type */
const borderClass = computed(() => {
const type = props.card?.type
return type ? borderColors[type] : borderColors.default
})
/**
* Rarity display labels for user-friendly names.
*/
const rarityLabels: Record<string, string> = {
common: 'Common',
uncommon: 'Uncommon',
rare: 'Rare',
holo: 'Holo Rare',
ultra: 'Ultra Rare',
}
/** Format rarity for display */
const rarityLabel = computed(() => {
if (!props.card?.rarity) return ''
return rarityLabels[props.card.rarity] || props.card.rarity
})
/**
* Handle Escape key to close modal.
*/
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
emit('close')
}
}
/**
* Handle backdrop click to close modal.
* Only closes if clicking directly on the backdrop, not modal content.
*/
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
emit('close')
}
}
/**
* Trap focus within the modal for accessibility.
* When tabbing past the last focusable element, wraps to the first.
*/
function handleTabTrap(event: KeyboardEvent) {
if (event.key !== 'Tab' || !modalRef.value) return
const focusableElements = modalRef.value.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault()
lastElement?.focus()
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault()
firstElement?.focus()
}
}
/**
* Focus the close button when modal opens.
*/
watch(
() => props.isOpen,
async (isOpen) => {
if (isOpen) {
await nextTick()
closeButtonRef.value?.focus()
}
}
)
/**
* Add/remove global keyboard listener for Escape key.
*/
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isOpen && card"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
role="dialog"
aria-modal="true"
:aria-label="`${card.name} card details`"
@click="handleBackdropClick"
@keydown="handleTabTrap"
>
<!-- Modal container -->
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="isOpen"
ref="modalRef"
class="relative w-full max-w-lg max-h-[90vh] overflow-hidden bg-surface rounded-2xl shadow-2xl"
>
<!-- Header with close button -->
<div class="flex items-center justify-between p-4 border-b border-surface-light">
<h2 class="text-xl font-bold text-text truncate">
{{ card.name }}
</h2>
<button
ref="closeButtonRef"
class="p-1.5 rounded-lg text-text-muted hover:text-text hover:bg-surface-light transition-colors"
aria-label="Close modal"
@click="emit('close')"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Scrollable content -->
<div class="overflow-y-auto max-h-[calc(90vh-4rem)] p-4">
<!-- Card image section -->
<div class="flex justify-center mb-6">
<div class="relative">
<!-- Owned quantity badge -->
<div
v-if="ownedQuantity > 0"
class="absolute -top-2 -right-2 z-10 min-w-[1.75rem] h-7 px-2 bg-primary text-white text-sm font-bold rounded-full flex items-center justify-center shadow-lg"
>
{{ ownedQuantity }}x
</div>
<!-- Card image with type glow -->
<div
class="rounded-xl border-2 shadow-xl overflow-hidden"
:class="[borderClass, glowClass]"
>
<CardImage
:src="card.imageUrl"
:alt="card.name"
size="lg"
/>
</div>
</div>
</div>
<!-- Card info section -->
<div class="space-y-4">
<!-- Type, HP, Category, Rarity row -->
<div class="flex flex-wrap items-center gap-2">
<!-- Type badge (Pokemon/Energy) -->
<TypeBadge
v-if="card.type"
:type="card.type"
size="md"
/>
<!-- HP (Pokemon only) -->
<span
v-if="card.category === 'pokemon' && card.hp"
class="px-2 py-0.5 rounded-full bg-error/20 text-error text-sm font-bold"
>
{{ card.hp }} HP
</span>
<!-- Category -->
<span class="px-2 py-0.5 rounded-full bg-surface-light text-text-muted text-xs font-medium uppercase">
{{ card.category }}
</span>
<!-- Rarity -->
<span class="px-2 py-0.5 rounded-full bg-surface-light text-text-muted text-xs font-medium">
{{ rarityLabel }}
</span>
</div>
<!-- Attacks section (Pokemon only) -->
<div
v-if="card.category === 'pokemon' && card.attacks && card.attacks.length > 0"
class="space-y-2"
>
<h3 class="text-sm font-semibold text-text-muted uppercase tracking-wide">
Attacks
</h3>
<div class="space-y-2">
<AttackDisplay
v-for="(attack, index) in card.attacks"
:key="index"
:attack="attack"
/>
</div>
</div>
<!-- Weakness/Resistance/Retreat (Pokemon only) -->
<div
v-if="card.category === 'pokemon'"
class="grid grid-cols-3 gap-3"
>
<!-- Weakness -->
<div class="text-center p-2 rounded-lg bg-surface-light/50">
<span class="block text-xs text-text-muted uppercase mb-1">Weakness</span>
<span class="text-sm font-medium text-text">
<!-- Placeholder - extend CardDefinition type to include weakness -->
-
</span>
</div>
<!-- Resistance -->
<div class="text-center p-2 rounded-lg bg-surface-light/50">
<span class="block text-xs text-text-muted uppercase mb-1">Resistance</span>
<span class="text-sm font-medium text-text">
<!-- Placeholder - extend CardDefinition type to include resistance -->
-
</span>
</div>
<!-- Retreat Cost -->
<div class="text-center p-2 rounded-lg bg-surface-light/50">
<span class="block text-xs text-text-muted uppercase mb-1">Retreat</span>
<div class="flex justify-center">
<!-- Placeholder - extend CardDefinition type to include retreatCost -->
<EnergyCost
:cost="[]"
size="sm"
/>
</div>
</div>
</div>
<!-- Set info -->
<div class="flex items-center justify-between text-sm text-text-muted pt-2 border-t border-surface-light">
<span>Set: {{ card.setId }}</span>
<span>#{{ card.setNumber }}</span>
</div>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>

View File

@ -0,0 +1,500 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import type { CardDefinition } from '@/types'
import CardDisplay from './CardDisplay.vue'
import CardImage from './CardImage.vue'
import TypeBadge from './TypeBadge.vue'
/**
* Test fixtures for card display tests.
*/
const mockPokemonCard: CardDefinition = {
id: 'pikachu-base-001',
name: 'Pikachu',
category: 'pokemon',
type: 'lightning',
hp: 60,
attacks: [
{ name: 'Thunder Shock', cost: ['lightning'], damage: 20 },
],
imageUrl: 'https://example.com/pikachu.png',
rarity: 'common',
setId: 'base',
setNumber: 25,
}
const mockTrainerCard: CardDefinition = {
id: 'professor-oak-base-001',
name: 'Professor Oak',
category: 'trainer',
imageUrl: 'https://example.com/oak.png',
rarity: 'uncommon',
setId: 'base',
setNumber: 88,
}
const mockEnergyCard: CardDefinition = {
id: 'fire-energy-base-001',
name: 'Fire Energy',
category: 'energy',
type: 'fire',
imageUrl: 'https://example.com/fire-energy.png',
rarity: 'common',
setId: 'base',
setNumber: 98,
}
describe('CardDisplay', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('rendering', () => {
it('renders card name', () => {
/**
* Test that the card name is displayed prominently.
*
* The card name is the primary identifier for players, so it must
* be visible at all times in the card display.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard },
})
expect(wrapper.text()).toContain('Pikachu')
})
it('renders HP for Pokemon cards', () => {
/**
* Test that HP is displayed for Pokemon cards.
*
* HP is a critical stat for gameplay decisions - players need to
* know card HP at a glance when selecting attackers and targets.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard },
})
expect(wrapper.text()).toContain('60 HP')
})
it('does not render HP for non-Pokemon cards', () => {
/**
* Test that HP is not displayed for Trainer cards.
*
* Trainer cards don't have HP, so the HP element should not
* appear to avoid confusion.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockTrainerCard },
})
expect(wrapper.text()).not.toContain('HP')
})
it('renders TypeBadge for Pokemon cards', () => {
/**
* Test that type badge is shown for Pokemon cards.
*
* Type is essential for energy attachment and weakness/resistance
* calculations, so it must be visible.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard },
})
const typeBadge = wrapper.findComponent(TypeBadge)
expect(typeBadge.exists()).toBe(true)
expect(typeBadge.props('type')).toBe('lightning')
})
it('renders TypeBadge for Energy cards', () => {
/**
* Test that type badge is shown for Energy cards.
*
* Energy type determines which Pokemon can use the energy,
* so it must be clearly indicated.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockEnergyCard },
})
const typeBadge = wrapper.findComponent(TypeBadge)
expect(typeBadge.exists()).toBe(true)
expect(typeBadge.props('type')).toBe('fire')
})
it('renders "Trainer" label for Trainer cards', () => {
/**
* Test that Trainer cards show a category label.
*
* Since trainers don't have types, we display the category
* to help identify the card type at a glance.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockTrainerCard },
})
expect(wrapper.text()).toContain('Trainer')
})
it('renders CardImage with correct props', () => {
/**
* Test that CardImage receives correct props.
*
* The card image is the visual anchor of the display.
* It must receive the correct src and alt for proper
* loading and accessibility.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard },
})
const cardImage = wrapper.findComponent(CardImage)
expect(cardImage.exists()).toBe(true)
expect(cardImage.props('src')).toBe(mockPokemonCard.imageUrl)
expect(cardImage.props('alt')).toBe(mockPokemonCard.name)
})
it('renders quantity badge when quantity is provided', () => {
/**
* Test quantity badge display.
*
* In collection and deck views, users need to see how many
* copies of a card they own at a glance.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard, quantity: 3 },
})
expect(wrapper.text()).toContain('3')
})
it('does not render quantity badge when quantity is undefined', () => {
/**
* Test quantity badge absence.
*
* When quantity isn't relevant (e.g., viewing a single card),
* the badge should not appear.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard },
})
// The badge element should not exist
const badge = wrapper.find('.bg-primary.rounded-full')
expect(badge.exists()).toBe(false)
})
it('does not render quantity badge when quantity is 0', () => {
/**
* Test that zero quantity hides the badge.
*
* A quantity of 0 means the card isn't available, so we
* shouldn't show a confusing "0" badge.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard, quantity: 0 },
})
const badge = wrapper.find('.bg-primary.rounded-full')
expect(badge.exists()).toBe(false)
})
})
describe('size variants', () => {
it('applies sm size classes', () => {
/**
* Test small size variant.
*
* Small cards are used in compact views like deck builder
* card rows where space is limited.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard, size: 'sm' },
})
expect(wrapper.classes()).toContain('p-1')
})
it('applies md size classes (default)', () => {
/**
* Test medium size variant (default).
*
* Medium is the default for most grid views like collection
* and deck builder collection picker.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard },
})
expect(wrapper.classes()).toContain('p-2')
})
it('applies lg size classes', () => {
/**
* Test large size variant.
*
* Large cards are used in detail views and modals where
* more visual space is available.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard, size: 'lg' },
})
expect(wrapper.classes()).toContain('p-3')
})
})
describe('interaction states', () => {
it('applies type-colored border for Pokemon cards', () => {
/**
* Test type-colored borders.
*
* Type-colored borders help users quickly identify card types
* visually, following the design reference patterns.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard },
})
expect(wrapper.classes()).toContain('border-type-lightning')
})
it('applies default border for cards without type', () => {
/**
* Test fallback border for typeless cards.
*
* Trainer cards don't have a type, so they should use
* a neutral border color.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockTrainerCard },
})
expect(wrapper.classes()).toContain('border-surface-light')
})
it('applies selected state classes', () => {
/**
* Test selected state styling.
*
* Selected cards need a clear visual indicator so users
* know which cards they've chosen in deck building.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard, selected: true },
})
expect(wrapper.classes()).toContain('ring-2')
expect(wrapper.classes()).toContain('ring-primary')
})
it('applies disabled state classes', () => {
/**
* Test disabled state styling.
*
* Disabled cards (e.g., no copies available) should be
* visually distinct so users know they can't be selected.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard, disabled: true },
})
expect(wrapper.classes()).toContain('opacity-50')
expect(wrapper.classes()).toContain('grayscale')
expect(wrapper.classes()).toContain('cursor-not-allowed')
})
it('has tabindex 0 when not disabled', () => {
/**
* Test keyboard accessibility.
*
* Cards should be focusable for keyboard navigation
* in deck building and collection views.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard },
})
expect(wrapper.attributes('tabindex')).toBe('0')
})
it('has tabindex -1 when disabled', () => {
/**
* Test disabled keyboard accessibility.
*
* Disabled cards should not be focusable to avoid
* confusing keyboard navigation.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard, disabled: true },
})
expect(wrapper.attributes('tabindex')).toBe('-1')
})
})
describe('events', () => {
it('emits click event with card id', async () => {
/**
* Test click event emission.
*
* Click events drive card selection and opening detail views.
* The card id must be included for the parent to identify
* which card was clicked.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard },
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
expect(wrapper.emitted('click')![0]).toEqual([mockPokemonCard.id])
})
it('does not emit click when disabled', async () => {
/**
* Test disabled click prevention.
*
* Disabled cards should not respond to clicks to prevent
* invalid actions.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard, disabled: true },
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeFalsy()
})
it('emits select event with card id and new state when selectable', async () => {
/**
* Test select event for selectable cards.
*
* When a card is selectable (deck building mode), clicking
* should emit both click and select events with the toggled state.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard, selectable: true, selected: false },
})
await wrapper.trigger('click')
expect(wrapper.emitted('select')).toBeTruthy()
expect(wrapper.emitted('select')![0]).toEqual([mockPokemonCard.id, true])
})
it('emits select with false when deselecting', async () => {
/**
* Test deselection event.
*
* Clicking an already-selected card should toggle it off.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard, selectable: true, selected: true },
})
await wrapper.trigger('click')
expect(wrapper.emitted('select')).toBeTruthy()
expect(wrapper.emitted('select')![0]).toEqual([mockPokemonCard.id, false])
})
it('responds to Enter key', async () => {
/**
* Test keyboard activation with Enter.
*
* Cards should be activatable via keyboard for accessibility.
* Enter is the standard key for activating buttons.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard },
})
await wrapper.trigger('keydown', { key: 'Enter' })
expect(wrapper.emitted('click')).toBeTruthy()
})
it('responds to Space key', async () => {
/**
* Test keyboard activation with Space.
*
* Space is also a standard key for button activation
* and should work the same as Enter.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard },
})
await wrapper.trigger('keydown', { key: ' ' })
expect(wrapper.emitted('click')).toBeTruthy()
})
it('does not respond to other keys', async () => {
/**
* Test that random keys don't activate the card.
*
* Only Enter and Space should trigger activation.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard },
})
await wrapper.trigger('keydown', { key: 'a' })
expect(wrapper.emitted('click')).toBeFalsy()
})
})
describe('accessibility', () => {
it('has button role', () => {
/**
* Test ARIA role.
*
* The card acts as a button, so it should have the
* button role for screen readers.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard },
})
expect(wrapper.attributes('role')).toBe('button')
})
it('has aria-pressed for selectable cards', () => {
/**
* Test aria-pressed for toggle buttons.
*
* Selectable cards are toggle buttons, so they should
* have aria-pressed to indicate selection state.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard, selectable: true, selected: true },
})
expect(wrapper.attributes('aria-pressed')).toBe('true')
})
it('has aria-disabled when disabled', () => {
/**
* Test aria-disabled attribute.
*
* Disabled cards should communicate their state to
* screen readers via aria-disabled.
*/
const wrapper = mount(CardDisplay, {
props: { card: mockPokemonCard, disabled: true },
})
expect(wrapper.attributes('aria-disabled')).toBe('true')
})
})
})

View File

@ -0,0 +1,199 @@
<script setup lang="ts">
/**
* Card display component for showing Pokemon/Trainer/Energy cards.
*
* A reusable card component that displays:
* - Card image with loading/error handling
* - Card name and HP (for Pokemon)
* - Type badge with appropriate coloring
* - Optional quantity overlay for collection views
* - Selection and disabled states for deck building
*
* The component follows the design patterns from DESIGN_REFERENCE.md
* with type-colored borders and hover effects.
*/
import { computed } from 'vue'
import type { CardDefinition, CardType } from '@/types'
import CardImage from './CardImage.vue'
import TypeBadge from './TypeBadge.vue'
const props = withDefaults(
defineProps<{
/** The card definition to display */
card: CardDefinition
/** Size variant */
size?: 'sm' | 'md' | 'lg'
/** Quantity to show (for collection/deck views) */
quantity?: number
/** Whether the card can be selected */
selectable?: boolean
/** Whether the card is currently selected */
selected?: boolean
/** Whether the card is disabled (greyed out) */
disabled?: boolean
}>(),
{
size: 'md',
quantity: undefined,
selectable: false,
selected: false,
disabled: false,
}
)
const emit = defineEmits<{
/** Emitted when the card is clicked (with card id) */
click: [cardId: string]
/** Emitted when the card is selected/deselected (for selectable cards) */
select: [cardId: string, selected: boolean]
}>()
/**
* Border color classes based on card type.
* Falls back to surface-light for cards without a type (like some trainers).
*/
const borderColors: Record<CardType | 'default', string> = {
grass: 'border-type-grass',
fire: 'border-type-fire',
water: 'border-type-water',
lightning: 'border-type-lightning',
psychic: 'border-type-psychic',
fighting: 'border-type-fighting',
darkness: 'border-type-darkness',
metal: 'border-type-metal',
fairy: 'border-type-fairy',
dragon: 'border-type-dragon',
colorless: 'border-type-colorless',
default: 'border-surface-light',
}
/** Get the border color class for the current card */
const borderColorClass = computed(() => {
const type = props.card.type
return type ? borderColors[type] : borderColors.default
})
/**
* Container size classes for different variants.
* Maintains consistent padding and layout per size.
*/
const containerSizeClasses = {
sm: 'p-1',
md: 'p-2',
lg: 'p-3',
}
/**
* Text size classes for card name.
*/
const nameSizeClasses = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
}
/**
* Handle click on the card.
* Emits click event unless disabled.
* For selectable cards, also toggles selection.
*/
function handleClick() {
if (props.disabled) return
emit('click', props.card.id)
if (props.selectable) {
emit('select', props.card.id, !props.selected)
}
}
/**
* Handle keyboard activation (Enter/Space).
*/
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleClick()
}
}
</script>
<template>
<div
class="relative bg-surface rounded-xl border-2 shadow-md transition-all duration-200 ease-out"
:class="[
borderColorClass,
containerSizeClasses[props.size],
// Hover effects (only when not disabled)
!disabled && 'cursor-pointer hover:shadow-xl hover:scale-[1.02] hover:-translate-y-1',
// Selected state
selected && 'ring-2 ring-primary ring-offset-2 ring-offset-gray-900',
// Disabled state
disabled && 'opacity-50 grayscale cursor-not-allowed',
]"
:tabindex="disabled ? -1 : 0"
role="button"
:aria-pressed="selectable ? selected : undefined"
:aria-disabled="disabled"
@click="handleClick"
@keydown="handleKeydown"
>
<!-- Quantity badge -->
<div
v-if="quantity !== undefined && quantity > 0"
class="absolute -top-2 -right-2 z-10 min-w-[1.5rem] h-6 px-1.5 bg-primary text-white text-sm font-bold rounded-full flex items-center justify-center shadow-lg"
>
{{ quantity }}
</div>
<!-- Card image -->
<CardImage
:src="card.imageUrl"
:alt="card.name"
:size="props.size"
class="w-full"
/>
<!-- Card info -->
<div class="mt-2">
<!-- Name row -->
<div class="flex items-center justify-between gap-1">
<span
class="font-medium text-gray-100 truncate"
:class="nameSizeClasses[props.size]"
>
{{ card.name }}
</span>
<!-- HP for Pokemon cards -->
<span
v-if="card.category === 'pokemon' && card.hp"
class="text-xs font-bold text-error shrink-0"
>
{{ card.hp }} HP
</span>
</div>
<!-- Type badge (only for Pokemon and Energy) -->
<div
v-if="card.type && (card.category === 'pokemon' || card.category === 'energy')"
class="mt-1"
>
<TypeBadge
:type="card.type"
size="sm"
/>
</div>
<!-- Category label for Trainers -->
<div
v-else-if="card.category === 'trainer'"
class="mt-1"
>
<span class="text-xs text-gray-400 uppercase">Trainer</span>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,105 @@
<script setup lang="ts">
/**
* Card image component with loading and error states.
*
* Handles the display of card artwork with:
* - Loading placeholder with pulse animation
* - Error fallback with placeholder graphic
* - Smooth fade-in transition when image loads
* - Multiple size variants for different contexts
*/
import { ref } from 'vue'
const props = withDefaults(
defineProps<{
/** Image source URL */
src: string
/** Alt text for accessibility */
alt: string
/** Size variant affecting dimensions */
size?: 'sm' | 'md' | 'lg'
}>(),
{
size: 'md',
}
)
/** Whether the image is still loading */
const isLoading = ref(true)
/** Whether the image failed to load */
const hasError = ref(false)
/**
* Handle successful image load.
* Hides loading state and shows the image with fade-in.
*/
function handleLoad() {
isLoading.value = false
hasError.value = false
}
/**
* Handle image load error.
* Shows placeholder fallback.
*/
function handleError() {
isLoading.value = false
hasError.value = true
}
/**
* Size classes for the image container.
* Uses standard Pokemon card aspect ratio (2.5:3.5).
*/
const sizeClasses = {
sm: 'w-16 h-[5.6rem]', // ~64x90px - thumbnails
md: 'w-28 h-[9.8rem]', // ~112x156px - grid cards
lg: 'w-48 h-[16.8rem]', // ~192x268px - detail view
}
</script>
<template>
<div
class="relative overflow-hidden rounded-lg bg-surface-light"
:class="sizeClasses[props.size]"
>
<!-- Loading placeholder -->
<div
v-if="isLoading"
class="absolute inset-0 animate-pulse bg-surface-light"
/>
<!-- Error placeholder -->
<div
v-else-if="hasError"
class="absolute inset-0 flex flex-col items-center justify-center bg-surface-light text-gray-500"
>
<svg
class="w-8 h-8 mb-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span class="text-xs">No image</span>
</div>
<!-- Actual image -->
<img
:src="props.src"
:alt="props.alt"
class="w-full h-full object-cover transition-opacity duration-200"
:class="[isLoading || hasError ? 'opacity-0' : 'opacity-100']"
@load="handleLoad"
@error="handleError"
>
</div>
</template>

View File

@ -0,0 +1,86 @@
<script setup lang="ts">
/**
* Energy cost display component for showing attack energy requirements.
*
* Renders a horizontal row of colored energy type indicators that show
* the cost to use an attack. Each energy type is shown as a small
* colored circle matching the type's color scheme.
*
* Used in:
* - CardDetailModal for attack costs
* - Deck builder for energy requirements
* - Game board for attack availability
*/
import type { CardType } from '@/types'
const props = withDefaults(
defineProps<{
/** Array of energy types required for the attack */
cost: CardType[]
/** Size variant for the energy circles */
size?: 'sm' | 'md' | 'lg'
}>(),
{
size: 'md',
}
)
/**
* Background color classes for each energy type.
* Uses the custom type color palette from Tailwind config.
*/
const typeColors: Record<CardType, string> = {
grass: 'bg-type-grass',
fire: 'bg-type-fire',
water: 'bg-type-water',
lightning: 'bg-type-lightning',
psychic: 'bg-type-psychic',
fighting: 'bg-type-fighting',
darkness: 'bg-type-darkness',
metal: 'bg-type-metal',
fairy: 'bg-type-fairy',
dragon: 'bg-type-dragon',
colorless: 'bg-type-colorless',
}
/**
* Size classes for the energy circles.
*/
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
}
/**
* Gap classes between energy circles.
*/
const gapClasses = {
sm: 'gap-0.5',
md: 'gap-1',
lg: 'gap-1.5',
}
</script>
<template>
<div
class="inline-flex items-center"
:class="gapClasses[props.size]"
role="img"
:aria-label="`Energy cost: ${props.cost.join(', ')}`"
>
<span
v-for="(energy, index) in props.cost"
:key="`${energy}-${index}`"
class="rounded-full shadow-sm ring-1 ring-black/20"
:class="[typeColors[energy], sizeClasses[props.size]]"
:title="energy"
/>
<span
v-if="props.cost.length === 0"
class="text-xs text-text-muted italic"
>
Free
</span>
</div>
</template>

View File

@ -0,0 +1,79 @@
<script setup lang="ts">
/**
* Type badge component for displaying Pokemon card types.
*
* Shows a colored badge with the type name. Used in card displays,
* filters, and anywhere type information needs to be shown.
*/
import type { CardType } from '@/types'
const props = withDefaults(
defineProps<{
/** The Pokemon type to display */
type: CardType
/** Size variant */
size?: 'sm' | 'md'
/** Whether to show text label (or just the colored dot) */
showLabel?: boolean
}>(),
{
size: 'md',
showLabel: true,
}
)
/**
* Type color mapping for Tailwind classes.
* Background colors use the custom type-* colors from the theme.
*/
const typeColors: Record<CardType, string> = {
grass: 'bg-type-grass',
fire: 'bg-type-fire',
water: 'bg-type-water',
lightning: 'bg-type-lightning',
psychic: 'bg-type-psychic',
fighting: 'bg-type-fighting',
darkness: 'bg-type-darkness',
metal: 'bg-type-metal',
fairy: 'bg-type-fairy',
dragon: 'bg-type-dragon',
colorless: 'bg-type-colorless',
}
/**
* Text colors for types - most use white, but some light types need dark text.
*/
const textColors: Record<CardType, string> = {
grass: 'text-white',
fire: 'text-white',
water: 'text-white',
lightning: 'text-gray-900',
psychic: 'text-white',
fighting: 'text-white',
darkness: 'text-white',
metal: 'text-gray-900',
fairy: 'text-gray-900',
dragon: 'text-white',
colorless: 'text-gray-900',
}
const sizeClasses = {
sm: 'px-1.5 py-0.5 text-xs',
md: 'px-2 py-0.5 text-xs',
}
</script>
<template>
<span
class="inline-flex items-center gap-1 rounded-full font-medium capitalize"
:class="[
typeColors[props.type],
textColors[props.type],
sizeClasses[props.size],
]"
>
<template v-if="showLabel">
{{ props.type }}
</template>
</span>
</template>

View File

@ -0,0 +1,335 @@
<script setup lang="ts">
/**
* Left panel of deck builder for picking cards from collection.
*
* Displays:
* - Filter bar with search, type, category, and rarity filters
* - Card grid showing collection cards (draggable to deck)
* - Quantity badge showing available count
* - Disabled state for exhausted cards
* - Drop target for removing cards from deck
*
* Supports both click-to-add and drag-and-drop interactions.
* Click is the primary interaction; drag-drop is an enhancement.
*
* @example
* ```vue
* <CollectionPicker
* :cards="collectionCards"
* :is-loading="isLoading"
* :deck-cards="deckCardsMap"
* :drag-drop="dragDrop"
* @select="handleCardSelect"
* @remove-from-deck="handleRemoveFromDeck"
* />
* ```
*/
import { ref, computed } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import type { CollectionCard, CardType, CardCategory, CardDefinition } from '@/types'
import type { UseDragDrop } from '@/composables/useDragDrop'
import FilterBar from '@/components/ui/FilterBar.vue'
import CardDisplay from '@/components/cards/CardDisplay.vue'
import SkeletonCard from '@/components/ui/SkeletonCard.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
const props = withDefaults(
defineProps<{
/** All cards in the collection */
cards: CollectionCard[]
/** Whether collection is loading */
isLoading: boolean
/** Map of cardDefinitionId -> quantity in current deck */
deckCards: Map<string, number>
/** Function to check if a card can be added to the deck */
canAddCard: (cardId: string) => boolean
/** Drag-drop composable instance (optional - enables drag-drop support) */
dragDrop?: UseDragDrop
}>(),
{
dragDrop: undefined,
}
)
const emit = defineEmits<{
/** Emitted when a card is clicked to add to deck */
select: [cardId: string]
/** Emitted when a card is clicked to view details */
viewCard: [card: CardDefinition]
/** Emitted when a card is dropped here to remove from deck */
removeFromDeck: [cardId: string]
}>()
// Filter state
const searchQuery = ref('')
const selectedType = ref<CardType | null>(null)
const selectedCategory = ref<CardCategory | null>(null)
const selectedRarity = ref<CardDefinition['rarity'] | null>(null)
// Internal search for debouncing
const internalSearch = ref('')
/** Number of skeleton cards to show */
const SKELETON_COUNT = 12
/**
* Debounced search update.
*/
const debouncedSearchUpdate = useDebounceFn((value: string) => {
searchQuery.value = value
}, 300)
/**
* Handle search input.
*/
function onSearchInput(value: string) {
internalSearch.value = value
debouncedSearchUpdate(value)
}
/**
* Filtered cards based on current filters.
*/
const filteredCards = computed<CollectionCard[]>(() => {
let result = props.cards
// Filter by search query
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(c =>
c.card.name.toLowerCase().includes(query)
)
}
// Filter by type
if (selectedType.value) {
result = result.filter(c => c.card.type === selectedType.value)
}
// Filter by category
if (selectedCategory.value) {
result = result.filter(c => c.card.category === selectedCategory.value)
}
// Filter by rarity
if (selectedRarity.value) {
result = result.filter(c => c.card.rarity === selectedRarity.value)
}
return result
})
/**
* Get remaining quantity of a card (collection qty - deck qty).
*/
function getRemainingQuantity(cardId: string, collectionQty: number): number {
const deckQty = props.deckCards.get(cardId) ?? 0
return Math.max(0, collectionQty - deckQty)
}
/**
* Check if a card is exhausted (all copies in deck).
*/
function isCardExhausted(cardId: string, collectionQty: number): boolean {
const remaining = getRemainingQuantity(cardId, collectionQty)
return remaining <= 0 || !props.canAddCard(cardId)
}
/**
* Handle card click - add to deck.
*/
function handleCardClick(cardId: string) {
const collectionCard = props.cards.find(c => c.card.id === cardId)
if (collectionCard && !isCardExhausted(cardId, collectionCard.quantity)) {
emit('select', cardId)
}
}
// ============================================================================
// Drag and Drop
// ============================================================================
/**
* Get drag handlers for a card if drag-drop is enabled.
*/
function getDragHandlers(card: CardDefinition) {
if (!props.dragDrop) return {}
const collectionCard = props.cards.find(c => c.card.id === card.id)
if (!collectionCard || isCardExhausted(card.id, collectionCard.quantity)) {
return {}
}
return props.dragDrop.createDragHandlers(card, 'collection')
}
/**
* Get drop handlers for the collection area (to remove cards from deck).
*/
const collectionDropHandlers = computed(() => {
if (!props.dragDrop) return {}
return props.dragDrop.createDropHandlers(
'collection',
// Can only drop cards that came from the deck
(_card) => props.dragDrop!.dragSource.value === 'deck',
// On drop, emit remove event
(card) => emit('removeFromDeck', card.id)
)
})
/**
* Whether a drag is in progress (for visual feedback).
*/
const isDragging = computed(() => props.dragDrop?.isDragging.value ?? false)
/**
* Whether this collection area is the current drop target.
*/
const isDropTarget = computed(() => props.dragDrop?.dropTarget.value === 'collection')
/**
* Whether the current drop would be valid.
*/
const isValidDropTarget = computed(() => {
if (!props.dragDrop) return false
return isDropTarget.value && props.dragDrop.isValidDrop.value
})
/**
* Whether the drop would be invalid (dropping collection card on collection).
*/
const isInvalidDropTarget = computed(() => {
if (!props.dragDrop) return false
return isDropTarget.value && !props.dragDrop.isValidDrop.value
})
/**
* Check if a specific card is currently being dragged.
*/
function isCardBeingDragged(cardId: string): boolean {
if (!props.dragDrop) return false
return isDragging.value && props.dragDrop.draggedCard.value?.id === cardId
}
</script>
<template>
<div
class="bg-surface rounded-xl p-4 transition-all duration-150"
:class="[
// Drop target styling when dragging from deck
isValidDropTarget && 'ring-2 ring-primary ring-dashed bg-primary/5',
isInvalidDropTarget && 'ring-2 ring-error ring-dashed bg-error/5',
// Subtle pulse animation when valid drop target
isValidDropTarget && 'animate-pulse',
]"
data-drop-target="collection"
v-bind="collectionDropHandlers"
>
<!-- Filter bar -->
<FilterBar
:search="internalSearch"
:type="selectedType"
:category="selectedCategory"
:rarity="selectedRarity"
search-placeholder="Search collection..."
@update:search="onSearchInput"
@update:type="selectedType = $event"
@update:category="selectedCategory = $event"
@update:rarity="selectedRarity = $event"
/>
<!-- Loading state -->
<div
v-if="isLoading"
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-3 xl:grid-cols-4 gap-3"
>
<SkeletonCard
v-for="i in SKELETON_COUNT"
:key="i"
size="sm"
/>
</div>
<!-- Empty collection -->
<EmptyState
v-else-if="cards.length === 0"
title="Collection empty"
description="Win matches to earn booster packs and grow your collection."
>
<template #icon>
<svg
class="w-full h-full"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
</template>
</EmptyState>
<!-- No filter results -->
<EmptyState
v-else-if="filteredCards.length === 0"
title="No cards found"
description="Try adjusting your filters or search query."
>
<template #icon>
<svg
class="w-full h-full"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</template>
</EmptyState>
<!-- Card grid -->
<div
v-else
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-3 xl:grid-cols-4 gap-3"
>
<div
v-for="collectionCard in filteredCards"
:key="collectionCard.cardDefinitionId"
class="transition-all duration-150"
:class="[
// Card being dragged gets reduced opacity
isCardBeingDragged(collectionCard.cardDefinitionId) && 'opacity-50',
]"
v-bind="getDragHandlers(collectionCard.card)"
>
<CardDisplay
:card="collectionCard.card"
:quantity="getRemainingQuantity(collectionCard.cardDefinitionId, collectionCard.quantity)"
:disabled="isCardExhausted(collectionCard.cardDefinitionId, collectionCard.quantity)"
size="sm"
:class="[
// Cursor when draggable
dragDrop && !isCardExhausted(collectionCard.cardDefinitionId, collectionCard.quantity) && 'cursor-grab',
// Grabbing cursor when being dragged
isCardBeingDragged(collectionCard.cardDefinitionId) && 'cursor-grabbing',
]"
@click="handleCardClick"
/>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,91 @@
<script setup lang="ts">
/**
* Save/Cancel action buttons for deck builder.
*
* Displays the bottom action buttons with proper disabled states
* and loading indicator during save operations.
*
* @example
* ```vue
* <DeckActionButtons
* :can-save="canSave"
* :is-saving="isSaving"
* :is-dirty="isDirty"
* :deck-name="deckName"
* @save="handleSave"
* @cancel="handleCancel"
* />
* ```
*/
import { computed } from 'vue'
const props = defineProps<{
/** Whether the save operation is allowed (deck is valid, has name) */
canSave: boolean
/** Whether save is in progress */
isSaving: boolean
/** Whether there are unsaved changes */
isDirty: boolean
/** Current deck name (used for save button disabled state) */
deckName: string
}>()
const emit = defineEmits<{
save: []
cancel: []
}>()
/**
* Whether the save button should be disabled.
*/
const saveDisabled = computed(() => {
return props.isSaving || !props.isDirty || !props.deckName.trim()
})
</script>
<template>
<div class="flex gap-3 pt-4 border-t border-surface-light">
<button
type="button"
class="flex-1 px-4 py-2 rounded-lg font-medium bg-surface-light text-text hover:bg-surface-lighter active:scale-95 transition-all duration-150"
:disabled="isSaving"
@click="emit('cancel')"
>
Cancel
</button>
<button
type="button"
class="flex-1 px-4 py-2 rounded-lg font-medium bg-primary text-white hover:bg-primary-dark active:scale-95 transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="saveDisabled"
@click="emit('save')"
>
<span
v-if="isSaving"
class="inline-flex items-center gap-2"
>
<svg
class="animate-spin h-4 w-4"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
fill="none"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span>Saving...</span>
</span>
<span v-else>Save</span>
</button>
</div>
</template>

View File

@ -0,0 +1,520 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import DeckCard from './DeckCard.vue'
import type { Deck, DeckCard as DeckCardType, CardDefinition } from '@/types'
/**
* Helper to create a mock CardDefinition.
*/
function createMockCardDefinition(
overrides: Partial<CardDefinition> = {}
): CardDefinition {
return {
id: `card-${Math.random().toString(36).slice(2, 6)}`,
name: 'Test Card',
category: 'pokemon',
type: 'fire',
hp: 60,
attacks: [],
imageUrl: '/cards/test.png',
rarity: 'common',
setId: 'base',
setNumber: 1,
...overrides,
}
}
/**
* Helper to create a mock deck card.
*/
function createMockDeckCard(
overrides: Partial<CardDefinition> = {},
quantity = 1
): DeckCardType {
const card = createMockCardDefinition(overrides)
return {
cardDefinitionId: card.id,
quantity,
card,
}
}
/**
* Helper to create a mock deck.
*/
function createMockDeck(overrides: Partial<Deck> = {}): Deck {
return {
id: `deck-${Math.random().toString(36).slice(2, 6)}`,
name: 'Test Deck',
cards: [],
energyCards: {},
isValid: true,
cardCount: 60,
isStarter: false,
...overrides,
}
}
describe('DeckCard', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('rendering', () => {
it('renders deck name', () => {
/**
* Test that the deck name is prominently displayed.
*
* The deck name is the primary identifier for users to distinguish
* between their decks, so it must be clearly visible.
*/
const deck = createMockDeck({ name: 'Fire Power Deck' })
const wrapper = mount(DeckCard, {
props: { deck },
})
expect(wrapper.text()).toContain('Fire Power Deck')
})
it('renders card count', () => {
/**
* Test that the card count is displayed.
*
* Users need to see at a glance if their deck has the correct
* number of cards (typically 60 for a valid deck).
*/
const deck = createMockDeck({ cardCount: 45 })
const wrapper = mount(DeckCard, {
props: { deck },
})
expect(wrapper.text()).toContain('45 cards')
})
it('shows valid checkmark icon when deck is valid', () => {
/**
* Test valid deck status indicator.
*
* Valid decks should display a green checkmark icon so users
* can quickly identify which decks are ready to play.
*/
const deck = createMockDeck({ isValid: true })
const wrapper = mount(DeckCard, {
props: { deck },
})
// Find the success-colored check icon
const validIcon = wrapper.find('.text-success')
expect(validIcon.exists()).toBe(true)
})
it('shows invalid X icon when deck is invalid', () => {
/**
* Test invalid deck status indicator.
*
* Invalid decks should display a red X icon to warn users
* that the deck needs attention before it can be used.
*/
const deck = createMockDeck({ isValid: false })
const wrapper = mount(DeckCard, {
props: { deck },
})
// Find the error-colored X icon
const invalidIcon = wrapper.find('.text-error')
expect(invalidIcon.exists()).toBe(true)
})
it('shows starter badge for starter decks', () => {
/**
* Test starter deck badge display.
*
* Starter decks have special status and cannot be deleted,
* so users need a visual indicator to distinguish them.
*/
const deck = createMockDeck({ isStarter: true, starterType: 'fire' })
const wrapper = mount(DeckCard, {
props: { deck },
})
expect(wrapper.text()).toContain('Starter')
})
it('does not show starter badge for non-starter decks', () => {
/**
* Test that non-starter decks don't show the badge.
*
* Regular decks should not have the starter badge to avoid
* confusion about which decks are protected.
*/
const deck = createMockDeck({ isStarter: false })
const wrapper = mount(DeckCard, {
props: { deck },
})
expect(wrapper.text()).not.toContain('Starter')
})
it('renders preview images for Pokemon cards', () => {
/**
* Test that Pokemon card thumbnails are displayed.
*
* The preview images help users visually identify decks
* by showing the Pokemon they contain.
*/
const cards = [
createMockDeckCard({ id: 'pikachu', name: 'Pikachu', category: 'pokemon' }),
createMockDeckCard({ id: 'charmander', name: 'Charmander', category: 'pokemon' }),
]
const deck = createMockDeck({ cards })
const wrapper = mount(DeckCard, {
props: { deck },
})
const images = wrapper.findAll('img')
expect(images.length).toBe(2)
})
it('shows placeholder when deck has no Pokemon cards', () => {
/**
* Test placeholder display for empty decks.
*
* When a deck has no Pokemon cards to preview, a placeholder
* icon should be shown instead of an empty space.
*/
const deck = createMockDeck({ cards: [] })
const wrapper = mount(DeckCard, {
props: { deck },
})
// No images should be present
const images = wrapper.findAll('img')
expect(images.length).toBe(0)
// Should show placeholder SVG
const placeholder = wrapper.find('.text-text-muted svg')
expect(placeholder.exists()).toBe(true)
})
it('limits preview to 4 Pokemon cards', () => {
/**
* Test preview image limit.
*
* To keep the card compact, we only show up to 4 Pokemon
* card thumbnails even if the deck has more.
*/
const cards = Array.from({ length: 10 }, (_, i) =>
createMockDeckCard({
id: `pokemon-${i}`,
name: `Pokemon ${i}`,
category: 'pokemon',
})
)
const deck = createMockDeck({ cards })
const wrapper = mount(DeckCard, {
props: { deck },
})
const images = wrapper.findAll('img')
expect(images.length).toBe(4)
})
it('only shows Pokemon cards in preview (not trainers or energy)', () => {
/**
* Test that only Pokemon cards appear in the preview.
*
* Trainer and Energy cards aren't as visually distinctive,
* so we only show Pokemon for the thumbnail preview.
*/
const cards = [
createMockDeckCard({ id: 'pikachu', name: 'Pikachu', category: 'pokemon' }),
createMockDeckCard({ id: 'oak', name: 'Professor Oak', category: 'trainer' }),
createMockDeckCard({ id: 'energy', name: 'Fire Energy', category: 'energy' }),
]
const deck = createMockDeck({ cards })
const wrapper = mount(DeckCard, {
props: { deck },
})
// Only 1 Pokemon, so only 1 image
const images = wrapper.findAll('img')
expect(images.length).toBe(1)
})
})
describe('delete button', () => {
it('shows delete button for non-starter decks', () => {
/**
* Test delete button visibility for regular decks.
*
* Regular decks should have a delete button so users can
* remove decks they no longer want.
*/
const deck = createMockDeck({ isStarter: false })
const wrapper = mount(DeckCard, {
props: { deck },
})
const deleteButton = wrapper.find('button[aria-label="Delete deck"]')
expect(deleteButton.exists()).toBe(true)
})
it('hides delete button for starter decks', () => {
/**
* Test that starter decks cannot be deleted.
*
* Starter decks are protected and should not have a delete
* button since they cannot be removed.
*/
const deck = createMockDeck({ isStarter: true })
const wrapper = mount(DeckCard, {
props: { deck },
})
const deleteButton = wrapper.find('button[aria-label="Delete deck"]')
expect(deleteButton.exists()).toBe(false)
})
it('emits delete event when delete button is clicked', async () => {
/**
* Test delete button functionality.
*
* Clicking the delete button should emit an event with the deck ID
* so the parent can show a confirmation dialog.
*/
const deck = createMockDeck({ id: 'deck-123', isStarter: false })
const wrapper = mount(DeckCard, {
props: { deck },
})
const deleteButton = wrapper.find('button[aria-label="Delete deck"]')
await deleteButton.trigger('click')
expect(wrapper.emitted('delete')).toBeTruthy()
expect(wrapper.emitted('delete')![0]).toEqual(['deck-123'])
})
it('delete button click does not trigger card click', async () => {
/**
* Test event propagation on delete button.
*
* Clicking delete should not also navigate to the deck editor.
* The click event must be stopped from propagating.
*/
const deck = createMockDeck({ id: 'deck-123', isStarter: false })
const wrapper = mount(DeckCard, {
props: { deck },
})
const deleteButton = wrapper.find('button[aria-label="Delete deck"]')
await deleteButton.trigger('click')
// Delete should be emitted
expect(wrapper.emitted('delete')).toBeTruthy()
// Click should NOT be emitted
expect(wrapper.emitted('click')).toBeFalsy()
})
})
describe('interactions', () => {
it('emits click event with deck id when clicked', async () => {
/**
* Test card click navigation.
*
* Clicking the card should emit an event with the deck ID
* so the parent can navigate to the deck editor.
*/
const deck = createMockDeck({ id: 'deck-456' })
const wrapper = mount(DeckCard, {
props: { deck },
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
expect(wrapper.emitted('click')![0]).toEqual(['deck-456'])
})
it('emits click on Enter key', async () => {
/**
* Test keyboard navigation.
*
* The card should be activatable via Enter key for users
* who navigate with keyboards.
*/
const deck = createMockDeck({ id: 'deck-789' })
const wrapper = mount(DeckCard, {
props: { deck },
})
await wrapper.trigger('keydown', { key: 'Enter' })
expect(wrapper.emitted('click')).toBeTruthy()
expect(wrapper.emitted('click')![0]).toEqual(['deck-789'])
})
it('emits click on Space key', async () => {
/**
* Test Space key activation.
*
* Space is another standard key for button activation
* and should work like Enter.
*/
const deck = createMockDeck({ id: 'deck-abc' })
const wrapper = mount(DeckCard, {
props: { deck },
})
await wrapper.trigger('keydown', { key: ' ' })
expect(wrapper.emitted('click')).toBeTruthy()
expect(wrapper.emitted('click')![0]).toEqual(['deck-abc'])
})
it('does not emit click on other keys', async () => {
/**
* Test that only Enter and Space activate the card.
*
* Random keys should not trigger navigation.
*/
const deck = createMockDeck()
const wrapper = mount(DeckCard, {
props: { deck },
})
await wrapper.trigger('keydown', { key: 'a' })
expect(wrapper.emitted('click')).toBeFalsy()
})
})
describe('accessibility', () => {
it('has button role', () => {
/**
* Test ARIA role.
*
* The card acts as a button for navigation, so it should
* have the button role for screen readers.
*/
const deck = createMockDeck()
const wrapper = mount(DeckCard, {
props: { deck },
})
expect(wrapper.attributes('role')).toBe('button')
})
it('has tabindex 0 for keyboard focus', () => {
/**
* Test keyboard focusability.
*
* The card should be focusable via tab key for
* keyboard navigation.
*/
const deck = createMockDeck()
const wrapper = mount(DeckCard, {
props: { deck },
})
expect(wrapper.attributes('tabindex')).toBe('0')
})
it('has descriptive aria-label', () => {
/**
* Test screen reader label.
*
* The card should have an aria-label that describes its content
* for users who cannot see the visual layout.
*/
const deck = createMockDeck({
name: 'My Fire Deck',
cardCount: 60,
isStarter: false,
})
const wrapper = mount(DeckCard, {
props: { deck },
})
const ariaLabel = wrapper.attributes('aria-label')
expect(ariaLabel).toContain('My Fire Deck')
expect(ariaLabel).toContain('60 cards')
})
it('includes starter status in aria-label for starter decks', () => {
/**
* Test aria-label for starter decks.
*
* Starter decks should mention their special status in the
* aria-label so screen reader users know they can't be deleted.
*/
const deck = createMockDeck({
name: 'Fire Starter',
isStarter: true,
})
const wrapper = mount(DeckCard, {
props: { deck },
})
const ariaLabel = wrapper.attributes('aria-label')
expect(ariaLabel).toContain('Starter deck')
})
it('validation icon has proper title for tooltip', () => {
/**
* Test validation icon tooltip.
*
* The validation status icon should have a title attribute
* for users who hover over it.
*/
const validDeck = createMockDeck({ isValid: true })
const invalidDeck = createMockDeck({ isValid: false })
const validWrapper = mount(DeckCard, { props: { deck: validDeck } })
const invalidWrapper = mount(DeckCard, { props: { deck: invalidDeck } })
const validIconContainer = validWrapper.find('[title="Valid deck"]')
const invalidIconContainer = invalidWrapper.find('[title="Invalid deck"]')
expect(validIconContainer.exists()).toBe(true)
expect(invalidIconContainer.exists()).toBe(true)
})
})
describe('styling', () => {
it('has hover effect classes', () => {
/**
* Test hover styling.
*
* The card should have hover classes for the lift effect
* described in the design reference.
*/
const deck = createMockDeck()
const wrapper = mount(DeckCard, {
props: { deck },
})
expect(wrapper.classes()).toContain('hover:shadow-lg')
expect(wrapper.classes()).toContain('hover:scale-[1.02]')
expect(wrapper.classes()).toContain('hover:-translate-y-1')
})
it('has correct base styling', () => {
/**
* Test base card styling.
*
* The card should use the surface background color and
* rounded corners per the design system.
*/
const deck = createMockDeck()
const wrapper = mount(DeckCard, {
props: { deck },
})
expect(wrapper.classes()).toContain('bg-surface')
expect(wrapper.classes()).toContain('rounded-xl')
expect(wrapper.classes()).toContain('shadow-md')
})
})
})

View File

@ -0,0 +1,217 @@
<script setup lang="ts">
/**
* DeckCard component for displaying a single deck in the deck list.
*
* Displays deck information including:
* - Deck name
* - Card count
* - Validation status (valid/invalid icon)
* - Starter badge if applicable
* - Mini preview of Pokemon card thumbnails
* - Delete button (hidden for starter decks)
*
* Follows the DESIGN_REFERENCE patterns for card components with
* hover effects and consistent styling.
*
* @example
* ```vue
* <DeckCard
* :deck="deck"
* @click="navigateToDeck(deck.id)"
* @delete="confirmDelete(deck)"
* />
* ```
*/
import { computed } from 'vue'
import type { Deck } from '@/types'
const props = defineProps<{
/** The deck to display */
deck: Deck
}>()
const emit = defineEmits<{
/** Emitted when the card is clicked (navigate to edit) */
click: [deckId: string]
/** Emitted when the delete button is clicked */
delete: [deckId: string]
}>()
/**
* Get up to 4 Pokemon card images for the preview.
* Falls back to placeholder if no cards available.
*/
const previewImages = computed(() => {
const pokemonCards = props.deck.cards
.filter(c => c.card.category === 'pokemon')
.slice(0, 4)
return pokemonCards.map(c => ({
id: c.cardDefinitionId,
imageUrl: c.card.imageUrl,
name: c.card.name,
}))
})
/**
* Handle click on the card (navigate to deck).
*/
function handleClick() {
emit('click', props.deck.id)
}
/**
* Handle delete button click.
* Stops propagation to prevent triggering card click.
*/
function handleDelete(event: MouseEvent) {
event.stopPropagation()
emit('delete', props.deck.id)
}
/**
* Handle keyboard activation.
*/
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
emit('click', props.deck.id)
}
}
</script>
<template>
<div
class="relative bg-surface rounded-xl p-4 shadow-md hover:shadow-lg transition-all duration-200 ease-out hover:scale-[1.02] hover:-translate-y-1 cursor-pointer group"
role="button"
tabindex="0"
:aria-label="`${deck.name} - ${deck.cardCount} cards${deck.isStarter ? ' - Starter deck' : ''}`"
@click="handleClick"
@keydown="handleKeydown"
>
<!-- Delete button (top right, hidden for starter decks) -->
<button
v-if="!deck.isStarter"
type="button"
class="absolute top-2 right-2 p-2 rounded-lg text-text-muted hover:text-error hover:bg-error/10 transition-colors duration-150 opacity-0 group-hover:opacity-100 focus:opacity-100 z-10"
aria-label="Delete deck"
@click="handleDelete"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
<!-- Deck preview images -->
<div class="flex items-center justify-center h-20 mb-3">
<div
v-if="previewImages.length > 0"
class="flex items-center"
>
<div
v-for="(card, index) in previewImages"
:key="card.id"
class="w-8 h-11 rounded overflow-hidden shadow-md bg-surface-light flex-shrink-0"
:class="{ '-ml-3': index > 0 }"
:style="{ zIndex: previewImages.length - index }"
>
<img
:src="card.imageUrl"
:alt="card.name"
class="w-full h-full object-cover"
loading="lazy"
>
</div>
</div>
<div
v-else
class="flex items-center justify-center w-full h-full text-text-muted"
>
<svg
class="w-12 h-12"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
</div>
</div>
<!-- Deck info -->
<div class="space-y-2">
<!-- Name row with validation icon -->
<div class="flex items-center justify-between gap-2">
<h3 class="text-lg font-medium text-text truncate flex-1">
{{ deck.name }}
</h3>
<!-- Validation status icon -->
<div
:title="deck.isValid ? 'Valid deck' : 'Invalid deck'"
:aria-label="deck.isValid ? 'Valid deck' : 'Invalid deck'"
>
<!-- CheckCircle for valid -->
<svg
v-if="deck.isValid"
class="w-5 h-5 text-success flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
<!-- XCircle for invalid -->
<svg
v-else
class="w-5 h-5 text-error flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
<!-- Card count and badges row -->
<div class="flex items-center justify-between">
<span class="text-sm text-text-muted">
{{ deck.cardCount }} cards
</span>
<!-- Starter badge -->
<span
v-if="deck.isStarter"
class="px-2 py-0.5 text-xs font-medium rounded-full bg-primary/20 text-primary"
>
Starter
</span>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,248 @@
<script setup lang="ts">
/**
* Single card row in deck contents panel.
*
* Displays a card with:
* - Small thumbnail image
* - Card name
* - Quantity stepper (+/- buttons)
* - Draggable support for drag-and-drop removal
*
* Used in DeckContents to show each unique card and allow quantity adjustments.
* Supports both click-to-add/remove and drag-to-remove interactions.
*
* @example
* ```vue
* <DeckCardRow
* :card="cardDefinition"
* :quantity="4"
* :can-add="false"
* :drag-handlers="dragHandlers"
* :is-dragging="isCardBeingDragged"
* @add="handleAdd"
* @remove="handleRemove"
* />
* ```
*/
import { computed } from 'vue'
import type { CardDefinition, CardType } from '@/types'
import type { DragHandlers } from '@/composables/useDragDrop'
const props = withDefaults(
defineProps<{
/** The card definition to display */
card: CardDefinition
/** Current quantity of this card in the deck */
quantity: number
/** Whether more copies can be added (respects 4-copy rule and collection) */
canAdd?: boolean
/** Whether the card is disabled (e.g., being saved) */
disabled?: boolean
/** Drag handlers (optional - enables drag-drop support) */
dragHandlers?: Partial<DragHandlers>
/** Whether this card is currently being dragged */
isDragging?: boolean
}>(),
{
canAdd: true,
disabled: false,
dragHandlers: undefined,
isDragging: false,
}
)
const emit = defineEmits<{
/** Emitted when the add button is clicked */
add: [cardId: string]
/** Emitted when the remove button is clicked */
remove: [cardId: string]
/** Emitted when the card is clicked (for viewing details) */
click: [cardId: string]
}>()
/**
* Border color classes based on card type.
*/
const borderColors: Record<CardType | 'default', string> = {
grass: 'border-type-grass',
fire: 'border-type-fire',
water: 'border-type-water',
lightning: 'border-type-lightning',
psychic: 'border-type-psychic',
fighting: 'border-type-fighting',
darkness: 'border-type-darkness',
metal: 'border-type-metal',
fairy: 'border-type-fairy',
dragon: 'border-type-dragon',
colorless: 'border-type-colorless',
default: 'border-surface-light',
}
const borderColorClass = computed(() => {
const type = props.card.type
return type ? borderColors[type] : borderColors.default
})
/**
* Handle add button click.
*/
function handleAdd(event: MouseEvent) {
event.stopPropagation()
if (!props.disabled && props.canAdd) {
emit('add', props.card.id)
}
}
/**
* Handle remove button click.
*/
function handleRemove(event: MouseEvent) {
event.stopPropagation()
if (!props.disabled && props.quantity > 0) {
emit('remove', props.card.id)
}
}
/**
* Handle row click for card details.
*/
function handleClick() {
if (!props.disabled) {
emit('click', props.card.id)
}
}
/**
* Handle keyboard activation.
*/
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleClick()
}
}
</script>
<template>
<div
class="flex items-center gap-3 p-2 rounded-lg bg-surface-light/50 hover:bg-surface-light transition-all duration-150"
:class="[
disabled && 'opacity-50',
isDragging && 'opacity-50 cursor-grabbing',
!disabled && dragHandlers && !isDragging && 'cursor-grab',
!disabled && !dragHandlers && 'cursor-pointer',
]"
role="button"
tabindex="0"
:aria-label="`${card.name}, ${quantity} copies`"
v-bind="dragHandlers"
@click="handleClick"
@keydown="handleKeydown"
>
<!-- Card thumbnail -->
<div
class="w-10 h-14 flex-shrink-0 rounded border-2 overflow-hidden bg-surface"
:class="borderColorClass"
>
<img
v-if="card.imageUrl"
:src="card.imageUrl"
:alt="card.name"
class="w-full h-full object-cover"
loading="lazy"
>
<div
v-else
class="w-full h-full flex items-center justify-center text-text-muted"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
</div>
<!-- Card name -->
<div class="flex-1 min-w-0">
<span class="text-sm font-medium text-text truncate block">
{{ card.name }}
</span>
<span
v-if="card.category === 'pokemon' && card.hp"
class="text-xs text-text-muted"
>
{{ card.hp }} HP
</span>
</div>
<!-- Quantity stepper -->
<div
class="flex items-center rounded-lg border border-surface-light overflow-hidden"
@click.stop
>
<!-- Minus button -->
<button
type="button"
class="w-8 h-8 flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-light transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="disabled || quantity <= 0"
aria-label="Remove one copy"
@click="handleRemove"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 12H4"
/>
</svg>
</button>
<!-- Quantity display -->
<span class="w-8 text-center text-sm font-bold text-text">
{{ quantity }}
</span>
<!-- Plus button -->
<button
type="button"
class="w-8 h-8 flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-light transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="disabled || !canAdd"
aria-label="Add one copy"
@click="handleAdd"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
</div>
</div>
</template>

View File

@ -0,0 +1,124 @@
<script setup lang="ts">
/**
* Scrollable list of deck cards for deck builder.
*
* Displays all cards in the deck with DeckCardRow components.
* Supports drag-and-drop for card removal. Shows empty state
* when no cards are present.
*
* @example
* ```vue
* <DeckCardsList
* :cards="cardList"
* :card-definitions="cardDefinitions"
* :can-add-card="canAddCard"
* :disabled="isSaving"
* :drag-drop="dragDrop"
* @add="handleAdd"
* @remove="handleRemove"
* @view-card="handleViewCard"
* />
* ```
*/
import type { CardDefinition } from '@/types'
import type { DeckBuilderCard } from '@/composables/useDeckBuilder'
import type { UseDragDrop } from '@/composables/useDragDrop'
import DeckCardRow from './DeckCardRow.vue'
const props = withDefaults(
defineProps<{
/** Cards in the deck */
cards: DeckBuilderCard[]
/** Card definitions map for display */
cardDefinitions: Map<string, CardDefinition>
/** Function to check if a card can be added */
canAddCard: (cardId: string) => boolean
/** Whether interactions are disabled (e.g., saving) */
disabled?: boolean
/** Drag-drop composable instance (optional - enables drag-drop support) */
dragDrop?: UseDragDrop
}>(),
{
disabled: false,
dragDrop: undefined,
}
)
const emit = defineEmits<{
add: [cardId: string]
remove: [cardId: string]
viewCard: [cardId: string]
}>()
/**
* Get card definition for a deck card.
*/
function getCardDefinition(cardId: string): CardDefinition | undefined {
return props.cardDefinitions.get(cardId)
}
/**
* Get drag handlers for a deck card if drag-drop is enabled.
*/
function getDragHandlers(card: CardDefinition) {
if (!props.dragDrop) return {}
return props.dragDrop.createDragHandlers(card, 'deck')
}
/**
* Check if a specific card is currently being dragged.
*/
function isCardBeingDragged(cardId: string): boolean {
if (!props.dragDrop) return false
return (
props.dragDrop.isDragging.value &&
props.dragDrop.draggedCard.value?.id === cardId
)
}
</script>
<template>
<div class="flex-1 overflow-y-auto space-y-2 min-h-0 mb-4">
<template v-if="cards.length > 0">
<DeckCardRow
v-for="deckCard in cards"
:key="deckCard.cardDefinitionId"
:card="getCardDefinition(deckCard.cardDefinitionId)!"
:quantity="deckCard.quantity"
:can-add="canAddCard(deckCard.cardDefinitionId)"
:disabled="disabled"
:drag-handlers="getCardDefinition(deckCard.cardDefinitionId) ? getDragHandlers(getCardDefinition(deckCard.cardDefinitionId)!) : undefined"
:is-dragging="isCardBeingDragged(deckCard.cardDefinitionId)"
@add="emit('add', $event)"
@remove="emit('remove', $event)"
@click="emit('viewCard', $event)"
/>
</template>
<div
v-else
class="text-center py-8 text-text-muted"
>
<svg
class="w-12 h-12 mx-auto mb-3 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
<p class="text-sm">
No cards yet
</p>
<p class="text-xs mt-1">
Select cards from your collection
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,291 @@
<script setup lang="ts">
/**
* Right panel of deck builder showing deck contents.
*
* Displays:
* - Deck name input (DeckHeader)
* - Progress bar showing card count
* - Validation errors (if any)
* - Scrollable list of cards with DeckCardRow components (DeckCardsList)
* - Energy configuration section (DeckEnergySection)
* - Save/Cancel buttons (DeckActionButtons)
* - Drop target for adding cards from collection
*
* Supports both click-to-add/remove and drag-and-drop interactions.
* Click is the primary interaction; drag-drop is an enhancement.
*
* @example
* ```vue
* <DeckContents
* :deck-name="deckName"
* :cards="cardList"
* :total-cards="totalCards"
* :target-cards="60"
* :validation-errors="errors"
* :is-valid="isValid"
* :is-dirty="isDirty"
* :is-saving="isSaving"
* :energy-config="energyConfig"
* :drag-drop="dragDrop"
* @update:deck-name="setDeckName"
* @add-card="addCard"
* @remove-card="removeCard"
* @update:energy-config="setEnergyConfig"
* @save="save"
* @cancel="cancel"
* />
* ```
*/
import { ref, computed } from 'vue'
import type { CardDefinition } from '@/types'
import type { DeckBuilderCard } from '@/composables/useDeckBuilder'
import type { UseDragDrop } from '@/composables/useDragDrop'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import DeckHeader from './DeckHeader.vue'
import DeckCardsList from './DeckCardsList.vue'
import DeckEnergySection from './DeckEnergySection.vue'
import DeckActionButtons from './DeckActionButtons.vue'
const props = withDefaults(
defineProps<{
/** Current deck name */
deckName: string
/** Cards in the deck */
cards: DeckBuilderCard[]
/** Card definitions map for display */
cardDefinitions: Map<string, CardDefinition>
/** Total number of cards */
totalCards: number
/** Target card count for a valid deck */
targetCards?: number
/** Validation error messages */
validationErrors: string[]
/** Whether the deck is currently valid */
isValid: boolean
/** Whether there are unsaved changes */
isDirty: boolean
/** Whether save is in progress */
isSaving: boolean
/** Whether validation is in progress */
isValidating: boolean
/** Whether validation failed due to network error */
validationNetworkError?: boolean
/** Current energy configuration */
energyConfig: Record<string, number>
/** Function to check if a card can be added */
canAddCard: (cardId: string) => boolean
/** Drag-drop composable instance (optional - enables drag-drop support) */
dragDrop?: UseDragDrop
}>(),
{
targetCards: 60,
validationNetworkError: false,
dragDrop: undefined,
}
)
const emit = defineEmits<{
'update:deckName': [name: string]
'addCard': [cardId: string]
'removeCard': [cardId: string]
'update:energyConfig': [config: Record<string, number>]
'save': []
'cancel': []
'viewCard': [cardId: string]
}>()
/** Whether the energy section is collapsed */
const energyCollapsed = ref(false)
// ============================================================================
// Drag and Drop
// ============================================================================
/**
* Get drop handlers for the deck area (to add cards from collection).
*/
const deckDropHandlers = computed(() => {
if (!props.dragDrop) return {}
return props.dragDrop.createDropHandlers(
'deck',
// Can only drop cards from collection that pass canAddCard check
(card) => {
if (props.dragDrop!.dragSource.value !== 'collection') return false
return props.canAddCard(card.id)
},
// On drop, emit add card event
(card) => emit('addCard', card.id)
)
})
/**
* Whether this deck area is the current drop target.
*/
const isDropTarget = computed(() => props.dragDrop?.dropTarget.value === 'deck')
/**
* Whether the current drop would be valid.
*/
const isValidDropTarget = computed(() => {
if (!props.dragDrop) return false
return isDropTarget.value && props.dragDrop.isValidDrop.value
})
/**
* Whether the drop would be invalid (card limit reached or wrong source).
*/
const isInvalidDropTarget = computed(() => {
if (!props.dragDrop) return false
return isDropTarget.value && !props.dragDrop.isValidDrop.value
})
</script>
<template>
<div
class="bg-surface rounded-xl p-4 flex flex-col h-full transition-all duration-150"
:class="[
// Drop target styling when dragging from collection
isValidDropTarget && 'ring-2 ring-primary ring-dashed bg-primary/5',
isInvalidDropTarget && 'ring-2 ring-error ring-dashed bg-error/5',
// Subtle pulse animation when valid drop target
isValidDropTarget && 'animate-pulse',
]"
data-drop-target="deck"
v-bind="deckDropHandlers"
>
<!-- Deck name input -->
<DeckHeader
:deck-name="deckName"
:is-validating="isValidating"
@update:deck-name="emit('update:deckName', $event)"
/>
<!-- Progress bar -->
<div class="mb-4">
<ProgressBar
:current="totalCards"
:target="targetCards"
label="cards"
/>
</div>
<!-- Validation errors -->
<div
v-if="validationErrors.length > 0"
class="mb-4 p-3 bg-error/10 border border-error/20 rounded-lg"
>
<div class="flex items-start gap-2">
<svg
class="w-4 h-4 text-error flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<ul class="text-sm text-error space-y-1">
<li
v-for="(error, index) in validationErrors"
:key="index"
>
{{ error }}
</li>
</ul>
</div>
</div>
<!-- Validation network error warning -->
<div
v-if="validationNetworkError && validationErrors.length === 0"
class="mb-4 p-3 bg-warning/10 border border-warning/20 rounded-lg"
>
<div class="flex items-start gap-2">
<svg
class="w-4 h-4 text-warning flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<p class="text-sm text-warning">
Deck validation unavailable. You can still save, but the deck may have issues.
</p>
</div>
</div>
<!-- Validating indicator -->
<div
v-if="isValidating"
class="mb-4 flex items-center gap-2 text-text-muted text-sm"
>
<svg
class="animate-spin h-4 w-4"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
fill="none"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span>Validating...</span>
</div>
<!-- Cards list -->
<DeckCardsList
:cards="cards"
:card-definitions="cardDefinitions"
:can-add-card="canAddCard"
:disabled="isSaving"
:drag-drop="dragDrop"
@add="emit('addCard', $event)"
@remove="emit('removeCard', $event)"
@view-card="emit('viewCard', $event)"
/>
<!-- Energy section -->
<DeckEnergySection
:energy-config="energyConfig"
:is-collapsed="energyCollapsed"
:disabled="isSaving"
@update:energy-config="emit('update:energyConfig', $event)"
@update:is-collapsed="energyCollapsed = $event"
/>
<!-- Action buttons -->
<DeckActionButtons
:can-save="isValid"
:is-saving="isSaving"
:is-dirty="isDirty"
:deck-name="deckName"
@save="emit('save')"
@cancel="emit('cancel')"
/>
</div>
</template>

View File

@ -0,0 +1,256 @@
<script setup lang="ts">
/**
* Two-panel deck editor container component.
*
* Orchestrates the deck building experience with:
* - CollectionPicker on the left (or top on mobile)
* - DeckContents on the right (or bottom on mobile)
* - Mobile tab switcher between the two panels
* - Drag-and-drop support for moving cards between panels
*
* This component is primarily a layout container. Business logic
* lives in the composables used by DeckBuilderPage.
*
* Drag-and-drop is an optional enhancement - click-to-add/remove
* remains the primary interaction method.
*
* @example
* ```vue
* <DeckEditor
* :collection-cards="cards"
* :collection-loading="isLoading"
* :deck-name="deckName"
* :deck-cards="cardList"
* :card-definitions="cardDefinitions"
* :total-cards="totalCards"
* :validation-errors="validationErrors"
* :is-valid="isValid"
* :is-dirty="isDirty"
* :is-saving="isSaving"
* :is-validating="isValidating"
* :energy-config="energyConfig"
* :can-add-card="canAddCard"
* @update:deck-name="setDeckName"
* @add-card="addCard"
* @remove-card="removeCard"
* @update:energy-config="setEnergyConfig"
* @save="save"
* @cancel="handleCancel"
* />
* ```
*/
import { ref } from 'vue'
import type { CollectionCard, CardDefinition } from '@/types'
import type { DeckBuilderCard } from '@/composables/useDeckBuilder'
import { useDragDrop } from '@/composables/useDragDrop'
import CollectionPicker from './CollectionPicker.vue'
import DeckContents from './DeckContents.vue'
const props = defineProps<{
/** Cards from user's collection */
collectionCards: CollectionCard[]
/** Whether collection is loading */
collectionLoading: boolean
/** Current deck name */
deckName: string
/** Cards in the deck */
deckCards: DeckBuilderCard[]
/** Map of card definitions for display */
cardDefinitions: Map<string, CardDefinition>
/** Map of cardDefinitionId -> quantity in deck (for picker) */
deckCardsMap: Map<string, number>
/** Total cards in deck */
totalCards: number
/** Target card count */
targetCards?: number
/** Validation errors */
validationErrors: string[]
/** Whether deck is valid */
isValid: boolean
/** Whether there are unsaved changes */
isDirty: boolean
/** Whether save is in progress */
isSaving: boolean
/** Whether validation is in progress */
isValidating: boolean
/** Whether validation failed due to network error */
validationNetworkError?: boolean
/** Energy configuration */
energyConfig: Record<string, number>
/** Function to check if card can be added */
canAddCard: (cardId: string) => boolean
}>()
const emit = defineEmits<{
'update:deckName': [name: string]
'addCard': [cardId: string]
'removeCard': [cardId: string]
'update:energyConfig': [config: Record<string, number>]
'save': []
'cancel': []
'viewCard': [card: CardDefinition]
}>()
/** Active mobile tab */
const activeTab = ref<'collection' | 'deck'>('collection')
/** Drag-drop state and handlers */
const dragDrop = useDragDrop()
/**
* Switch between collection and deck tabs on mobile.
*/
function setActiveTab(tab: 'collection' | 'deck') {
activeTab.value = tab
}
/**
* Handle card selection from collection picker.
*/
function handleCardSelect(cardId: string) {
emit('addCard', cardId)
// On mobile, optionally switch to deck tab to show the addition
// (keeping it on collection for now to allow multi-add)
}
/**
* Handle viewing a card's details.
*/
function handleViewCard(cardId: string) {
const card = props.cardDefinitions.get(cardId)
if (card) {
emit('viewCard', card)
}
}
</script>
<template>
<div class="h-full flex flex-col">
<!-- Mobile tab switcher -->
<div class="lg:hidden sticky top-0 z-10 bg-background p-2 mb-4">
<div class="bg-surface-light rounded-lg p-1 flex">
<button
type="button"
class="flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors"
:class="[
activeTab === 'collection'
? 'bg-surface text-text shadow-sm'
: 'text-text-muted hover:text-text'
]"
@click="setActiveTab('collection')"
>
Collection
</button>
<button
type="button"
class="flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors"
:class="[
activeTab === 'deck'
? 'bg-surface text-text shadow-sm'
: 'text-text-muted hover:text-text'
]"
@click="setActiveTab('deck')"
>
Deck ({{ totalCards }})
</button>
</div>
</div>
<!-- Desktop two-panel layout -->
<div class="hidden lg:flex lg:gap-6 flex-1 min-h-0">
<!-- Collection picker (left, larger) -->
<div class="lg:w-2/3 overflow-y-auto">
<CollectionPicker
:cards="collectionCards"
:is-loading="collectionLoading"
:deck-cards="deckCardsMap"
:can-add-card="canAddCard"
:drag-drop="dragDrop"
@select="handleCardSelect"
@view-card="emit('viewCard', $event)"
@remove-from-deck="emit('removeCard', $event)"
/>
</div>
<!-- Deck contents (right, smaller, sticky) -->
<div class="lg:w-1/3 lg:sticky lg:top-0 lg:self-start lg:max-h-[calc(100vh-8rem)] overflow-hidden">
<DeckContents
:deck-name="deckName"
:cards="deckCards"
:card-definitions="cardDefinitions"
:total-cards="totalCards"
:target-cards="targetCards"
:validation-errors="validationErrors"
:is-valid="isValid"
:is-dirty="isDirty"
:is-saving="isSaving"
:is-validating="isValidating"
:validation-network-error="validationNetworkError"
:energy-config="energyConfig"
:can-add-card="canAddCard"
:drag-drop="dragDrop"
@update:deck-name="emit('update:deckName', $event)"
@add-card="emit('addCard', $event)"
@remove-card="emit('removeCard', $event)"
@update:energy-config="emit('update:energyConfig', $event)"
@save="emit('save')"
@cancel="emit('cancel')"
@view-card="handleViewCard"
/>
</div>
</div>
<!-- Mobile stacked layout -->
<!-- Note: Drag-drop is primarily for desktop; mobile uses tabs -->
<div class="lg:hidden flex-1 min-h-0 overflow-hidden">
<!-- Collection panel -->
<div
v-show="activeTab === 'collection'"
class="h-full overflow-y-auto"
>
<CollectionPicker
:cards="collectionCards"
:is-loading="collectionLoading"
:deck-cards="deckCardsMap"
:can-add-card="canAddCard"
:drag-drop="dragDrop"
@select="handleCardSelect"
@view-card="emit('viewCard', $event)"
@remove-from-deck="emit('removeCard', $event)"
/>
</div>
<!-- Deck panel -->
<div
v-show="activeTab === 'deck'"
class="h-full overflow-y-auto"
>
<DeckContents
:deck-name="deckName"
:cards="deckCards"
:card-definitions="cardDefinitions"
:total-cards="totalCards"
:target-cards="targetCards"
:validation-errors="validationErrors"
:is-valid="isValid"
:is-dirty="isDirty"
:is-saving="isSaving"
:is-validating="isValidating"
:validation-network-error="validationNetworkError"
:energy-config="energyConfig"
:can-add-card="canAddCard"
:drag-drop="dragDrop"
@update:deck-name="emit('update:deckName', $event)"
@add-card="emit('addCard', $event)"
@remove-card="emit('removeCard', $event)"
@update:energy-config="emit('update:energyConfig', $event)"
@save="emit('save')"
@cancel="emit('cancel')"
@view-card="handleViewCard"
/>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,209 @@
<script setup lang="ts">
/**
* Collapsible energy configuration section for deck builder.
*
* Displays a grid of energy type buttons allowing users to add/remove
* basic energy cards. The section can be collapsed to save space.
*
* @example
* ```vue
* <DeckEnergySection
* :energy-config="energyConfig"
* :is-collapsed="!energyExpanded"
* :disabled="isSaving"
* @update:energy-config="setEnergyConfig"
* @update:is-collapsed="(v) => energyExpanded = !v"
* />
* ```
*/
import { computed } from 'vue'
import type { EnergyType } from '@/types'
import { ENERGY_TYPES } from '@/types'
const props = withDefaults(
defineProps<{
/** Current energy configuration (type -> quantity) */
energyConfig: Record<string, number>
/** Whether the section is collapsed */
isCollapsed?: boolean
/** Whether interactions are disabled */
disabled?: boolean
}>(),
{
isCollapsed: false,
disabled: false,
}
)
const emit = defineEmits<{
'update:energyConfig': [config: Record<string, number>]
'update:isCollapsed': [collapsed: boolean]
}>()
/**
* Display labels for energy types.
*/
const energyLabels: Record<EnergyType, string> = {
grass: 'Grass',
fire: 'Fire',
water: 'Water',
lightning: 'Lightning',
psychic: 'Psychic',
fighting: 'Fighting',
darkness: 'Darkness',
metal: 'Metal',
colorless: 'Colorless',
dragon: 'Dragon',
}
/**
* Energy type background colors for buttons.
*/
const energyBgColors: Record<EnergyType, string> = {
grass: 'bg-type-grass',
fire: 'bg-type-fire',
water: 'bg-type-water',
lightning: 'bg-type-lightning',
psychic: 'bg-type-psychic',
fighting: 'bg-type-fighting',
darkness: 'bg-type-darkness',
metal: 'bg-type-metal',
colorless: 'bg-type-colorless',
dragon: 'bg-type-dragon',
}
/**
* Total energy cards count.
*/
const totalEnergy = computed(() => {
return Object.values(props.energyConfig).reduce((sum, n) => sum + n, 0)
})
/**
* Whether the section is expanded (inverse of isCollapsed).
*/
const isExpanded = computed(() => !props.isCollapsed)
/**
* Get count of a specific energy type.
*/
function getEnergyCount(type: EnergyType): number {
return props.energyConfig[type] || 0
}
/**
* Add energy of a specific type.
*/
function addEnergy(type: EnergyType) {
const newConfig = { ...props.energyConfig }
newConfig[type] = (newConfig[type] || 0) + 1
emit('update:energyConfig', newConfig)
}
/**
* Remove energy of a specific type.
*/
function removeEnergy(type: EnergyType) {
if (!props.energyConfig[type] || props.energyConfig[type] <= 0) return
const newConfig = { ...props.energyConfig }
newConfig[type] = newConfig[type] - 1
if (newConfig[type] <= 0) {
delete newConfig[type]
}
emit('update:energyConfig', newConfig)
}
/**
* Toggle section expanded state.
*/
function toggleSection() {
emit('update:isCollapsed', !props.isCollapsed)
}
</script>
<template>
<div class="border-t border-surface-light pt-4 mb-4">
<button
type="button"
class="flex items-center justify-between w-full text-left text-sm font-medium text-text hover:text-text/80 transition-colors"
@click="toggleSection"
>
<span>Basic Energy ({{ totalEnergy }})</span>
<svg
class="w-5 h-5 transition-transform"
:class="{ 'rotate-180': isExpanded }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div
v-if="isExpanded"
class="mt-3 grid grid-cols-2 gap-2"
>
<div
v-for="energyType in ENERGY_TYPES"
:key="energyType"
class="flex items-center gap-2"
>
<button
type="button"
class="w-6 h-6 rounded-full flex items-center justify-center text-white text-xs font-bold shadow-sm hover:scale-110 transition-transform"
:class="energyBgColors[energyType]"
:title="energyLabels[energyType]"
:disabled="disabled"
@click="addEnergy(energyType)"
>
+
</button>
<span class="text-xs text-text-muted flex-1 truncate">
{{ energyLabels[energyType] }}
</span>
<span class="text-sm font-medium text-text w-4 text-center">
{{ getEnergyCount(energyType) }}
</span>
<button
type="button"
class="w-5 h-5 rounded flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-light transition-colors disabled:opacity-30"
:disabled="disabled || getEnergyCount(energyType) <= 0"
@click="removeEnergy(energyType)"
>
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 12H4"
/>
</svg>
</button>
</div>
</div>
</Transition>
</div>
</template>

View File

@ -0,0 +1,48 @@
<script setup lang="ts">
/**
* Deck name input section for deck builder.
*
* Displays an editable deck name input with styling that matches
* the deck builder design. Shows validating indicator when needed.
*
* @example
* ```vue
* <DeckHeader
* :deck-name="deckName"
* :is-validating="isValidating"
* @update:deck-name="setDeckName"
* />
* ```
*/
defineProps<{
/** Current deck name */
deckName: string
/** Whether validation is in progress */
isValidating: boolean
}>()
const emit = defineEmits<{
'update:deckName': [name: string]
}>()
/**
* Handle deck name input.
*/
function onNameInput(event: Event) {
const target = event.target as HTMLInputElement
emit('update:deckName', target.value)
}
</script>
<template>
<div class="mb-4">
<input
type="text"
:value="deckName"
placeholder="Deck Name"
class="w-full text-xl font-bold bg-transparent border-transparent border-b-2 focus:border-primary focus:outline-none text-text placeholder:text-text-muted/50 px-0 py-1 transition-colors"
@input="onNameInput"
>
</div>
</template>

View File

@ -0,0 +1,269 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import PhaserGame from './PhaserGame.vue'
/**
* Mock Phaser since jsdom doesn't support WebGL/canvas rendering.
*
* We create a minimal mock that simulates the Phaser.Game interface
* needed by the component: events system and destroy method.
*/
// Mock event emitter for Phaser events
class MockEventEmitter {
private listeners: Map<string, Set<(...args: unknown[]) => void>> = new Map()
on(event: string, callback: (...args: unknown[]) => void): this {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set())
}
this.listeners.get(event)!.add(callback)
return this
}
once(event: string, callback: (...args: unknown[]) => void): this {
const wrapper = (...args: unknown[]) => {
this.off(event, wrapper)
callback(...args)
}
return this.on(event, wrapper)
}
off(event: string, callback: (...args: unknown[]) => void): this {
this.listeners.get(event)?.delete(callback)
return this
}
emit(event: string, ...args: unknown[]): boolean {
const callbacks = this.listeners.get(event)
if (callbacks) {
callbacks.forEach(cb => cb(...args))
return true
}
return false
}
}
// Mock Phaser.Game class
const mockDestroy = vi.fn()
let mockGameEvents: MockEventEmitter
function createMockGame() {
mockGameEvents = new MockEventEmitter()
return {
events: mockGameEvents,
destroy: mockDestroy,
scale: {
resize: vi.fn(),
},
}
}
// Mock the createGame function from @/game
vi.mock('@/game', () => ({
createGame: vi.fn(() => createMockGame()),
}))
describe('PhaserGame', () => {
let wrapper: VueWrapper | null = null
beforeEach(() => {
// Reset mocks before each test
vi.clearAllMocks()
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))
})
afterEach(() => {
// Cleanup wrapper after each test
if (wrapper) {
wrapper.unmount()
wrapper = null
}
})
describe('mounting', () => {
it('creates a game instance on mount', async () => {
/**
* Test that mounting the component creates a Phaser game instance.
*
* This is the core functionality - when the component mounts,
* it should create and store a Phaser.Game instance that can
* be used for rendering the game canvas.
*/
const { createGame } = await import('@/game')
wrapper = mount(PhaserGame)
expect(createGame).toHaveBeenCalledTimes(1)
expect(createGame).toHaveBeenCalledWith(expect.any(HTMLElement))
})
it('provides container element as ref', () => {
/**
* Test that the container div is properly referenced.
*
* The container element must be accessible for Phaser to mount
* its canvas inside. This verifies the template ref is working.
*/
wrapper = mount(PhaserGame)
const container = wrapper.find('[data-testid="phaser-container"]')
expect(container.exists()).toBe(true)
expect(container.element).toBeInstanceOf(HTMLElement)
})
it('applies correct Tailwind classes to container', () => {
/**
* Test that styling classes are applied per design requirements.
*
* The container needs specific styling to ensure the Phaser canvas
* fills the available space and handles overflow correctly.
*/
wrapper = mount(PhaserGame)
const container = wrapper.find('[data-testid="phaser-container"]')
expect(container.classes()).toContain('w-full')
expect(container.classes()).toContain('h-full')
expect(container.classes()).toContain('relative')
expect(container.classes()).toContain('overflow-hidden')
expect(container.classes()).toContain('bg-background')
})
it('sets up ResizeObserver on mount', () => {
/**
* Test that ResizeObserver is created and observes the container.
*
* The ResizeObserver allows the component to detect when its
* container size changes, enabling responsive canvas behavior.
*/
const observeMock = vi.fn()
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: observeMock,
unobserve: vi.fn(),
disconnect: vi.fn(),
}))
wrapper = mount(PhaserGame)
expect(global.ResizeObserver).toHaveBeenCalled()
expect(observeMock).toHaveBeenCalledWith(expect.any(HTMLElement))
})
})
describe('events', () => {
it('emits ready event when Phaser signals ready', async () => {
/**
* Test that the component emits a 'ready' event.
*
* Parent components need to know when Phaser has finished
* initializing so they can start interacting with the game.
* The ready event provides the game instance for communication.
*/
wrapper = mount(PhaserGame)
// Simulate Phaser emitting ready event
mockGameEvents.emit('ready')
// Wait for Vue to process
await wrapper.vm.$nextTick()
const readyEvents = wrapper.emitted('ready')
expect(readyEvents).toBeDefined()
expect(readyEvents!.length).toBeGreaterThanOrEqual(1)
})
it('emits error event when game creation fails', async () => {
/**
* Test that errors during game creation are properly emitted.
*
* If Phaser fails to initialize (e.g., WebGL not supported),
* the component should emit an error event so parent components
* can display appropriate fallback UI.
*/
const { createGame } = await import('@/game')
const mockError = new Error('WebGL not supported')
vi.mocked(createGame).mockImplementationOnce(() => {
throw mockError
})
wrapper = mount(PhaserGame)
const errorEvents = wrapper.emitted('error')
expect(errorEvents).toBeDefined()
expect(errorEvents![0][0]).toEqual(mockError)
})
})
describe('unmounting', () => {
it('destroys game instance on unmount', async () => {
/**
* Test that the Phaser game is properly destroyed on unmount.
*
* Memory leaks occur if Phaser game instances are not destroyed.
* The component must call game.destroy(true) to fully clean up
* all Phaser resources including textures and cached data.
*/
wrapper = mount(PhaserGame)
// Unmount the component
wrapper.unmount()
wrapper = null
// Verify destroy was called with true (to remove canvas from DOM)
expect(mockDestroy).toHaveBeenCalledTimes(1)
expect(mockDestroy).toHaveBeenCalledWith(true)
})
it('disconnects ResizeObserver on unmount', () => {
/**
* Test that ResizeObserver is disconnected on unmount.
*
* Leaving observers connected after component unmount causes
* memory leaks and potential errors when callbacks fire on
* non-existent elements.
*/
const disconnectMock = vi.fn()
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: disconnectMock,
}))
wrapper = mount(PhaserGame)
wrapper.unmount()
wrapper = null
expect(disconnectMock).toHaveBeenCalledTimes(1)
})
})
describe('expose', () => {
it('exposes game ref via defineExpose', () => {
/**
* Test that the game ref is exposed to parent components.
*
* Parent components need access to the game instance to
* send events to Phaser (e.g., play card, start animation)
* and register listeners for Phaser events.
*
* The ref should be accessible whether game creation succeeds
* or fails, allowing parent components to check if game is ready.
*/
wrapper = mount(PhaserGame)
// Access exposed properties via vm
const exposed = wrapper.vm as unknown as { game: { value: unknown } }
// The game ref should be exposed
expect(exposed.game).toBeDefined()
// The value should be the mock game (since createGame mock returns it)
// and other tests verify createGame is called with the container
})
})
})

View File

@ -0,0 +1,124 @@
<script setup lang="ts">
/**
* PhaserGame Vue component.
*
* Mounts and manages the lifecycle of a Phaser game instance.
* This component provides the bridge between Vue and Phaser,
* handling creation on mount and cleanup on unmount.
*/
import { ref, onMounted, onUnmounted, shallowRef } from 'vue'
import { createGame, scenes } from '@/game'
import type Phaser from 'phaser'
/**
* Phaser Core.Events.READY event name constant.
* Using string directly to avoid runtime dependency on Phaser import for testing.
*/
const PHASER_READY_EVENT = 'ready'
// Emits
const emit = defineEmits<{
ready: [game: Phaser.Game]
error: [error: Error]
}>()
// Refs
const container = ref<HTMLElement | null>(null)
// Use shallowRef for Phaser.Game to avoid Vue's deep reactivity on complex objects
const game = shallowRef<Phaser.Game | null>(null)
// ResizeObserver for detecting container size changes
let resizeObserver: ResizeObserver | null = null
/**
* Initialize the Phaser game instance.
*/
function initGame(): void {
if (!container.value) {
const error = new Error('Container element not found')
console.error('[PhaserGame]', error)
emit('error', error)
return
}
try {
// Create the Phaser game instance
game.value = createGame(container.value, scenes)
// Listen for Phaser ready event (emitted when game is fully initialized)
// Using PHASER_READY_EVENT constant to avoid runtime Phaser dependency
game.value.events.once(PHASER_READY_EVENT, () => {
if (game.value) {
emit('ready', game.value)
}
})
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err))
console.error('[PhaserGame] Failed to create game instance:', error)
emit('error', error)
}
}
/**
* Set up resize observation for the container.
*/
function setupResizeObserver(): void {
if (!container.value) return
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// Phaser handles resize internally with Scale.RESIZE mode,
// but we can emit events if needed for external coordination
if (game.value && entry.contentRect) {
game.value.events.emit('resize', {
width: entry.contentRect.width,
height: entry.contentRect.height,
})
}
}
})
resizeObserver.observe(container.value)
}
/**
* Cleanup the Phaser game instance and observers.
*/
function cleanup(): void {
// Disconnect resize observer
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
// Destroy Phaser game instance
if (game.value) {
game.value.destroy(true)
game.value = null
}
}
// Lifecycle hooks
onMounted(() => {
initGame()
setupResizeObserver()
})
onUnmounted(() => {
cleanup()
})
// Expose game instance for parent components
defineExpose({
game,
})
</script>
<template>
<div
ref="container"
class="w-full h-full relative overflow-hidden bg-background"
data-testid="phaser-container"
/>
</template>

View File

@ -0,0 +1,387 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import DisplayNameEditor from './DisplayNameEditor.vue'
describe('DisplayNameEditor', () => {
describe('view mode', () => {
it('displays the current name', () => {
/**
* Test that the display name is shown in view mode.
*
* By default, the component shows the current name
* with an edit button, not an input field.
*/
const wrapper = mount(DisplayNameEditor, {
props: {
modelValue: 'Test User',
isSaving: false,
},
})
expect(wrapper.text()).toContain('Test User')
})
it('shows edit button', () => {
/**
* Test that an edit button is visible in view mode.
*
* Users need a way to enter edit mode to change their name.
*/
const wrapper = mount(DisplayNameEditor, {
props: {
modelValue: 'Test User',
isSaving: false,
},
})
const editButton = wrapper.find('button')
expect(editButton.exists()).toBe(true)
})
it('enters edit mode when edit button is clicked', async () => {
/**
* Test entering edit mode.
*
* Clicking the edit button should switch from viewing
* to editing the display name.
*/
const wrapper = mount(DisplayNameEditor, {
props: {
modelValue: 'Test User',
isSaving: false,
},
})
await wrapper.find('button').trigger('click')
// Should now show an input field
expect(wrapper.find('input').exists()).toBe(true)
})
})
describe('edit mode', () => {
it('shows input with current value', async () => {
/**
* Test that input is pre-filled with current name.
*
* When entering edit mode, the input should contain
* the current display name for easy editing.
*/
const wrapper = mount(DisplayNameEditor, {
props: {
modelValue: 'Test User',
isSaving: false,
},
})
// Enter edit mode
await wrapper.find('button').trigger('click')
const input = wrapper.find('input')
expect((input.element as HTMLInputElement).value).toBe('Test User')
})
it('shows save and cancel buttons', async () => {
/**
* Test that save and cancel buttons are visible.
*
* Users need clear actions to either save changes
* or cancel and revert to the original value.
*/
const wrapper = mount(DisplayNameEditor, {
props: {
modelValue: 'Test User',
isSaving: false,
},
})
await wrapper.find('button').trigger('click')
const buttons = wrapper.findAll('button')
const buttonTexts = buttons.map(b => b.text())
expect(buttonTexts.some(t => t.includes('Save'))).toBe(true)
expect(buttonTexts.some(t => t.includes('Cancel'))).toBe(true)
})
it('shows character count', async () => {
/**
* Test that character count is displayed.
*
* Users should know how many characters they've used
* relative to the maximum allowed.
*/
const wrapper = mount(DisplayNameEditor, {
props: {
modelValue: 'Test User',
isSaving: false,
},
})
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('/32')
})
it('cancels and reverts on cancel button click', async () => {
/**
* Test cancel functionality.
*
* Clicking cancel should exit edit mode and discard
* any changes made to the input.
*/
const wrapper = mount(DisplayNameEditor, {
props: {
modelValue: 'Test User',
isSaving: false,
},
})
// Enter edit mode
await wrapper.find('button').trigger('click')
// Change the value
await wrapper.find('input').setValue('New Name')
// Click cancel
const cancelButton = wrapper.findAll('button').find(b => b.text().includes('Cancel'))
await cancelButton!.trigger('click')
// Should be back in view mode with original name
expect(wrapper.find('input').exists()).toBe(false)
expect(wrapper.text()).toContain('Test User')
})
it('cancels on Escape key', async () => {
/**
* Test keyboard shortcut for cancel.
*
* Pressing Escape should cancel editing as a
* common keyboard shortcut.
*/
const wrapper = mount(DisplayNameEditor, {
props: {
modelValue: 'Test User',
isSaving: false,
},
})
await wrapper.find('button').trigger('click')
await wrapper.find('input').trigger('keydown', { key: 'Escape' })
expect(wrapper.find('input').exists()).toBe(false)
})
})
describe('validation', () => {
it('shows error for empty name', async () => {
/**
* Test validation of empty display name.
*
* Empty or whitespace-only names should be rejected
* with a clear error message.
*/
const wrapper = mount(DisplayNameEditor, {
props: {
modelValue: 'Test User',
isSaving: false,
},
})
await wrapper.find('button').trigger('click')
await wrapper.find('input').setValue('')
// Click save
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
await saveButton!.trigger('click')
expect(wrapper.text()).toContain('cannot be empty')
})
it('shows error for name shorter than 2 characters', async () => {
/**
* Test minimum length validation.
*
* Very short names are likely typos and should be rejected.
*/
const wrapper = mount(DisplayNameEditor, {
props: {
modelValue: 'Test User',
isSaving: false,
},
})
await wrapper.find('button').trigger('click')
await wrapper.find('input').setValue('A')
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
await saveButton!.trigger('click')
expect(wrapper.text()).toContain('at least 2 characters')
})
it('shows error for name longer than 32 characters', async () => {
/**
* Test maximum length validation.
*
* Names that are too long should be rejected to prevent
* UI issues and database constraints.
*/
const wrapper = mount(DisplayNameEditor, {
props: {
modelValue: 'Test User',
isSaving: false,
},
})
await wrapper.find('button').trigger('click')
await wrapper.find('input').setValue('A'.repeat(33))
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
await saveButton!.trigger('click')
expect(wrapper.text()).toContain('cannot exceed 32')
})
it('does not emit save with validation errors', async () => {
/**
* Test that invalid input does not trigger save.
*
* The component should validate locally before emitting
* the save event to avoid unnecessary API calls.
*/
const wrapper = mount(DisplayNameEditor, {
props: {
modelValue: 'Test User',
isSaving: false,
},
})
await wrapper.find('button').trigger('click')
await wrapper.find('input').setValue('')
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
await saveButton!.trigger('click')
expect(wrapper.emitted('save')).toBeFalsy()
})
})
describe('saving', () => {
it('emits save event with trimmed value', async () => {
/**
* Test save event emission.
*
* When the user saves, the component should emit the
* trimmed display name for the parent to handle.
*/
const wrapper = mount(DisplayNameEditor, {
props: {
modelValue: 'Test User',
isSaving: false,
},
})
await wrapper.find('button').trigger('click')
await wrapper.find('input').setValue(' New Name ')
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
await saveButton!.trigger('click')
expect(wrapper.emitted('save')).toBeTruthy()
expect(wrapper.emitted('save')![0]).toEqual(['New Name'])
})
it('saves on Enter key', async () => {
/**
* Test keyboard shortcut for save.
*
* Pressing Enter should save the changes as a
* common keyboard shortcut.
*/
const wrapper = mount(DisplayNameEditor, {
props: {
modelValue: 'Test User',
isSaving: false,
},
})
await wrapper.find('button').trigger('click')
await wrapper.find('input').setValue('New Name')
await wrapper.find('input').trigger('keydown', { key: 'Enter' })
expect(wrapper.emitted('save')).toBeTruthy()
})
it('disables buttons while saving', async () => {
/**
* Test button states during save.
*
* While saving, buttons should be disabled to prevent
* duplicate submissions.
*/
const wrapper = mount(DisplayNameEditor, {
props: {
modelValue: 'Test User',
isSaving: true,
},
})
await wrapper.find('button').trigger('click')
const buttons = wrapper.findAll('button')
buttons.forEach(button => {
if (button.text().includes('Save') || button.text().includes('Cancel')) {
expect(button.attributes('disabled')).toBeDefined()
}
})
})
it('shows "Saving..." text while saving', async () => {
/**
* Test loading state display.
*
* Users should see feedback that their save is in progress.
*/
const wrapper = mount(DisplayNameEditor, {
props: {
modelValue: 'Test User',
isSaving: true,
},
})
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('Saving')
})
it('exits edit mode when modelValue matches saved value', async () => {
/**
* Test automatic exit from edit mode on success.
*
* When the parent updates the modelValue to match the
* saved value, edit mode should close automatically.
*/
const wrapper = mount(DisplayNameEditor, {
props: {
modelValue: 'Test User',
isSaving: false,
},
})
// Enter edit mode and save
await wrapper.find('button').trigger('click')
await wrapper.find('input').setValue('New Name')
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
await saveButton!.trigger('click')
// Simulate parent updating the value
await wrapper.setProps({ modelValue: 'New Name' })
// Should exit edit mode
expect(wrapper.find('input').exists()).toBe(false)
})
})
})

View File

@ -0,0 +1,162 @@
<script setup lang="ts">
/**
* Inline editable display name component.
*
* Shows the current display name with an edit button. When editing,
* shows an input field with save/cancel buttons. Validates that the
* name is not empty before saving.
*/
import { ref, watch } from 'vue'
const props = defineProps<{
/** Current display name */
modelValue: string
/** Whether a save operation is in progress */
isSaving: boolean
}>()
const emit = defineEmits<{
/** Emitted when user saves a new name */
'update:modelValue': [value: string]
/** Emitted when user saves */
save: [newName: string]
}>()
const isEditing = ref(false)
const editValue = ref('')
const validationError = ref<string | null>(null)
/** Start editing mode */
function startEdit(): void {
editValue.value = props.modelValue
validationError.value = null
isEditing.value = true
}
/** Cancel editing and revert changes */
function cancelEdit(): void {
isEditing.value = false
editValue.value = ''
validationError.value = null
}
/** Validate and save the new name */
function saveEdit(): void {
const trimmed = editValue.value.trim()
// Validation
if (!trimmed) {
validationError.value = 'Display name cannot be empty'
return
}
if (trimmed.length < 2) {
validationError.value = 'Display name must be at least 2 characters'
return
}
if (trimmed.length > 32) {
validationError.value = 'Display name cannot exceed 32 characters'
return
}
// Clear validation and emit save
validationError.value = null
emit('save', trimmed)
}
/** Handle Enter key to save */
function handleKeydown(e: KeyboardEvent): void {
if (e.key === 'Enter') {
e.preventDefault()
saveEdit()
} else if (e.key === 'Escape') {
cancelEdit()
}
}
// Exit edit mode when save completes successfully
watch(
() => props.modelValue,
(newVal) => {
if (isEditing.value && newVal === editValue.value.trim()) {
isEditing.value = false
}
}
)
</script>
<template>
<div class="flex flex-col gap-1">
<!-- View mode -->
<div
v-if="!isEditing"
class="flex items-center gap-2"
>
<span class="text-xl font-semibold text-white">{{ modelValue }}</span>
<button
class="p-1 text-gray-400 hover:text-white transition-colors"
title="Edit display name"
@click="startEdit"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
</div>
<!-- Edit mode -->
<div
v-else
class="flex flex-col gap-2"
>
<div class="flex items-center gap-2">
<input
v-model="editValue"
type="text"
class="flex-1 px-3 py-2 bg-surface-light border border-gray-600 rounded-lg text-white focus:outline-none focus:border-primary"
:class="{ 'border-red-500': validationError }"
placeholder="Enter display name"
maxlength="32"
autofocus
@keydown="handleKeydown"
>
<button
class="px-3 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isSaving"
@click="saveEdit"
>
<span v-if="isSaving">Saving...</span>
<span v-else>Save</span>
</button>
<button
class="px-3 py-2 bg-gray-700 text-gray-300 rounded-lg hover:bg-gray-600 hover:text-white transition-colors"
:disabled="isSaving"
@click="cancelEdit"
>
Cancel
</button>
</div>
<p
v-if="validationError"
class="text-sm text-red-400"
>
{{ validationError }}
</p>
<p class="text-xs text-gray-500">
{{ editValue.length }}/32 characters
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,264 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import LinkedAccountCard from './LinkedAccountCard.vue'
import type { LinkedAccount } from '@/composables/useProfile'
describe('LinkedAccountCard', () => {
const createAccount = (overrides: Partial<LinkedAccount> = {}): LinkedAccount => ({
provider: 'google',
providerUserId: 'g123',
email: 'test@gmail.com',
linkedAt: '2026-01-15T10:30:00Z',
...overrides,
})
describe('rendering', () => {
it('displays provider name', () => {
/**
* Test that the provider name is displayed.
*
* Users should be able to identify which OAuth provider
* each linked account belongs to.
*/
const wrapper = mount(LinkedAccountCard, {
props: {
account: createAccount({ provider: 'google' }),
isOnlyAccount: false,
isUnlinking: false,
},
})
expect(wrapper.text()).toContain('Google')
})
it('displays email when available', () => {
/**
* Test that the email is displayed for linked accounts.
*
* The email helps users identify which specific account
* is linked (e.g., if they have multiple Google accounts).
*/
const wrapper = mount(LinkedAccountCard, {
props: {
account: createAccount({ email: 'user@example.com' }),
isOnlyAccount: false,
isUnlinking: false,
},
})
expect(wrapper.text()).toContain('user@example.com')
})
it('displays fallback text when email is null', () => {
/**
* Test fallback display when no email is available.
*
* Some providers may not provide email (e.g., Discord).
* We should show a reasonable fallback.
*/
const wrapper = mount(LinkedAccountCard, {
props: {
account: createAccount({ provider: 'discord', email: null }),
isOnlyAccount: false,
isUnlinking: false,
},
})
expect(wrapper.text()).toContain('Discord Account')
})
it('displays linked date formatted', () => {
/**
* Test that the linked date is displayed in a readable format.
*
* Users should know when they linked each account for security.
*/
const wrapper = mount(LinkedAccountCard, {
props: {
account: createAccount({ linkedAt: '2026-01-15T10:30:00Z' }),
isOnlyAccount: false,
isUnlinking: false,
},
})
// Should contain "Linked" and some date text
expect(wrapper.text()).toContain('Linked')
expect(wrapper.text()).toMatch(/Jan|15|2026/)
})
it('shows correct icon for Google', () => {
/**
* Test Google provider icon.
*
* Visual differentiation helps users quickly identify providers.
*/
const wrapper = mount(LinkedAccountCard, {
props: {
account: createAccount({ provider: 'google' }),
isOnlyAccount: false,
isUnlinking: false,
},
})
expect(wrapper.text()).toContain('🔵')
})
it('shows correct icon for Discord', () => {
/**
* Test Discord provider icon.
*
* Visual differentiation helps users quickly identify providers.
*/
const wrapper = mount(LinkedAccountCard, {
props: {
account: createAccount({ provider: 'discord' }),
isOnlyAccount: false,
isUnlinking: false,
},
})
expect(wrapper.text()).toContain('🟣')
})
})
describe('unlink button', () => {
it('is enabled when not the only account', () => {
/**
* Test that unlink is enabled with multiple accounts.
*
* Users should be able to unlink accounts as long as
* they have at least one other linked account.
*/
const wrapper = mount(LinkedAccountCard, {
props: {
account: createAccount(),
isOnlyAccount: false,
isUnlinking: false,
},
})
const button = wrapper.find('button')
expect(button.attributes('disabled')).toBeUndefined()
})
it('is disabled when only account', () => {
/**
* Test that unlink is disabled for the only account.
*
* Users cannot unlink their only OAuth provider because
* they would be locked out of their account.
*/
const wrapper = mount(LinkedAccountCard, {
props: {
account: createAccount(),
isOnlyAccount: true,
isUnlinking: false,
},
})
const button = wrapper.find('button')
expect(button.attributes('disabled')).toBeDefined()
})
it('is disabled while unlinking', () => {
/**
* Test that unlink is disabled during operation.
*
* Prevent double-clicks and show feedback while
* the unlink operation is in progress.
*/
const wrapper = mount(LinkedAccountCard, {
props: {
account: createAccount(),
isOnlyAccount: false,
isUnlinking: true,
},
})
const button = wrapper.find('button')
expect(button.attributes('disabled')).toBeDefined()
expect(button.text()).toContain('Unlinking')
})
it('shows tooltip explaining why unlink is disabled', () => {
/**
* Test tooltip for disabled unlink button.
*
* Users should understand why they can't unlink
* their only account.
*/
const wrapper = mount(LinkedAccountCard, {
props: {
account: createAccount(),
isOnlyAccount: true,
isUnlinking: false,
},
})
const button = wrapper.find('button')
expect(button.attributes('title')).toContain('Cannot unlink')
})
it('emits unlink event with provider when clicked', async () => {
/**
* Test unlink event emission.
*
* When the user clicks unlink, the component should emit
* an event with the provider name for the parent to handle.
*/
const wrapper = mount(LinkedAccountCard, {
props: {
account: createAccount({ provider: 'discord' }),
isOnlyAccount: false,
isUnlinking: false,
},
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('unlink')).toBeTruthy()
expect(wrapper.emitted('unlink')![0]).toEqual(['discord'])
})
it('does not emit when only account', async () => {
/**
* Test that unlink is not emitted for only account.
*
* Even if the button is somehow clicked, the event
* should not be emitted.
*/
const wrapper = mount(LinkedAccountCard, {
props: {
account: createAccount(),
isOnlyAccount: true,
isUnlinking: false,
},
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('unlink')).toBeFalsy()
})
it('does not emit while unlinking', async () => {
/**
* Test that unlink is not emitted while in progress.
*
* Prevent duplicate requests if button is clicked
* while an operation is already in progress.
*/
const wrapper = mount(LinkedAccountCard, {
props: {
account: createAccount(),
isOnlyAccount: false,
isUnlinking: true,
},
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('unlink')).toBeFalsy()
})
})
})

View File

@ -0,0 +1,105 @@
<script setup lang="ts">
/**
* Linked OAuth account card component.
*
* Displays information about a linked OAuth provider (Google or Discord)
* including the email/username, when it was linked, and an unlink button.
* The unlink button is disabled if this is the only linked account.
*/
import { computed } from 'vue'
import type { OAuthProvider, LinkedAccount } from '@/composables/useProfile'
const props = defineProps<{
/** The linked account data */
account: LinkedAccount
/** Whether this is the only linked account (cannot unlink) */
isOnlyAccount: boolean
/** Whether an unlink operation is in progress */
isUnlinking: boolean
}>()
const emit = defineEmits<{
/** Emitted when user clicks unlink button */
unlink: [provider: OAuthProvider]
}>()
/** Provider display configuration */
const providerConfig = computed(() => {
const configs = {
google: {
name: 'Google',
icon: '🔵',
color: 'border-blue-500',
bgColor: 'bg-blue-500/10',
},
discord: {
name: 'Discord',
icon: '🟣',
color: 'border-indigo-500',
bgColor: 'bg-indigo-500/10',
},
}
return configs[props.account.provider]
})
/** Format the linked date for display */
const linkedDateFormatted = computed(() => {
const date = new Date(props.account.linkedAt)
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
})
/** Display text (email or provider ID) */
const displayText = computed(() => {
return props.account.email || `${providerConfig.value.name} Account`
})
function handleUnlink(): void {
if (!props.isOnlyAccount && !props.isUnlinking) {
emit('unlink', props.account.provider)
}
}
</script>
<template>
<div
class="flex items-center justify-between p-4 rounded-lg border-l-4"
:class="[providerConfig.bgColor, providerConfig.color]"
>
<!-- Provider info -->
<div class="flex items-center gap-3">
<span class="text-2xl">{{ providerConfig.icon }}</span>
<div>
<div class="font-medium text-white">
{{ providerConfig.name }}
</div>
<div class="text-sm text-gray-400">
{{ displayText }}
</div>
<div class="text-xs text-gray-500 mt-0.5">
Linked {{ linkedDateFormatted }}
</div>
</div>
</div>
<!-- Unlink button -->
<button
class="px-3 py-1.5 text-sm rounded transition-colors"
:class="[
isOnlyAccount
? 'bg-gray-700 text-gray-500 cursor-not-allowed'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600 hover:text-white'
]"
:disabled="isOnlyAccount || isUnlinking"
:title="isOnlyAccount ? 'Cannot unlink the only linked account' : 'Unlink this account'"
@click="handleUnlink"
>
<span v-if="isUnlinking">Unlinking...</span>
<span v-else>Unlink</span>
</button>
</div>
</template>

View File

@ -0,0 +1,225 @@
<script setup lang="ts">
/**
* Reusable confirmation dialog component.
*
* A modal dialog that asks the user to confirm a destructive or important action.
* Supports different variants for different contexts (danger for delete operations).
*
* Features:
* - Accessible modal with focus trap
* - Keyboard support (Escape to close)
* - Click outside to close
* - Customizable title, message, and button labels
* - Variant styling for danger operations
*
* @example
* ```vue
* <ConfirmDialog
* :is-open="showDeleteConfirm"
* title="Delete Deck"
* message="Are you sure? This cannot be undone."
* confirm-label="Delete"
* variant="danger"
* @confirm="handleDelete"
* @cancel="showDeleteConfirm = false"
* />
* ```
*/
import { watch, ref, onUnmounted } from 'vue'
const props = withDefaults(
defineProps<{
/** Whether the dialog is open */
isOpen: boolean
/** Dialog title */
title: string
/** Dialog message/description */
message: string
/** Label for the confirm button */
confirmLabel?: string
/** Label for the cancel button */
cancelLabel?: string
/** Visual variant - 'default' or 'danger' */
variant?: 'default' | 'danger'
/** Whether the confirm action is in progress (shows loading state) */
isLoading?: boolean
}>(),
{
confirmLabel: 'Confirm',
cancelLabel: 'Cancel',
variant: 'default',
isLoading: false,
}
)
const emit = defineEmits<{
/** Emitted when the user confirms the action */
confirm: []
/** Emitted when the user cancels or closes the dialog */
cancel: []
}>()
const dialogRef = ref<HTMLDivElement | null>(null)
/**
* Handle keyboard events for the dialog.
*/
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && !props.isLoading) {
emit('cancel')
}
}
/**
* Handle click on the backdrop to close the dialog.
*/
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget && !props.isLoading) {
emit('cancel')
}
}
/**
* Handle the confirm button click.
*/
function handleConfirm() {
if (!props.isLoading) {
emit('confirm')
}
}
/**
* Handle the cancel button click.
*/
function handleCancel() {
if (!props.isLoading) {
emit('cancel')
}
}
// Add/remove keyboard listener based on open state
watch(
() => props.isOpen,
(isOpen) => {
if (isOpen) {
document.addEventListener('keydown', handleKeydown)
// Focus the dialog when it opens
setTimeout(() => {
dialogRef.value?.focus()
}, 0)
} else {
document.removeEventListener('keydown', handleKeydown)
}
},
{ immediate: true }
)
// Clean up listener on unmount
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
/**
* Confirm button classes based on variant.
*/
const confirmButtonClasses = {
default:
'bg-primary text-white hover:bg-primary-dark focus:ring-primary',
danger: 'bg-error text-white hover:bg-error/90 focus:ring-error',
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isOpen"
class="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
@click="handleBackdropClick"
>
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="isOpen"
ref="dialogRef"
role="dialog"
aria-modal="true"
:aria-labelledby="title"
class="bg-surface rounded-2xl shadow-2xl w-full max-w-md overflow-hidden"
tabindex="-1"
>
<!-- Header -->
<div class="p-6 pb-4">
<h2 class="text-xl font-bold text-text">
{{ title }}
</h2>
<p class="mt-2 text-text-muted">
{{ message }}
</p>
</div>
<!-- Actions -->
<div class="flex gap-3 p-6 pt-2">
<button
type="button"
class="flex-1 px-4 py-2 rounded-lg font-medium bg-surface-light text-text hover:bg-surface-lighter active:scale-95 transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLoading"
@click="handleCancel"
>
{{ cancelLabel }}
</button>
<button
type="button"
class="flex-1 px-4 py-2 rounded-lg font-medium active:scale-95 transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-surface"
:class="confirmButtonClasses[variant]"
:disabled="isLoading"
@click="handleConfirm"
>
<span
v-if="isLoading"
class="inline-flex items-center gap-2"
>
<svg
class="animate-spin h-4 w-4"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
fill="none"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span>{{ confirmLabel }}</span>
</span>
<span v-else>{{ confirmLabel }}</span>
</button>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>

View File

@ -0,0 +1,89 @@
<script setup lang="ts">
/**
* Empty state component for displaying when lists/grids have no content.
*
* A reusable component that shows a centered message with:
* - Optional icon slot for contextual imagery
* - Title text explaining the empty state
* - Description text with additional context
* - Optional action slot for CTA buttons
*
* Follows the 'Empty States' pattern from DESIGN_REFERENCE.md with
* consistent spacing, typography, and visual hierarchy.
*
* @example
* ```vue
* <EmptyState
* title="Your collection is empty"
* description="Win matches to earn booster packs."
* >
* <template #icon>
* <CollectionIcon class="w-16 h-16" />
* </template>
* <template #action>
* <button class="btn-primary">Find a Match</button>
* </template>
* </EmptyState>
* ```
*/
defineProps<{
/** Main title text */
title: string
/** Supporting description text */
description?: string
}>()
</script>
<template>
<div class="flex flex-col items-center justify-center py-16 px-4 text-center">
<!-- Icon slot -->
<div
v-if="$slots.icon"
class="w-16 h-16 mb-4 text-text-muted"
>
<slot name="icon" />
</div>
<!-- Default icon if no slot provided -->
<div
v-else
class="w-16 h-16 mb-4 text-text-muted"
>
<svg
class="w-full h-full"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
</div>
<!-- Title -->
<h3 class="text-lg font-medium text-text mb-2">
{{ title }}
</h3>
<!-- Description -->
<p
v-if="description"
class="text-text-muted mb-6 max-w-sm"
>
{{ description }}
</p>
<!-- Action slot -->
<div
v-if="$slots.action"
class="mt-2"
>
<slot name="action" />
</div>
</div>
</template>

View File

@ -0,0 +1,189 @@
import { describe, it, expect } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import ErrorBoundary from './ErrorBoundary.vue'
describe('ErrorBoundary', () => {
describe('normal operation', () => {
it('renders slot content when no error', () => {
/**
* Test that child content is displayed normally.
*
* When no errors occur, the error boundary should be
* transparent and render its slot content as-is.
*/
const wrapper = mount(ErrorBoundary, {
slots: {
default: '<div class="child">Hello World</div>',
},
})
expect(wrapper.find('.child').exists()).toBe(true)
expect(wrapper.text()).toContain('Hello World')
})
})
describe('error handling', () => {
it('shows fallback UI when child throws', async () => {
/**
* Test that errors in children are caught and handled.
*
* When a child component throws, the error boundary should
* display a friendly fallback UI instead of crashing.
*/
const ThrowingComponent = defineComponent({
setup() {
throw new Error('Test error')
},
render() {
return h('div', 'Should not render')
},
})
const wrapper = mount(ErrorBoundary, {
slots: {
default: h(ThrowingComponent),
},
})
await flushPromises()
expect(wrapper.text()).toContain('Oops!')
expect(wrapper.text()).toContain('Something went wrong')
expect(wrapper.find('.child').exists()).toBe(false)
})
it('shows custom fallback message', async () => {
/**
* Test that custom fallback messages are displayed.
*
* Components can customize the error message to provide
* context-specific guidance to users.
*/
const ThrowingComponent = defineComponent({
setup() {
throw new Error('Test error')
},
render() {
return h('div')
},
})
const wrapper = mount(ErrorBoundary, {
props: {
fallbackMessage: 'Failed to load game data.',
},
slots: {
default: h(ThrowingComponent),
},
})
await flushPromises()
expect(wrapper.text()).toContain('Failed to load game data.')
})
it('emits error event when catching', async () => {
/**
* Test that error events are emitted for parent handling.
*
* Parent components may want to log errors or take
* additional recovery actions.
*/
const ThrowingComponent = defineComponent({
setup() {
throw new Error('Test error message')
},
render() {
return h('div')
},
})
const wrapper = mount(ErrorBoundary, {
slots: {
default: h(ThrowingComponent),
},
})
await flushPromises()
const errorEvents = wrapper.emitted('error')
expect(errorEvents).toBeTruthy()
expect(errorEvents![0][0]).toBeInstanceOf(Error)
expect((errorEvents![0][0] as Error).message).toBe('Test error message')
})
})
describe('recovery', () => {
it('has retry button', async () => {
/**
* Test that retry button is available.
*
* Users should be able to attempt recovery without
* refreshing the entire page.
*/
const ThrowingComponent = defineComponent({
setup() {
throw new Error('Test error')
},
render() {
return h('div')
},
})
const wrapper = mount(ErrorBoundary, {
slots: {
default: h(ThrowingComponent),
},
})
await flushPromises()
const retryButton = wrapper.find('button')
expect(retryButton.exists()).toBe(true)
expect(retryButton.text()).toContain('Try Again')
})
it('resets error state on retry', async () => {
/**
* Test that clicking retry clears the error state.
*
* After retry, the boundary should attempt to re-render
* the slot content.
*/
let shouldThrow = true
const ConditionalThrowComponent = defineComponent({
setup() {
if (shouldThrow) {
throw new Error('Test error')
}
},
render() {
return h('div', { class: 'success' }, 'Success!')
},
})
const wrapper = mount(ErrorBoundary, {
slots: {
default: h(ConditionalThrowComponent),
},
})
await flushPromises()
// Error state active
expect(wrapper.text()).toContain('Oops!')
// Disable throwing and retry
shouldThrow = false
await wrapper.find('button').trigger('click')
await flushPromises()
// Should attempt to render slot again
// Note: The slot is still the throwing component from mount time,
// so we just verify error state is cleared
expect(wrapper.text()).not.toContain('Oops!')
})
})
})

View File

@ -0,0 +1,77 @@
<script setup lang="ts">
/**
* Error boundary component.
*
* Catches errors in child components and displays a fallback UI
* instead of crashing the entire application. Provides a retry
* button to attempt recovery.
*/
import { ref, computed, onErrorCaptured } from 'vue'
const props = withDefaults(defineProps<{
/** Custom fallback message */
fallbackMessage?: string
}>(), {
fallbackMessage: 'Something went wrong. Please try again.',
})
const emit = defineEmits<{
error: [error: Error, info: string]
}>()
const hasError = ref(false)
const errorMessage = ref<string | null>(null)
const isDev = computed(() => import.meta.env.DEV)
onErrorCaptured((error: Error, _instance, info: string) => {
hasError.value = true
errorMessage.value = error.message
// Emit error for parent handling/logging
emit('error', error, info)
// Log in development
if (import.meta.env.DEV) {
console.error('ErrorBoundary caught:', error)
console.error('Component info:', info)
}
// Prevent error from propagating
return false
})
function handleRetry(): void {
hasError.value = false
errorMessage.value = null
}
</script>
<template>
<div
v-if="hasError"
class="flex flex-col items-center justify-center p-8 text-center"
>
<div class="text-4xl mb-4">
&#x26A0;
</div>
<h2 class="text-xl font-semibold text-gray-200 mb-2">
Oops!
</h2>
<p class="text-gray-400 mb-4 max-w-md">
{{ props.fallbackMessage }}
</p>
<p
v-if="errorMessage && isDev"
class="text-error text-sm mb-4 font-mono bg-error/10 px-3 py-2 rounded"
>
{{ errorMessage }}
</p>
<button
class="btn btn-primary"
@click="handleRetry"
>
Try Again
</button>
</div>
<slot v-else />
</template>

View File

@ -0,0 +1,284 @@
<script setup lang="ts">
/**
* Filter bar component for filtering and searching collections/lists.
*
* A horizontal bar with:
* - Search input with icon
* - Type dropdown filter
* - Category dropdown filter
* - Rarity dropdown filter
* - Clear filters button (when filters are active)
*
* Follows the 'Filter Bar' pattern from DESIGN_REFERENCE.md with
* consistent input styling and responsive layout.
*
* @example
* ```vue
* <FilterBar
* v-model:search="searchQuery"
* v-model:type="selectedType"
* v-model:category="selectedCategory"
* v-model:rarity="selectedRarity"
* />
* ```
*/
import { computed } from 'vue'
import type { CardType, CardCategory, CardDefinition } from '@/types'
const props = withDefaults(
defineProps<{
/** Current search query */
search?: string
/** Selected type filter (null for all) */
type?: CardType | null
/** Selected category filter (null for all) */
category?: CardCategory | null
/** Selected rarity filter (null for all) */
rarity?: CardDefinition['rarity'] | null
/** Placeholder text for search input */
searchPlaceholder?: string
/** Whether to show the category filter */
showCategoryFilter?: boolean
/** Whether to show the rarity filter */
showRarityFilter?: boolean
}>(),
{
search: '',
type: null,
category: null,
rarity: null,
searchPlaceholder: 'Search cards...',
showCategoryFilter: true,
showRarityFilter: true,
}
)
const emit = defineEmits<{
'update:search': [value: string]
'update:type': [value: CardType | null]
'update:category': [value: CardCategory | null]
'update:rarity': [value: CardDefinition['rarity'] | null]
}>()
/**
* All available card types for the dropdown.
*/
const cardTypes: CardType[] = [
'grass',
'fire',
'water',
'lightning',
'psychic',
'fighting',
'darkness',
'metal',
'fairy',
'dragon',
'colorless',
]
/**
* All available card categories.
*/
const cardCategories: CardCategory[] = ['pokemon', 'trainer', 'energy']
/**
* All available rarities.
*/
const rarities: CardDefinition['rarity'][] = [
'common',
'uncommon',
'rare',
'holo',
'ultra',
]
/**
* Display labels for card types.
*/
const typeLabels: Record<CardType, string> = {
grass: 'Grass',
fire: 'Fire',
water: 'Water',
lightning: 'Lightning',
psychic: 'Psychic',
fighting: 'Fighting',
darkness: 'Darkness',
metal: 'Metal',
fairy: 'Fairy',
dragon: 'Dragon',
colorless: 'Colorless',
}
/**
* Display labels for categories.
*/
const categoryLabels: Record<CardCategory, string> = {
pokemon: 'Pokemon',
trainer: 'Trainer',
energy: 'Energy',
}
/**
* Display labels for rarities.
*/
const rarityLabels: Record<CardDefinition['rarity'], string> = {
common: 'Common',
uncommon: 'Uncommon',
rare: 'Rare',
holo: 'Holo Rare',
ultra: 'Ultra Rare',
}
/**
* Whether any filter is currently active.
*/
const hasActiveFilters = computed(() => {
return props.search || props.type || props.category || props.rarity
})
/**
* Clear all filters.
*/
function clearFilters() {
emit('update:search', '')
emit('update:type', null)
emit('update:category', null)
emit('update:rarity', null)
}
/**
* Handle type selection change.
*/
function onTypeChange(event: Event) {
const target = event.target as HTMLSelectElement
const value = target.value === '' ? null : (target.value as CardType)
emit('update:type', value)
}
/**
* Handle category selection change.
*/
function onCategoryChange(event: Event) {
const target = event.target as HTMLSelectElement
const value = target.value === '' ? null : (target.value as CardCategory)
emit('update:category', value)
}
/**
* Handle rarity selection change.
*/
function onRarityChange(event: Event) {
const target = event.target as HTMLSelectElement
const value = target.value === '' ? null : (target.value as CardDefinition['rarity'])
emit('update:rarity', value)
}
/**
* Handle search input change.
*/
function onSearchInput(event: Event) {
const target = event.target as HTMLInputElement
emit('update:search', target.value)
}
</script>
<template>
<div class="flex flex-wrap items-center gap-3 p-4 bg-surface rounded-xl mb-6">
<!-- Search input -->
<div class="relative flex-1 min-w-[200px]">
<!-- Search icon -->
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
:value="search"
:placeholder="searchPlaceholder"
class="w-full pl-10 pr-4 py-2 bg-background rounded-lg border border-surface-light focus:border-primary focus:ring-1 focus:ring-primary transition-colors text-text placeholder:text-text-muted"
@input="onSearchInput"
>
</div>
<!-- Type dropdown -->
<select
:value="type ?? ''"
class="px-3 py-2 rounded-lg bg-background border border-surface-light focus:border-primary focus:ring-1 focus:ring-primary transition-colors text-text"
aria-label="Filter by type"
@change="onTypeChange"
>
<option value="">
All Types
</option>
<option
v-for="t in cardTypes"
:key="t"
:value="t"
>
{{ typeLabels[t] }}
</option>
</select>
<!-- Category dropdown -->
<select
v-if="showCategoryFilter"
:value="category ?? ''"
class="px-3 py-2 rounded-lg bg-background border border-surface-light focus:border-primary focus:ring-1 focus:ring-primary transition-colors text-text"
aria-label="Filter by category"
@change="onCategoryChange"
>
<option value="">
All Categories
</option>
<option
v-for="c in cardCategories"
:key="c"
:value="c"
>
{{ categoryLabels[c] }}
</option>
</select>
<!-- Rarity dropdown -->
<select
v-if="showRarityFilter"
:value="rarity ?? ''"
class="px-3 py-2 rounded-lg bg-background border border-surface-light focus:border-primary focus:ring-1 focus:ring-primary transition-colors text-text"
aria-label="Filter by rarity"
@change="onRarityChange"
>
<option value="">
All Rarities
</option>
<option
v-for="r in rarities"
:key="r"
:value="r"
>
{{ rarityLabels[r] }}
</option>
</select>
<!-- Clear filters button -->
<button
v-if="hasActiveFilters"
class="px-3 py-2 rounded-lg text-text-muted hover:text-text hover:bg-surface-light transition-colors text-sm font-medium"
aria-label="Clear all filters"
@click="clearFilters"
>
Clear
</button>
</div>
</template>

View File

@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { useUiStore } from '@/stores/ui'
import LoadingOverlay from './LoadingOverlay.vue'
describe('LoadingOverlay', () => {
beforeEach(() => {
setActivePinia(createPinia())
// Clear any teleported content from previous tests
document.body.innerHTML = ''
})
describe('visibility', () => {
it('is not visible when not loading', () => {
/**
* Test that the overlay is hidden by default.
*
* When no loading operations are active, the overlay should
* not be visible to avoid blocking the UI.
*/
mount(LoadingOverlay)
const ui = useUiStore()
expect(ui.isLoading).toBe(false)
expect(document.body.querySelector('.fixed')).toBeNull()
})
it('is visible when loading', async () => {
/**
* Test that the overlay appears when loading starts.
*
* The overlay should be teleported to body and be visible
* when showLoading() is called.
*/
mount(LoadingOverlay)
const ui = useUiStore()
ui.showLoading()
// Wait for the teleport and transition
await new Promise(resolve => setTimeout(resolve, 50))
expect(ui.isLoading).toBe(true)
expect(document.body.querySelector('.fixed')).not.toBeNull()
})
it('shows loading message when provided', async () => {
/**
* Test that loading messages are displayed.
*
* Components can provide context about what's loading,
* which should be shown to the user.
*/
mount(LoadingOverlay)
const ui = useUiStore()
ui.showLoading('Saving game...')
await new Promise(resolve => setTimeout(resolve, 50))
expect(document.body.textContent).toContain('Saving game...')
})
it('hides when loading count reaches zero', async () => {
/**
* Test that the overlay hides after all loading calls complete.
*
* Multiple components may trigger loading simultaneously.
* The overlay should stay visible until all are done.
*/
mount(LoadingOverlay)
const ui = useUiStore()
ui.showLoading()
ui.showLoading() // Second concurrent load
await new Promise(resolve => setTimeout(resolve, 50))
expect(document.body.querySelector('.fixed')).not.toBeNull()
ui.hideLoading() // First load done
await new Promise(resolve => setTimeout(resolve, 50))
expect(document.body.querySelector('.fixed')).not.toBeNull() // Still visible
ui.hideLoading() // Second load done
await new Promise(resolve => setTimeout(resolve, 250)) // Wait for transition
expect(document.body.querySelector('.fixed')).toBeNull() // Now hidden
})
})
})

View File

@ -0,0 +1,55 @@
<script setup lang="ts">
/**
* Loading overlay component.
*
* Displays a full-screen loading overlay when the UI store's isLoading
* is true. Supports stacked loading calls - the overlay stays visible
* until all showLoading() calls are balanced with hideLoading().
*/
import { useUiStore } from '@/stores/ui'
const ui = useUiStore()
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="ui.isLoading"
class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/80 backdrop-blur-sm"
>
<div class="flex flex-col items-center gap-4">
<!-- Spinner -->
<div class="relative">
<div
class="w-12 h-12 border-4 border-primary/30 rounded-full"
/>
<div
class="absolute inset-0 w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin"
/>
</div>
<!-- Optional message -->
<p
v-if="ui.loadingMessage"
class="text-gray-300 text-sm"
>
{{ ui.loadingMessage }}
</p>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -0,0 +1,115 @@
<script setup lang="ts">
/**
* Reusable progress bar component for displaying progress toward a target.
*
* Features:
* - Colored bar showing current progress
* - Text label showing current/target values
* - Dynamic color based on status (primary, success, error)
* - Smooth width transitions
*
* Used in deck builder to show card count progress toward 60 cards.
*
* @example
* ```vue
* <ProgressBar
* :current="45"
* :target="60"
* label="cards"
* />
* ```
*/
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
/** Current value */
current: number
/** Target value to reach */
target: number
/** Optional label to display after the count (e.g., "cards") */
label?: string
/** Whether to show the text label */
showLabel?: boolean
}>(),
{
label: '',
showLabel: true,
}
)
/**
* Percentage of progress, capped at 100%.
*/
const percentage = computed(() => {
if (props.target <= 0) return 0
return Math.min((props.current / props.target) * 100, 100)
})
/**
* Bar color class based on current vs target.
* - Success (green) when exactly at target
* - Error (red) when over target
* - Primary (blue) when under target
*/
const barColorClass = computed(() => {
if (props.current === props.target) {
return 'bg-success'
}
if (props.current > props.target) {
return 'bg-error'
}
return 'bg-primary'
})
/**
* Text color class based on current vs target.
*/
const textColorClass = computed(() => {
if (props.current === props.target) {
return 'text-success'
}
if (props.current > props.target) {
return 'text-error'
}
return 'text-text-muted'
})
/**
* Display text showing current/target with optional label.
*/
const displayText = computed(() => {
const countText = `${props.current}/${props.target}`
return props.label ? `${countText} ${props.label}` : countText
})
</script>
<template>
<div class="flex items-center gap-3">
<!-- Progress bar track -->
<div
class="flex-1 h-2 bg-surface-light rounded-full overflow-hidden"
role="progressbar"
:aria-valuenow="current"
:aria-valuemin="0"
:aria-valuemax="target"
:aria-label="label ? `${current} of ${target} ${label}` : `${current} of ${target}`"
>
<!-- Progress bar fill -->
<div
class="h-full transition-all duration-300 ease-out"
:class="barColorClass"
:style="{ width: `${percentage}%` }"
/>
</div>
<!-- Label -->
<span
v-if="showLabel"
class="text-sm font-medium whitespace-nowrap"
:class="textColorClass"
>
{{ displayText }}
</span>
</div>
</template>

View File

@ -0,0 +1,81 @@
<script setup lang="ts">
/**
* Skeleton card component for loading states.
*
* A placeholder component that mimics the CardDisplay layout with pulsing
* animation to indicate loading. Used in collection grids and deck views
* while cards are being fetched.
*
* Follows the 'Skeleton Card' pattern from DESIGN_REFERENCE.md with:
* - Matching aspect ratio (2.5:3.5 standard TCG card ratio)
* - Pulse animation for loading indication
* - Size variants matching CardDisplay
*/
const props = withDefaults(
defineProps<{
/** Size variant matching CardDisplay sizes */
size?: 'sm' | 'md' | 'lg'
}>(),
{
size: 'md',
}
)
/**
* Container padding classes per size variant.
*/
const containerSizeClasses = {
sm: 'p-1',
md: 'p-2',
lg: 'p-3',
}
/**
* Name placeholder width classes per size.
*/
const namePlaceholderClasses = {
sm: 'h-3 w-3/4',
md: 'h-4 w-3/4',
lg: 'h-5 w-3/4',
}
/**
* Badge placeholder width classes per size.
*/
const badgePlaceholderClasses = {
sm: 'h-3 w-1/3',
md: 'h-3 w-1/2',
lg: 'h-4 w-1/2',
}
</script>
<template>
<div
class="bg-surface rounded-xl animate-pulse"
:class="containerSizeClasses[props.size]"
role="status"
aria-label="Loading card"
>
<!-- Image placeholder with card aspect ratio -->
<div
class="aspect-[2.5/3.5] bg-surface-light rounded-lg"
/>
<!-- Info placeholder -->
<div class="mt-2 space-y-2">
<!-- Name placeholder -->
<div
class="bg-surface-light rounded"
:class="namePlaceholderClasses[props.size]"
/>
<!-- Type badge placeholder -->
<div
class="bg-surface-light rounded"
:class="badgePlaceholderClasses[props.size]"
/>
</div>
<!-- Screen reader text -->
<span class="sr-only">Loading card...</span>
</div>
</template>

View File

@ -0,0 +1,168 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { useUiStore } from '@/stores/ui'
import ToastContainer from './ToastContainer.vue'
describe('ToastContainer', () => {
beforeEach(() => {
setActivePinia(createPinia())
// Clear any teleported content from previous tests
document.body.innerHTML = ''
vi.useFakeTimers()
})
describe('toast display', () => {
it('is empty when no toasts', () => {
/**
* Test that container has no toasts by default.
*
* The container should be present but empty when
* no notifications have been triggered.
*/
mount(ToastContainer)
const ui = useUiStore()
expect(ui.toasts).toHaveLength(0)
})
it('shows toast when added', async () => {
/**
* Test that toasts appear when triggered.
*
* The toast should be visible with its message and
* appropriate styling for the toast type.
*/
mount(ToastContainer)
const ui = useUiStore()
ui.showSuccess('Operation completed!')
// Wait for render
await vi.advanceTimersByTimeAsync(50)
expect(ui.toasts).toHaveLength(1)
expect(document.body.textContent).toContain('Operation completed!')
})
it('shows multiple toasts', async () => {
/**
* Test that multiple toasts can be displayed.
*
* Users may trigger several notifications in quick succession,
* all should be visible simultaneously.
*/
mount(ToastContainer)
const ui = useUiStore()
ui.showSuccess('First message')
ui.showError('Second message')
ui.showWarning('Third message')
await vi.advanceTimersByTimeAsync(50)
expect(ui.toasts).toHaveLength(3)
expect(document.body.textContent).toContain('First message')
expect(document.body.textContent).toContain('Second message')
expect(document.body.textContent).toContain('Third message')
})
})
describe('auto-dismiss', () => {
it('auto-dismisses after duration', async () => {
/**
* Test that toasts auto-dismiss after their duration.
*
* Toasts should disappear automatically so users don't
* need to manually close them.
*/
mount(ToastContainer)
const ui = useUiStore()
ui.showSuccess('Auto dismiss me', 1000) // 1 second duration
expect(ui.toasts).toHaveLength(1)
await vi.advanceTimersByTimeAsync(1100) // Past the duration
expect(ui.toasts).toHaveLength(0)
})
it('does not auto-dismiss with zero duration', async () => {
/**
* Test that persistent toasts remain visible.
*
* Some notifications may be important enough to require
* manual dismissal by the user.
*/
mount(ToastContainer)
const ui = useUiStore()
ui.showError('Persistent error', 0) // No auto-dismiss
await vi.advanceTimersByTimeAsync(10000) // Long time passes
expect(ui.toasts).toHaveLength(1)
})
})
describe('manual dismiss', () => {
it('dismisses toast by id', async () => {
/**
* Test that individual toasts can be dismissed.
*
* Users should be able to close toasts they've read
* without waiting for the timeout.
*/
mount(ToastContainer)
const ui = useUiStore()
const id = ui.showSuccess('Dismiss me', 0)
expect(ui.toasts).toHaveLength(1)
ui.dismissToast(id)
expect(ui.toasts).toHaveLength(0)
})
it('dismisses all toasts', async () => {
/**
* Test that all toasts can be cleared at once.
*
* Useful for cleaning up notifications when navigating
* away or when user wants to clear all messages.
*/
mount(ToastContainer)
const ui = useUiStore()
ui.showSuccess('One', 0)
ui.showError('Two', 0)
ui.showInfo('Three', 0)
expect(ui.toasts).toHaveLength(3)
ui.dismissAllToasts()
expect(ui.toasts).toHaveLength(0)
})
})
describe('toast types', () => {
it('has convenience methods for all types', () => {
/**
* Test that all toast type helpers exist.
*
* The store should provide typed methods for common
* notification types.
*/
const ui = useUiStore()
expect(typeof ui.showSuccess).toBe('function')
expect(typeof ui.showError).toBe('function')
expect(typeof ui.showWarning).toBe('function')
expect(typeof ui.showInfo).toBe('function')
})
})
})

View File

@ -0,0 +1,86 @@
<script setup lang="ts">
/**
* Toast notification container.
*
* Displays toast notifications from the UI store in a stack at the
* bottom-right of the screen. Each toast auto-dismisses after its
* duration or can be manually dismissed by clicking.
*/
import { useUiStore, type Toast, type ToastType } from '@/stores/ui'
const ui = useUiStore()
function getToastClasses(type: ToastType): string {
const base = 'flex items-start gap-3 px-4 py-3 rounded-lg shadow-lg cursor-pointer transition-all hover:scale-[1.02]'
const typeClasses: Record<ToastType, string> = {
success: 'bg-success/90 text-white',
error: 'bg-error/90 text-white',
warning: 'bg-warning/90 text-gray-900',
info: 'bg-primary/90 text-white',
}
return `${base} ${typeClasses[type]}`
}
function getIcon(type: ToastType): string {
const icons: Record<ToastType, string> = {
success: '\u2713', // checkmark
error: '\u2717', // X
warning: '\u26A0', // warning triangle
info: '\u2139', // info circle
}
return icons[type]
}
function handleDismiss(toast: Toast): void {
ui.dismissToast(toast.id)
}
</script>
<template>
<Teleport to="body">
<div
class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none"
>
<TransitionGroup name="toast">
<div
v-for="toast in ui.toasts"
:key="toast.id"
:class="getToastClasses(toast.type)"
class="pointer-events-auto"
@click="handleDismiss(toast)"
>
<span class="text-lg shrink-0">{{ getIcon(toast.type) }}</span>
<p class="text-sm flex-1">
{{ toast.message }}
</p>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<style scoped>
.toast-enter-active {
transition: all 0.3s ease-out;
}
.toast-leave-active {
transition: all 0.2s ease-in;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
.toast-move {
transition: transform 0.3s ease;
}
</style>

View File

@ -0,0 +1,340 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { ref } from 'vue'
// Mock vue-router
const mockRouter = {
push: vi.fn(),
}
const mockRoute = ref({
query: {} as Record<string, string | undefined>,
})
vi.mock('vue-router', () => ({
useRouter: () => mockRouter,
useRoute: () => mockRoute.value,
}))
// Mock useProfile
const mockFetchProfile = vi.fn()
vi.mock('@/composables/useProfile', () => ({
useProfile: () => ({
fetchProfile: mockFetchProfile,
}),
}))
// Mock useUiStore
const mockShowSuccess = vi.fn()
const mockShowError = vi.fn()
vi.mock('@/stores/ui', () => ({
useUiStore: () => ({
showSuccess: mockShowSuccess,
showError: mockShowError,
}),
}))
import { useAccountLinking } from './useAccountLinking'
describe('useAccountLinking', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockRoute.value = { query: {} }
// Mock window.history.replaceState
vi.spyOn(window.history, 'replaceState').mockImplementation(() => {})
// Mock setTimeout to execute immediately
vi.useFakeTimers()
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('parseCallbackParams', () => {
it('parses success callback with provider', () => {
/**
* Test parsing of successful link callback.
*
* When the backend returns success=true and provider, we should
* parse this correctly to show the user appropriate feedback.
*/
mockRoute.value = {
query: { success: 'true', provider: 'google' },
}
const { parseCallbackParams } = useAccountLinking()
const result = parseCallbackParams()
expect(result.success).toBe(true)
expect(result.provider).toBe('google')
})
it('parses error callback with message', () => {
/**
* Test parsing of error callback.
*
* When linking fails, the backend returns error code and message.
* We should parse both for user feedback.
*/
mockRoute.value = {
query: { error: 'already_linked', message: 'Account already linked' },
}
const { parseCallbackParams } = useAccountLinking()
const result = parseCallbackParams()
expect(result.success).toBe(false)
expect(result.error).toBe('already_linked')
expect(result.message).toBe('Account already linked')
})
it('provides default error message for known error codes', () => {
/**
* Test default error messages.
*
* When the backend doesn't provide a message, we should have
* sensible defaults for known error codes.
*/
mockRoute.value = {
query: { error: 'already_linked' },
}
const { parseCallbackParams } = useAccountLinking()
const result = parseCallbackParams()
expect(result.success).toBe(false)
expect(result.message).toBe('This account is already linked to your profile.')
})
it('provides default message for account_in_use error', () => {
/**
* Test account_in_use error message.
*
* This error occurs when the OAuth account is linked to a different user.
*/
mockRoute.value = {
query: { error: 'account_in_use' },
}
const { parseCallbackParams } = useAccountLinking()
const result = parseCallbackParams()
expect(result.message).toBe('This account is already linked to a different user.')
})
it('handles invalid callback with no params', () => {
/**
* Test handling of direct navigation to callback URL.
*
* If user navigates directly to /auth/link/callback without params,
* we should show an appropriate error.
*/
mockRoute.value = { query: {} }
const { parseCallbackParams } = useAccountLinking()
const result = parseCallbackParams()
expect(result.success).toBe(false)
expect(result.error).toBe('invalid_callback')
})
})
describe('handleCallback', () => {
it('refreshes profile on successful link', async () => {
/**
* Test profile refresh after successful link.
*
* When an account is linked successfully, we need to refresh
* the profile to show the new linked account in the list.
*/
mockRoute.value = {
query: { success: 'true', provider: 'discord' },
}
mockFetchProfile.mockResolvedValue(true)
const { handleCallback } = useAccountLinking()
const result = await handleCallback()
expect(result.success).toBe(true)
expect(mockFetchProfile).toHaveBeenCalled()
})
it('shows success toast on successful link', async () => {
/**
* Test success toast display.
*
* Users should receive positive feedback when account linking succeeds.
*/
mockRoute.value = {
query: { success: 'true', provider: 'google' },
}
mockFetchProfile.mockResolvedValue(true)
const { handleCallback } = useAccountLinking()
await handleCallback()
expect(mockShowSuccess).toHaveBeenCalledWith('Google account linked successfully!')
})
it('shows error toast on failed link', async () => {
/**
* Test error toast display.
*
* Users should receive clear feedback when account linking fails.
*/
mockRoute.value = {
query: { error: 'oauth_failed', message: 'OAuth failed' },
}
const { handleCallback } = useAccountLinking()
await handleCallback()
expect(mockShowError).toHaveBeenCalledWith('OAuth failed')
})
it('does not refresh profile on failed link', async () => {
/**
* Test that profile is not refreshed on failure.
*
* There's no point refreshing the profile if the link failed -
* the linked accounts list hasn't changed.
*/
mockRoute.value = {
query: { error: 'already_linked' },
}
const { handleCallback } = useAccountLinking()
await handleCallback()
expect(mockFetchProfile).not.toHaveBeenCalled()
})
it('clears query params from URL', async () => {
/**
* Test URL cleanup.
*
* Query params should be cleared after processing to prevent
* them from appearing in browser history or being reprocessed.
*/
mockRoute.value = {
query: { success: 'true', provider: 'google' },
}
mockFetchProfile.mockResolvedValue(true)
const { handleCallback } = useAccountLinking()
await handleCallback()
expect(window.history.replaceState).toHaveBeenCalled()
})
it('redirects to profile after delay', async () => {
/**
* Test redirect to profile.
*
* After showing feedback, we should redirect the user back
* to their profile page.
*/
mockRoute.value = {
query: { success: 'true', provider: 'google' },
}
mockFetchProfile.mockResolvedValue(true)
const { handleCallback } = useAccountLinking()
await handleCallback()
// Fast-forward the setTimeout
vi.advanceTimersByTime(1500)
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Profile' })
})
it('sets isProcessing while handling callback', async () => {
/**
* Test loading state management.
*
* isProcessing should be true during async operations so the
* UI can show appropriate loading indicators.
*/
mockRoute.value = {
query: { success: 'true', provider: 'google' },
}
mockFetchProfile.mockResolvedValue(true)
const { handleCallback, isProcessing } = useAccountLinking()
// Start the callback but don't await
const promise = handleCallback()
// isProcessing should be true
expect(isProcessing.value).toBe(true)
await promise
// After completion, isProcessing should be false
expect(isProcessing.value).toBe(false)
})
it('returns callback result', async () => {
/**
* Test return value.
*
* handleCallback should return the parsed result so callers
* can take additional actions based on success/failure.
*/
mockRoute.value = {
query: { success: 'true', provider: 'discord' },
}
mockFetchProfile.mockResolvedValue(true)
const { handleCallback } = useAccountLinking()
const result = await handleCallback()
expect(result.success).toBe(true)
expect(result.provider).toBe('discord')
})
})
describe('goToProfile', () => {
it('navigates to profile page', () => {
/**
* Test manual navigation to profile.
*
* Provides a way for the UI to manually navigate to profile
* (e.g., when user clicks "Back to Profile" button).
*/
const { goToProfile } = useAccountLinking()
goToProfile()
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Profile' })
})
})
describe('callbackResult state', () => {
it('stores callback result after handling', async () => {
/**
* Test result state storage.
*
* The callback result should be stored in state so the UI
* can access it for display purposes.
*/
mockRoute.value = {
query: { success: 'true', provider: 'google' },
}
mockFetchProfile.mockResolvedValue(true)
const { handleCallback, callbackResult } = useAccountLinking()
expect(callbackResult.value).toBeNull()
await handleCallback()
expect(callbackResult.value).not.toBeNull()
expect(callbackResult.value?.success).toBe(true)
expect(callbackResult.value?.provider).toBe('google')
})
})
})

View File

@ -0,0 +1,174 @@
/**
* Account linking composable.
*
* Handles the OAuth account linking callback flow. When a user links an
* additional OAuth provider, they're redirected back to /auth/link/callback
* with success/error information in query params.
*
* This composable parses that information and provides methods to complete
* the linking process.
*/
import { ref, readonly } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useProfile } from '@/composables/useProfile'
import { useUiStore } from '@/stores/ui'
/** Result of parsing the link callback URL */
export interface LinkCallbackResult {
success: boolean
provider?: string
error?: string
message?: string
}
/**
* Account linking composable.
*
* Provides methods for handling the OAuth linking callback and completing
* the account linking process.
*
* @example
* ```vue
* <script setup lang="ts">
* import { useAccountLinking } from '@/composables/useAccountLinking'
*
* const { isProcessing, handleCallback } = useAccountLinking()
*
* onMounted(async () => {
* await handleCallback()
* })
* </script>
* ```
*/
export function useAccountLinking() {
const router = useRouter()
const route = useRoute()
const ui = useUiStore()
const { fetchProfile } = useProfile()
// State
const isProcessing = ref(false)
const callbackResult = ref<LinkCallbackResult | null>(null)
/**
* Parse the link callback result from URL query params.
*
* Success format: /auth/link/callback?success=true&provider=google
* Error format: /auth/link/callback?error=already_linked&message=Account%20already%20linked
*
* @returns Parsed callback result
*/
function parseCallbackParams(): LinkCallbackResult {
const success = route.query.success === 'true'
const provider = route.query.provider as string | undefined
const error = route.query.error as string | undefined
const message = route.query.message as string | undefined
if (success && provider) {
return {
success: true,
provider,
}
}
if (error) {
return {
success: false,
error,
message: message || getDefaultErrorMessage(error),
}
}
// No recognizable params - probably direct navigation
return {
success: false,
error: 'invalid_callback',
message: 'Invalid callback. Please try linking again from your profile.',
}
}
/**
* Get a user-friendly error message for known error codes.
*
* @param errorCode - Error code from the backend
* @returns User-friendly error message
*/
function getDefaultErrorMessage(errorCode: string): string {
switch (errorCode) {
case 'already_linked':
return 'This account is already linked to your profile.'
case 'account_in_use':
return 'This account is already linked to a different user.'
case 'oauth_failed':
return 'OAuth authentication failed. Please try again.'
case 'oauth_cancelled':
return 'Account linking was cancelled.'
case 'invalid_state':
return 'Invalid session state. Please try again.'
default:
return 'Failed to link account. Please try again.'
}
}
/**
* Handle the link callback.
*
* Parses the callback result, refreshes the profile if successful,
* shows appropriate feedback, and redirects to the profile page.
*
* @returns The callback result
*/
async function handleCallback(): Promise<LinkCallbackResult> {
isProcessing.value = true
try {
const result = parseCallbackParams()
callbackResult.value = result
// Clear query params from URL for cleaner history
window.history.replaceState(null, '', window.location.pathname)
if (result.success) {
// Refresh profile to get updated linked accounts
await fetchProfile()
// Show success toast
const providerName = result.provider === 'google' ? 'Google' : 'Discord'
ui.showSuccess(`${providerName} account linked successfully!`)
} else {
// Show error toast
ui.showError(result.message || 'Failed to link account')
}
// Redirect to profile after a brief delay for toast visibility
setTimeout(() => {
router.push({ name: 'Profile' })
}, 1500)
return result
} finally {
isProcessing.value = false
}
}
/**
* Navigate directly to profile (for error recovery).
*/
function goToProfile(): void {
router.push({ name: 'Profile' })
}
return {
// State
isProcessing: readonly(isProcessing),
callbackResult: readonly(callbackResult),
// Actions
handleCallback,
parseCallbackParams,
goToProfile,
}
}
export type UseAccountLinking = ReturnType<typeof useAccountLinking>

View File

@ -0,0 +1,955 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
// Create mock router instance
const mockRouter = {
push: vi.fn(),
currentRoute: { value: { name: 'AuthCallback', path: '/auth/callback' } },
}
// Mock vue-router (hoisted)
vi.mock('vue-router', () => ({
useRouter: () => mockRouter,
useRoute: () => ({ query: {} }),
}))
// Mock the API client
vi.mock('@/api/client', () => ({
apiClient: {
get: vi.fn(),
},
}))
// Mock the config
vi.mock('@/config', () => ({
config: {
apiBaseUrl: 'http://localhost:8000',
wsUrl: 'http://localhost:8000',
oauthRedirectUri: 'http://localhost:5173/auth/callback',
isDev: true,
isProd: false,
},
}))
import { apiClient } from '@/api/client'
import { useAuth } from './useAuth'
describe('useAuth', () => {
let mockLocation: { hash: string; pathname: string; search: string; href: string }
beforeEach(() => {
setActivePinia(createPinia())
// Reset mock router
mockRouter.push.mockReset()
// Mock window.location with a property descriptor that allows href assignment
mockLocation = {
hash: '',
pathname: '/auth/callback',
search: '',
href: 'http://localhost:5173/auth/callback',
}
// Delete and redefine to avoid conflicts
delete (window as unknown as Record<string, unknown>).location
Object.defineProperty(window, 'location', {
value: mockLocation,
writable: true,
configurable: true,
})
// Mock history.replaceState
vi.spyOn(window.history, 'replaceState').mockImplementation(() => {})
// Mock fetch for logout calls
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
})
// Reset mocks
vi.mocked(apiClient.get).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('initial state', () => {
it('starts with unauthenticated state', () => {
/**
* Test that useAuth starts in an unauthenticated state.
*
* Before OAuth flow or initialization completes, the composable
* should report that the user is not authenticated.
*/
const { isAuthenticated, isInitialized, user, error } = useAuth()
expect(isAuthenticated.value).toBe(false)
expect(isInitialized.value).toBe(false)
expect(user.value).toBeNull()
expect(error.value).toBeNull()
})
it('starts with isLoading as false', () => {
/**
* Test initial loading state.
*
* The composable should not be in a loading state until
* an async operation is initiated.
*/
const { isLoading } = useAuth()
expect(isLoading.value).toBe(false)
})
})
describe('initiateOAuth', () => {
it('redirects to Google OAuth URL with redirect_uri', () => {
/**
* Test Google OAuth initiation.
*
* When initiating Google OAuth, the browser should be redirected
* to the backend's Google OAuth endpoint with a redirect_uri param,
* which will then redirect to Google's consent screen.
*/
const { initiateOAuth } = useAuth()
initiateOAuth('google')
expect(mockLocation.href).toContain('/api/auth/google')
expect(mockLocation.href).toContain('redirect_uri=')
})
it('redirects to Discord OAuth URL with redirect_uri', () => {
/**
* Test Discord OAuth initiation.
*
* When initiating Discord OAuth, the browser should be redirected
* to the backend's Discord OAuth endpoint with a redirect_uri param,
* which will then redirect to Discord's consent screen.
*/
const { initiateOAuth } = useAuth()
initiateOAuth('discord')
expect(mockLocation.href).toContain('/api/auth/discord')
expect(mockLocation.href).toContain('redirect_uri=')
})
it('clears any existing error', () => {
/**
* Test error clearing on OAuth initiation.
*
* Starting a new OAuth flow should clear any previous errors
* so users don't see stale error messages.
*/
const auth = useAuth()
auth.initiateOAuth('google')
expect(auth.error.value).toBeNull()
})
})
describe('handleCallback', () => {
it('successfully extracts tokens from URL hash', async () => {
/**
* Test token extraction from OAuth callback.
*
* The OAuth provider redirects back with tokens in the URL fragment.
* handleCallback must parse these tokens and store them correctly.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(true)
expect(result.user?.id).toBe('user-1')
expect(result.user?.displayName).toBe('Test User')
})
it('stores tokens in auth store', async () => {
/**
* Test that tokens are persisted in the auth store.
*
* After extracting tokens, they must be stored in the auth store
* so they can be used for subsequent API requests.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
const { handleCallback } = useAuth()
await handleCallback()
const authStore = useAuthStore()
expect(authStore.accessToken).toBe('abc123')
expect(authStore.refreshToken).toBe('xyz789')
expect(authStore.expiresAt).toBeGreaterThan(Date.now())
})
it('fetches user profile after storing tokens', async () => {
/**
* Test profile fetch after token storage.
*
* Once tokens are stored, we need to fetch the user's profile
* to know their display name, avatar, and starter deck status.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: 'https://example.com/avatar.png',
has_starter_deck: true,
})
const { handleCallback } = useAuth()
await handleCallback()
expect(apiClient.get).toHaveBeenCalledWith('/api/users/me')
const authStore = useAuthStore()
expect(authStore.user).toEqual({
id: 'user-1',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.png',
hasStarterDeck: true,
})
})
it('returns needsStarter=true for users without starter deck', async () => {
/**
* Test starter deck status in callback result.
*
* The callback result should indicate if the user needs to select
* a starter deck, allowing the caller to redirect appropriately.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'New User',
avatar_url: null,
has_starter_deck: false,
})
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(true)
expect(result.needsStarter).toBe(true)
})
it('returns needsStarter=false for users with starter deck', async () => {
/**
* Test starter deck status for existing users.
*
* Users who already have a starter deck should have needsStarter=false,
* allowing them to proceed directly to the dashboard.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Existing User',
avatar_url: null,
has_starter_deck: true,
})
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(true)
expect(result.needsStarter).toBe(false)
})
it('returns error for missing tokens in hash', async () => {
/**
* Test error handling for malformed OAuth response.
*
* If the URL fragment is missing required tokens, handleCallback
* should return an error result, not throw an exception.
*/
mockLocation.hash = '#access_token=abc123' // Missing refresh_token and expires_in
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(false)
expect(result.error).toContain('Missing tokens')
})
it('returns error for empty hash', async () => {
/**
* Test error handling for empty hash fragment.
*
* If the OAuth provider redirects without any tokens,
* handleCallback should return an appropriate error.
*/
mockLocation.hash = ''
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
})
it('returns error from OAuth provider query params', async () => {
/**
* Test error forwarding from OAuth provider.
*
* When OAuth fails, the backend redirects with error info
* in query params. handleCallback should extract and return this.
*/
mockLocation.hash = ''
mockLocation.search = '?error=access_denied&message=User%20cancelled'
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(false)
expect(result.error).toBe('User cancelled')
})
it('uses default error message when message param is missing', async () => {
/**
* Test fallback error message.
*
* If the backend only provides an error code without a message,
* we should use a sensible default.
*/
mockLocation.hash = ''
mockLocation.search = '?error=unknown_error'
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(false)
expect(result.error).toBe('Authentication failed. Please try again.')
})
it('returns error when profile fetch fails', async () => {
/**
* Test error handling for profile fetch failures.
*
* If we successfully get tokens but fail to fetch the profile,
* handleCallback should return an error result.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'))
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(false)
expect(result.error).toBe('Network error')
})
it('clears tokens from URL after parsing', async () => {
/**
* Test security cleanup of URL.
*
* Tokens in the URL should be cleared after parsing to prevent
* them from appearing in browser history or being logged.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
const { handleCallback } = useAuth()
await handleCallback()
expect(window.history.replaceState).toHaveBeenCalledWith(
null,
'',
'/auth/callback'
)
})
})
describe('logout', () => {
it('calls auth store logout with server revocation', async () => {
/**
* Test logout with server-side token revocation.
*
* When logging out, we should revoke the refresh token on the
* server to prevent it from being used if somehow compromised.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
const logoutSpy = vi.spyOn(authStore, 'logout')
const { logout } = useAuth()
await logout()
expect(logoutSpy).toHaveBeenCalledWith(true)
})
it('clears auth state after logout', async () => {
/**
* Test state clearing after logout.
*
* All authentication state should be cleared so the user
* is properly logged out and cannot access protected resources.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
authStore.setUser({
id: 'user-1',
displayName: 'Test User',
avatarUrl: null,
hasStarterDeck: true,
})
const { logout, isAuthenticated, user } = useAuth()
await logout(false) // Don't redirect in test
expect(isAuthenticated.value).toBe(false)
expect(user.value).toBeNull()
})
it('redirects to login by default', async () => {
/**
* Test default redirect behavior after logout.
*
* After logging out, users should be redirected to the login page
* by default so they can log in again if desired.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
const { logout } = useAuth()
await logout()
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Login' })
})
it('skips redirect when redirectToLogin is false', async () => {
/**
* Test optional redirect suppression.
*
* Sometimes we want to log out without redirecting (e.g., when
* the user is already on the login page or when handling errors).
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
const { logout } = useAuth()
await logout(false)
expect(mockRouter.push).not.toHaveBeenCalled()
})
})
describe('logoutAll', () => {
it('calls logout-all endpoint', async () => {
/**
* Test all-device logout API call.
*
* logoutAll should call the special endpoint that revokes
* all refresh tokens for the user, logging them out everywhere.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
const { logoutAll } = useAuth()
await logoutAll(false)
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:8000/api/auth/logout-all',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Authorization': 'Bearer test-token',
}),
})
)
})
it('clears local state even if server call fails', async () => {
/**
* Test graceful handling of server errors.
*
* Even if the server call to revoke all tokens fails, we should
* still clear local state so the user is logged out locally.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
const { logoutAll, isAuthenticated } = useAuth()
await logoutAll(false)
expect(isAuthenticated.value).toBe(false)
})
it('redirects to login by default', async () => {
/**
* Test default redirect after all-device logout.
*
* After logging out from all devices, users should be
* redirected to the login page.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
const { logoutAll } = useAuth()
await logoutAll()
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Login' })
})
})
describe('initialize', () => {
it('sets isInitialized to true after completion', async () => {
/**
* Test initialization completion flag.
*
* After initialize() completes, isInitialized should be true
* so navigation guards know it's safe to check auth state.
*/
const { initialize, isInitialized } = useAuth()
expect(isInitialized.value).toBe(false)
await initialize()
expect(isInitialized.value).toBe(true)
})
it('returns true when authenticated with valid tokens', async () => {
/**
* Test initialization with existing valid session.
*
* If tokens are already stored and valid, initialization should
* return true and keep the user logged in.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
authStore.setUser({
id: 'user-1',
displayName: 'Test User',
avatarUrl: null,
hasStarterDeck: true,
})
const { initialize } = useAuth()
const result = await initialize()
expect(result).toBe(true)
})
it('returns false when not authenticated', async () => {
/**
* Test initialization without existing session.
*
* If no tokens are stored, initialization should return false
* indicating the user needs to log in.
*/
const { initialize } = useAuth()
const result = await initialize()
expect(result).toBe(false)
})
it('fetches profile if tokens exist but user is null', async () => {
/**
* Test profile fetch during initialization.
*
* If we have tokens persisted but no user data (e.g., after
* page reload), we should fetch the profile during init.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
// Note: user is NOT set
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
const { initialize } = useAuth()
await initialize()
expect(apiClient.get).toHaveBeenCalledWith('/api/users/me')
expect(authStore.user).toEqual({
id: 'user-1',
displayName: 'Test User',
avatarUrl: null,
hasStarterDeck: true,
})
})
it('logs out if profile fetch fails during initialization', async () => {
/**
* Test handling of invalid tokens during initialization.
*
* If tokens exist but profile fetch fails (invalid tokens, etc.),
* we should clear the invalid tokens and return false.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
vi.mocked(apiClient.get).mockRejectedValue(new Error('Unauthorized'))
const { initialize, isAuthenticated } = useAuth()
const result = await initialize()
expect(result).toBe(false)
expect(isAuthenticated.value).toBe(false)
})
it('only runs once (returns cached result on second call)', async () => {
/**
* Test initialization idempotency.
*
* Multiple calls to initialize() should only run the
* initialization logic once. Subsequent calls should
* return the same result immediately.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
authStore.setUser({
id: 'user-1',
displayName: 'Test User',
avatarUrl: null,
hasStarterDeck: true,
})
const { initialize, isInitialized } = useAuth()
await initialize()
expect(isInitialized.value).toBe(true)
// Call again - should return immediately
const result = await initialize()
expect(result).toBe(true)
})
})
describe('fetchProfile', () => {
it('fetches and updates user profile', async () => {
/**
* Test manual profile refresh.
*
* Components may need to refresh the user profile (e.g., after
* updating display name). fetchProfile should update the store.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Updated Name',
avatar_url: 'https://example.com/new-avatar.png',
has_starter_deck: true,
})
const { fetchProfile } = useAuth()
const result = await fetchProfile()
expect(result.displayName).toBe('Updated Name')
expect(authStore.user?.displayName).toBe('Updated Name')
})
it('throws error when not authenticated', async () => {
/**
* Test unauthenticated profile fetch rejection.
*
* fetchProfile should throw an error if called when not
* authenticated, rather than making a doomed API call.
*/
const { fetchProfile } = useAuth()
await expect(fetchProfile()).rejects.toThrow('Not authenticated')
})
})
describe('clearError', () => {
it('clears the error state', async () => {
/**
* Test error clearing.
*
* After displaying an error, components may want to clear it
* (e.g., when user dismisses the error or tries again).
*/
// First cause an error
mockLocation.hash = ''
const { handleCallback, clearError, error } = useAuth()
await handleCallback()
// Error should be set
expect(error.value).toBeDefined()
// Clear it
clearError()
expect(error.value).toBeNull()
})
})
describe('loading states', () => {
it('isLoading is true during handleCallback', async () => {
/**
* Test loading state during callback processing.
*
* Components should show loading indicators while the callback
* is being processed (token storage, profile fetch).
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
// Create a deferred promise to control when the API responds
let resolveApi: (value: unknown) => void
vi.mocked(apiClient.get).mockImplementation(
() => new Promise((resolve) => { resolveApi = resolve })
)
const { handleCallback, isLoading } = useAuth()
// Start callback (don't await)
const promise = handleCallback()
// Should be loading
expect(isLoading.value).toBe(true)
// Resolve the API call
resolveApi!({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
await promise
// Should no longer be loading
expect(isLoading.value).toBe(false)
})
it('isLoading is true during logout', async () => {
/**
* Test loading state during logout.
*
* Components should disable logout buttons and show feedback
* while the logout operation is in progress.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
// Create a deferred promise
let resolveFetch: () => void
global.fetch = vi.fn().mockImplementation(
() => new Promise((resolve) => {
resolveFetch = () => resolve({ ok: true })
})
)
const { logout, isLoading } = useAuth()
// Start logout (don't await)
const promise = logout(false)
// Should be loading
expect(isLoading.value).toBe(true)
// Resolve the fetch
resolveFetch!()
await promise
// Should no longer be loading
expect(isLoading.value).toBe(false)
})
it('isLoading is true during initialize', async () => {
/**
* Test loading state during initialization.
*
* The app should show a loading screen while auth state
* is being initialized on startup.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
// Note: NOT setting user, so init will need to fetch profile
// Create a deferred promise that we control
let resolveApi: (value: unknown) => void = () => {}
const apiPromise = new Promise((resolve) => {
resolveApi = resolve
})
vi.mocked(apiClient.get).mockReturnValue(apiPromise as Promise<unknown>)
const { initialize, isLoading } = useAuth()
// Start init (don't await)
const promise = initialize()
// Wait a tick for async operations to start
await new Promise((r) => setTimeout(r, 0))
// Should be loading (needs to fetch profile)
expect(isLoading.value).toBe(true)
// Resolve the API call
resolveApi({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
await promise
// Should no longer be loading
expect(isLoading.value).toBe(false)
})
})
describe('computed properties', () => {
it('isAuthenticated reflects auth store state', () => {
/**
* Test isAuthenticated reactivity.
*
* The isAuthenticated computed should automatically update
* when the auth store's authentication state changes.
*/
const authStore = useAuthStore()
const { isAuthenticated } = useAuth()
expect(isAuthenticated.value).toBe(false)
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
expect(isAuthenticated.value).toBe(true)
})
it('user reflects auth store state', () => {
/**
* Test user computed reactivity.
*
* The user computed should automatically update when the
* auth store's user data changes.
*/
const authStore = useAuthStore()
const { user } = useAuth()
expect(user.value).toBeNull()
authStore.setUser({
id: 'user-1',
displayName: 'Test User',
avatarUrl: null,
hasStarterDeck: true,
})
expect(user.value?.displayName).toBe('Test User')
})
it('error combines local and store errors', () => {
/**
* Test error state composition.
*
* The error computed should show local errors (from composable
* operations) or store errors (from token refresh, etc.).
*/
const authStore = useAuthStore()
const { error } = useAuth()
expect(error.value).toBeNull()
// Store error should be reflected
authStore.error = 'Store error'
expect(error.value).toBe('Store error')
})
})
})

View File

@ -0,0 +1,375 @@
/**
* Authentication composable for OAuth-based login flow.
*
* Provides a higher-level API on top of the auth store, with:
* - Loading and error state management
* - OAuth flow helpers (initiate, callback)
* - Logout with navigation
* - App initialization with token validation
*
* Use this composable in components instead of accessing the auth store directly
* for operations that involve async operations or navigation.
*/
import { ref, computed, readonly } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import type { User } from '@/stores/auth'
import { apiClient } from '@/api/client'
import { config } from '@/config'
/** OAuth provider types supported by the backend */
export type OAuthProvider = 'google' | 'discord'
/** User profile response from the backend API */
interface UserProfileResponse {
id: string
display_name: string
avatar_url: string | null
has_starter_deck: boolean
}
/** Parsed tokens from OAuth callback URL */
interface ParsedTokens {
accessToken: string
refreshToken: string
expiresIn: number
}
/** Result of handleCallback operation */
export interface CallbackResult {
success: boolean
user?: User
error?: string
needsStarter?: boolean
}
/**
* Parse tokens from URL hash fragment.
*
* The backend redirects with tokens in the fragment (after #) for security,
* since fragments are not sent to servers in HTTP requests.
*/
function parseTokensFromHash(): ParsedTokens | null {
const hash = window.location.hash.substring(1)
if (!hash) return null
const params = new URLSearchParams(hash)
const accessToken = params.get('access_token')
const refreshToken = params.get('refresh_token')
const expiresIn = params.get('expires_in')
if (!accessToken || !refreshToken || !expiresIn) {
return null
}
return {
accessToken,
refreshToken,
expiresIn: parseInt(expiresIn, 10),
}
}
/**
* Parse error from URL query params.
*
* When OAuth fails, the backend redirects with error info in query params.
*/
function parseErrorFromQuery(): { error: string; message: string } | null {
const params = new URLSearchParams(window.location.search)
const error = params.get('error')
const message = params.get('message')
if (error) {
return {
error,
message: message || 'Authentication failed. Please try again.',
}
}
return null
}
/**
* Transform API user profile to auth store User type.
*/
function transformUserProfile(response: UserProfileResponse): User {
return {
id: response.id,
displayName: response.display_name,
avatarUrl: response.avatar_url,
hasStarterDeck: response.has_starter_deck,
}
}
/**
* Authentication composable.
*
* Wraps the auth store with loading/error state management and provides
* higher-level methods for OAuth flows.
*
* @example
* ```vue
* <script setup lang="ts">
* import { useAuth } from '@/composables/useAuth'
*
* const { isAuthenticated, isLoading, initiateOAuth, logout } = useAuth()
*
* function handleGoogleLogin() {
* initiateOAuth('google')
* }
* </script>
* ```
*/
export function useAuth() {
const router = useRouter()
const authStore = useAuthStore()
// Local state for this composable instance
const isInitializing = ref(false)
const isInitialized = ref(false)
const isProcessingCallback = ref(false)
const isLoggingOut = ref(false)
const localError = ref<string | null>(null)
// Computed properties from store
const isAuthenticated = computed(() => authStore.isAuthenticated)
const user = computed(() => authStore.user)
const isLoading = computed(
() => authStore.isLoading || isInitializing.value || isProcessingCallback.value || isLoggingOut.value
)
const error = computed(() => localError.value || authStore.error)
/**
* Initiate OAuth login flow.
*
* Redirects the user to the backend OAuth endpoint, which then redirects
* to the OAuth provider (Google/Discord). After authentication, the user
* is redirected back to /auth/callback with tokens.
*
* @param provider - OAuth provider to use ('google' or 'discord')
*/
function initiateOAuth(provider: OAuthProvider): void {
localError.value = null
const url = authStore.getOAuthUrl(provider)
window.location.href = url
}
/**
* Handle OAuth callback.
*
* Called from AuthCallbackPage after being redirected from OAuth provider.
* Extracts tokens from URL, fetches user profile, and determines redirect.
*
* @returns Result indicating success/failure and redirect info
*/
async function handleCallback(): Promise<CallbackResult> {
isProcessingCallback.value = true
localError.value = null
try {
// Check for OAuth errors first
const errorInfo = parseErrorFromQuery()
if (errorInfo) {
return {
success: false,
error: errorInfo.message,
}
}
// Parse tokens from URL fragment
const tokens = parseTokensFromHash()
if (!tokens) {
return {
success: false,
error: 'Invalid authentication response. Missing tokens.',
}
}
// Store tokens in auth store
authStore.setTokens({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: Date.now() + tokens.expiresIn * 1000,
})
// Clear tokens from URL for security
window.history.replaceState(null, '', window.location.pathname)
// Fetch user profile
const profileResponse = await apiClient.get<UserProfileResponse>('/api/users/me')
const user = transformUserProfile(profileResponse)
authStore.setUser(user)
return {
success: true,
user,
needsStarter: !user.hasStarterDeck,
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to complete authentication.'
localError.value = errorMessage
return {
success: false,
error: errorMessage,
}
} finally {
isProcessingCallback.value = false
}
}
/**
* Log out the current user.
*
* Revokes the refresh token on the server, clears local state,
* and redirects to the login page.
*
* @param redirectToLogin - Whether to redirect to login page (default: true)
*/
async function logout(redirectToLogin = true): Promise<void> {
isLoggingOut.value = true
localError.value = null
try {
await authStore.logout(true)
if (redirectToLogin) {
router.push({ name: 'Login' })
}
} finally {
isLoggingOut.value = false
}
}
/**
* Log out from all devices.
*
* Revokes all refresh tokens for the user, effectively logging them out
* from all sessions. This requires an additional API call.
*
* @param redirectToLogin - Whether to redirect to login page (default: true)
*/
async function logoutAll(redirectToLogin = true): Promise<void> {
isLoggingOut.value = true
localError.value = null
try {
// Call the logout-all endpoint to revoke all tokens
const token = await authStore.getValidToken()
if (token) {
await fetch(`${config.apiBaseUrl}/api/auth/logout-all`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
}
// Clear local state without making another server call
await authStore.logout(false)
if (redirectToLogin) {
router.push({ name: 'Login' })
}
} catch (e) {
// Log the error for debugging, but still proceed with local logout
console.warn('[useAuth] logoutAll server call failed:', e)
// Even if server call fails, clear local state
await authStore.logout(false)
if (redirectToLogin) {
router.push({ name: 'Login' })
}
} finally {
isLoggingOut.value = false
}
}
/**
* Initialize authentication state.
*
* Should be called once on app startup. Validates existing tokens
* and fetches user profile if authenticated.
*
* @returns Whether initialization succeeded with a valid session
*/
async function initialize(): Promise<boolean> {
if (isInitialized.value) {
return authStore.isAuthenticated
}
isInitializing.value = true
localError.value = null
try {
// First, let the store validate/refresh tokens
await authStore.init()
// If we have tokens but no user data, fetch the profile
if (authStore.isAuthenticated && !authStore.user) {
try {
const profileResponse = await apiClient.get<UserProfileResponse>('/api/users/me')
const user = transformUserProfile(profileResponse)
authStore.setUser(user)
} catch {
// Failed to fetch profile - tokens may be invalid
await authStore.logout(false)
return false
}
}
return authStore.isAuthenticated
} finally {
isInitializing.value = false
isInitialized.value = true
}
}
/**
* Fetch the current user's profile from the API.
*
* Updates the user in the auth store with fresh data.
*
* @returns The updated user profile
* @throws Error if not authenticated or fetch fails
*/
async function fetchProfile(): Promise<User> {
if (!authStore.isAuthenticated) {
throw new Error('Not authenticated')
}
const profileResponse = await apiClient.get<UserProfileResponse>('/api/users/me')
const user = transformUserProfile(profileResponse)
authStore.setUser(user)
return user
}
/**
* Clear any error state.
*/
function clearError(): void {
localError.value = null
}
return {
// State (readonly to prevent external mutation)
isAuthenticated,
isInitialized: readonly(isInitialized),
isLoading,
error,
user,
// Actions
initiateOAuth,
handleCallback,
logout,
logoutAll,
initialize,
fetchProfile,
clearError,
}
}
export type UseAuth = ReturnType<typeof useAuth>

View File

@ -0,0 +1,476 @@
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCollection } from './useCollection'
import type { CollectionApiResponse, CollectionEntryResponse } from './useCollection'
import { apiClient } from '@/api/client'
// Mock the API client
vi.mock('@/api/client', () => ({
apiClient: {
get: vi.fn(),
},
}))
// Helper to create mock API collection entries
function createMockApiEntry(
cardId: string,
quantity: number,
source = 'starter_deck'
): CollectionEntryResponse {
return {
card_definition_id: cardId,
quantity,
source,
obtained_at: new Date().toISOString(),
}
}
// Helper to create a full mock API response
function createMockApiResponse(
entries: CollectionEntryResponse[]
): CollectionApiResponse {
const totalCards = entries.reduce((sum, e) => sum + e.quantity, 0)
return {
total_unique_cards: entries.length,
total_card_count: totalCards,
entries,
}
}
describe('useCollection', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('initial state', () => {
it('starts with empty collection', () => {
/**
* Test that the composable initializes with empty state.
*
* Before fetching, the collection should be empty and not loading.
* This ensures a clean starting state for new sessions.
*/
const { cards, totalCards, uniqueCards, isLoading, error } = useCollection()
expect(cards.value).toEqual([])
expect(totalCards.value).toBe(0)
expect(uniqueCards.value).toBe(0)
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
})
})
describe('fetchCollection', () => {
it('fetches and transforms collection from API', async () => {
/**
* Test that fetchCollection retrieves data and transforms it correctly.
*
* The API returns snake_case fields which must be transformed to
* camelCase for the frontend. Card names are also extracted from IDs.
*/
const mockResponse = createMockApiResponse([
createMockApiEntry('a1-025-pikachu', 3),
createMockApiEntry('a1-004-charmander', 2),
])
;(apiClient.get as Mock).mockResolvedValue(mockResponse)
const { cards, totalCards, uniqueCards, fetchCollection } = useCollection()
const result = await fetchCollection()
expect(result.success).toBe(true)
expect(cards.value).toHaveLength(2)
expect(totalCards.value).toBe(5) // 3 + 2
expect(uniqueCards.value).toBe(2)
// Check transformation
expect(cards.value[0].cardDefinitionId).toBe('a1-025-pikachu')
expect(cards.value[0].quantity).toBe(3)
expect(cards.value[0].card.name).toBe('Pikachu')
expect(cards.value[0].card.setId).toBe('a1')
expect(cards.value[0].card.setNumber).toBe(25)
})
it('handles empty collection gracefully', async () => {
/**
* Test that an empty collection response is handled properly.
*
* New users who haven't selected a starter yet will have no cards.
* The composable should handle this without errors.
*/
const mockResponse = createMockApiResponse([])
;(apiClient.get as Mock).mockResolvedValue(mockResponse)
const { cards, totalCards, fetchCollection } = useCollection()
const result = await fetchCollection()
expect(result.success).toBe(true)
expect(cards.value).toEqual([])
expect(totalCards.value).toBe(0)
})
it('sets loading state during fetch', async () => {
/**
* Test that loading state is properly managed during fetch.
*
* Components use isLoading to show skeleton loaders while
* the collection is being fetched.
*/
let resolvePromise: (value: CollectionApiResponse) => void
const pendingPromise = new Promise<CollectionApiResponse>(resolve => {
resolvePromise = resolve
})
;(apiClient.get as Mock).mockReturnValue(pendingPromise)
const { isLoading, isFetching, fetchCollection } = useCollection()
expect(isLoading.value).toBe(false)
expect(isFetching.value).toBe(false)
const fetchPromise = fetchCollection()
expect(isLoading.value).toBe(true)
expect(isFetching.value).toBe(true)
resolvePromise!(createMockApiResponse([]))
await fetchPromise
expect(isLoading.value).toBe(false)
expect(isFetching.value).toBe(false)
})
it('handles API errors with user-friendly message', async () => {
/**
* Test that API errors are caught and exposed properly.
*
* Network failures should result in a user-friendly error message
* that can be displayed in the UI.
*/
;(apiClient.get as Mock).mockRejectedValue(new Error('Network error'))
const { error, fetchCollection } = useCollection()
const result = await fetchCollection()
expect(result.success).toBe(false)
expect(result.error).toBe('Network error')
expect(error.value).toBe('Network error')
})
it('handles non-Error exceptions', async () => {
/**
* Test that non-Error exceptions are handled gracefully.
*
* Some APIs throw strings or other types instead of Error objects.
* The composable should provide a fallback message.
*/
;(apiClient.get as Mock).mockRejectedValue('Something went wrong')
const { error, fetchCollection } = useCollection()
const result = await fetchCollection()
expect(result.success).toBe(false)
expect(result.error).toBe('Failed to fetch collection')
expect(error.value).toBe('Failed to fetch collection')
})
it('prevents concurrent fetches', async () => {
/**
* Test that multiple simultaneous fetches are prevented.
*
* Rapid user actions shouldn't trigger multiple API calls.
* Subsequent calls while one is in progress should be rejected.
*/
let resolvePromise: (value: CollectionApiResponse) => void
const pendingPromise = new Promise<CollectionApiResponse>(resolve => {
resolvePromise = resolve
})
;(apiClient.get as Mock).mockReturnValue(pendingPromise)
const { fetchCollection } = useCollection()
const promise1 = fetchCollection()
const promise2 = fetchCollection() // Should be rejected
const result2 = await promise2
expect(result2.success).toBe(false)
expect(result2.error).toBe('Fetch already in progress')
// API should only be called once
expect(apiClient.get).toHaveBeenCalledTimes(1)
// Clean up
resolvePromise!(createMockApiResponse([]))
await promise1
})
})
describe('filterByType', () => {
it('filters cards by Pokemon type', async () => {
/**
* Test filtering by Pokemon type (fire, water, etc.).
*
* The deck builder and collection page need to filter by type
* so users can find cards for specific deck strategies.
*/
const mockResponse = createMockApiResponse([
createMockApiEntry('a1-025-pikachu', 3),
createMockApiEntry('a1-004-charmander', 2),
])
;(apiClient.get as Mock).mockResolvedValue(mockResponse)
const { cards, fetchCollection, filterByType } = useCollection()
await fetchCollection()
// Manually set types since placeholders default to undefined
cards.value[0].card.type = 'lightning'
cards.value[1].card.type = 'fire'
const fireCards = filterByType('fire')
expect(fireCards).toHaveLength(1)
expect(fireCards[0].card.name).toBe('Charmander')
const lightningCards = filterByType('lightning')
expect(lightningCards).toHaveLength(1)
expect(lightningCards[0].card.name).toBe('Pikachu')
const waterCards = filterByType('water')
expect(waterCards).toHaveLength(0)
})
})
describe('filterByCategory', () => {
it('filters cards by category', async () => {
/**
* Test filtering by card category (pokemon, trainer, energy).
*
* Users need to view their Pokemon separately from Trainers
* and Energy cards when building decks.
*/
const mockResponse = createMockApiResponse([
createMockApiEntry('a1-025-pikachu', 3),
createMockApiEntry('a1-201-professor-oak', 2),
])
;(apiClient.get as Mock).mockResolvedValue(mockResponse)
const { cards, fetchCollection, filterByCategory } = useCollection()
await fetchCollection()
// Set categories
cards.value[0].card.category = 'pokemon'
cards.value[1].card.category = 'trainer'
const pokemon = filterByCategory('pokemon')
expect(pokemon).toHaveLength(1)
expect(pokemon[0].card.name).toBe('Pikachu')
const trainers = filterByCategory('trainer')
expect(trainers).toHaveLength(1)
expect(trainers[0].card.name).toBe('Professor Oak')
})
})
describe('filterByRarity', () => {
it('filters cards by rarity', async () => {
/**
* Test filtering by card rarity.
*
* Players often want to see their rare cards or build decks
* with specific rarity distributions.
*/
const mockResponse = createMockApiResponse([
createMockApiEntry('a1-025-pikachu', 3),
createMockApiEntry('a1-150-mewtwo-ex', 1),
])
;(apiClient.get as Mock).mockResolvedValue(mockResponse)
const { cards, fetchCollection, filterByRarity } = useCollection()
await fetchCollection()
// Set rarities
cards.value[0].card.rarity = 'common'
cards.value[1].card.rarity = 'ultra'
const commons = filterByRarity('common')
expect(commons).toHaveLength(1)
const ultras = filterByRarity('ultra')
expect(ultras).toHaveLength(1)
expect(ultras[0].card.name).toBe('Mewtwo EX')
})
})
describe('searchByName', () => {
it('searches cards by name (case-insensitive)', async () => {
/**
* Test name search with case-insensitive matching.
*
* Users should be able to search "pika" and find "Pikachu"
* regardless of case.
*/
const mockResponse = createMockApiResponse([
createMockApiEntry('a1-025-pikachu', 3),
createMockApiEntry('a1-004-charmander', 2),
createMockApiEntry('a1-026-raichu', 1),
])
;(apiClient.get as Mock).mockResolvedValue(mockResponse)
const { fetchCollection, searchByName } = useCollection()
await fetchCollection()
// Search for 'chu' should find Pikachu and Raichu
const chuResults = searchByName('chu')
expect(chuResults).toHaveLength(2)
// Case insensitive
const pikaResults = searchByName('PIKA')
expect(pikaResults).toHaveLength(1)
expect(pikaResults[0].card.name).toBe('Pikachu')
})
it('returns all cards for empty search query', async () => {
/**
* Test that empty search returns all cards.
*
* Clearing the search box should show the full collection again.
*/
const mockResponse = createMockApiResponse([
createMockApiEntry('a1-025-pikachu', 3),
createMockApiEntry('a1-004-charmander', 2),
])
;(apiClient.get as Mock).mockResolvedValue(mockResponse)
const { fetchCollection, searchByName } = useCollection()
await fetchCollection()
expect(searchByName('')).toHaveLength(2)
expect(searchByName(' ')).toHaveLength(2)
})
})
describe('lookup helpers', () => {
it('getQuantity returns quantity for owned card', async () => {
/**
* Test looking up quantity of a specific card.
*
* The deck builder needs to know how many copies are available
* to enforce card limits.
*/
const mockResponse = createMockApiResponse([
createMockApiEntry('a1-025-pikachu', 3),
])
;(apiClient.get as Mock).mockResolvedValue(mockResponse)
const { fetchCollection, getQuantity } = useCollection()
await fetchCollection()
expect(getQuantity('a1-025-pikachu')).toBe(3)
})
it('getQuantity returns 0 for unowned card', async () => {
/**
* Test that unowned cards return 0 quantity.
*
* This allows the deck builder to check ownership without
* handling undefined values.
*/
const mockResponse = createMockApiResponse([
createMockApiEntry('a1-025-pikachu', 3),
])
;(apiClient.get as Mock).mockResolvedValue(mockResponse)
const { fetchCollection, getQuantity } = useCollection()
await fetchCollection()
expect(getQuantity('a1-999-nonexistent')).toBe(0)
})
it('hasCard returns true for owned cards', async () => {
/**
* Test boolean ownership check.
*
* A convenient helper for conditional rendering of
* "You own this card" badges.
*/
const mockResponse = createMockApiResponse([
createMockApiEntry('a1-025-pikachu', 3),
])
;(apiClient.get as Mock).mockResolvedValue(mockResponse)
const { fetchCollection, hasCard } = useCollection()
await fetchCollection()
expect(hasCard('a1-025-pikachu')).toBe(true)
expect(hasCard('a1-999-nonexistent')).toBe(false)
})
})
describe('clear and error handling', () => {
it('clear resets collection state', async () => {
/**
* Test that clear resets all state.
*
* Called when user logs out to ensure the next user
* doesn't see the previous user's collection.
*/
const mockResponse = createMockApiResponse([
createMockApiEntry('a1-025-pikachu', 3),
])
;(apiClient.get as Mock).mockResolvedValue(mockResponse)
const { cards, fetchCollection, clear } = useCollection()
await fetchCollection()
expect(cards.value).toHaveLength(1)
clear()
expect(cards.value).toEqual([])
})
it('clearError clears error state', async () => {
/**
* Test that errors can be dismissed.
*
* After showing an error toast, the user should be able
* to dismiss it and try again.
*/
;(apiClient.get as Mock).mockRejectedValue(new Error('Network error'))
const { error, fetchCollection, clearError } = useCollection()
await fetchCollection()
expect(error.value).toBe('Network error')
clearError()
expect(error.value).toBeNull()
})
})
describe('card name formatting', () => {
it('formats card names from ID slugs', async () => {
/**
* Test that card names are properly formatted from IDs.
*
* IDs like "a1-025-pikachu-ex" should become "Pikachu EX"
* for display purposes.
*/
const mockResponse = createMockApiResponse([
createMockApiEntry('a1-150-mewtwo-ex', 1),
createMockApiEntry('a1-001-bulbasaur', 2),
createMockApiEntry('a1-100-mr-mime', 1),
])
;(apiClient.get as Mock).mockResolvedValue(mockResponse)
const { cards, fetchCollection } = useCollection()
await fetchCollection()
expect(cards.value[0].card.name).toBe('Mewtwo EX')
expect(cards.value[1].card.name).toBe('Bulbasaur')
expect(cards.value[2].card.name).toBe('Mr Mime')
})
})
})

View File

@ -0,0 +1,237 @@
/**
* Collection composable for fetching and managing the user's card collection.
*
* Wraps the collection store with API calls and provides filtering/search helpers.
* The collection entries contain card_definition_id and quantity - full card
* definitions will be available when the cards API endpoint is implemented.
*
* @example
* ```vue
* <script setup lang="ts">
* import { useCollection } from '@/composables/useCollection'
*
* const { cards, isLoading, fetchCollection, filterByType } = useCollection()
*
* onMounted(() => fetchCollection())
*
* const fireCards = computed(() => filterByType('fire'))
* </script>
* ```
*/
import { ref, computed, readonly } from 'vue'
import { useCollectionStore } from '@/stores/collection'
import { apiClient } from '@/api/client'
import { createPlaceholderCard } from '@/utils/cardHelpers'
import type { CollectionCard, CardType, CardCategory, CardDefinition } from '@/types'
/**
* API response for a single collection entry.
* Matches the backend CollectionEntryResponse schema.
*/
export interface CollectionEntryResponse {
card_definition_id: string
quantity: number
source: string
obtained_at: string
}
/**
* API response for the full collection.
* Matches the backend CollectionResponse schema.
*/
export interface CollectionApiResponse {
total_unique_cards: number
total_card_count: number
entries: CollectionEntryResponse[]
}
/**
* Result of collection fetch operation.
*/
export interface CollectionFetchResult {
success: boolean
error?: string
}
/**
* Transform API entry to frontend CollectionCard format.
*
* Note: Currently creates a placeholder CardDefinition since the API
* doesn't return full card definitions. This will be updated when
* the cards API endpoint is available.
*/
function transformEntry(entry: CollectionEntryResponse): CollectionCard {
return {
cardDefinitionId: entry.card_definition_id,
quantity: entry.quantity,
card: createPlaceholderCard(entry.card_definition_id),
}
}
/**
* Collection composable.
*
* Provides methods to fetch the user's collection and filter/search cards.
* Uses the collection store for state management.
*/
export function useCollection() {
const store = useCollectionStore()
// Local loading/error state for fetch operations
const isFetching = ref(false)
const fetchError = ref<string | null>(null)
// Expose store state as readonly
const cards = computed(() => store.cards)
const totalCards = computed(() => store.totalCards)
const uniqueCards = computed(() => store.uniqueCards)
const isLoading = computed(() => store.isLoading || isFetching.value)
const error = computed(() => fetchError.value || store.error)
/**
* Fetch the user's collection from the API.
*
* Updates the store with the fetched cards and handles loading/error states.
*
* @returns Result indicating success or failure with error message
*/
async function fetchCollection(): Promise<CollectionFetchResult> {
if (isFetching.value) {
return { success: false, error: 'Fetch already in progress' }
}
isFetching.value = true
fetchError.value = null
store.setLoading(true)
store.setError(null)
try {
const response = await apiClient.get<CollectionApiResponse>('/api/collections/me')
// Transform API entries to frontend format
const collectionCards = response.entries.map(transformEntry)
store.setCards(collectionCards)
return { success: true }
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to fetch collection'
fetchError.value = errorMessage
store.setError(errorMessage)
return { success: false, error: errorMessage }
} finally {
isFetching.value = false
store.setLoading(false)
}
}
/**
* Filter cards by Pokemon/energy type.
*
* @param type - The card type to filter by (e.g., 'fire', 'water')
* @returns Filtered array of collection cards
*/
function filterByType(type: CardType): CollectionCard[] {
return store.cards.filter(c => c.card.type === type)
}
/**
* Filter cards by category (pokemon, trainer, energy).
*
* @param category - The category to filter by
* @returns Filtered array of collection cards
*/
function filterByCategory(category: CardCategory): CollectionCard[] {
return store.cards.filter(c => c.card.category === category)
}
/**
* Filter cards by rarity.
*
* @param rarity - The rarity level to filter by
* @returns Filtered array of collection cards
*/
function filterByRarity(rarity: CardDefinition['rarity']): CollectionCard[] {
return store.cards.filter(c => c.card.rarity === rarity)
}
/**
* Search cards by name (case-insensitive partial match).
*
* @param query - Search query string
* @returns Filtered array of collection cards matching the query
*/
function searchByName(query: string): CollectionCard[] {
if (!query.trim()) {
return store.cards
}
const lowerQuery = query.toLowerCase()
return store.cards.filter(c =>
c.card.name.toLowerCase().includes(lowerQuery)
)
}
/**
* Get quantity of a specific card in the collection.
*
* @param cardDefinitionId - The card definition ID to look up
* @returns The quantity owned, or 0 if not in collection
*/
function getQuantity(cardDefinitionId: string): number {
return store.getCardQuantity(cardDefinitionId)
}
/**
* Check if a card is in the collection.
*
* @param cardDefinitionId - The card definition ID to check
* @returns True if the card is owned
*/
function hasCard(cardDefinitionId: string): boolean {
return store.getCardQuantity(cardDefinitionId) > 0
}
/**
* Clear the collection state.
* Called when user logs out.
*/
function clear(): void {
store.clearCollection()
fetchError.value = null
}
/**
* Clear any error state.
*/
function clearError(): void {
fetchError.value = null
store.setError(null)
}
return {
// State (readonly)
cards,
totalCards,
uniqueCards,
isLoading,
error,
isFetching: readonly(isFetching),
// Actions
fetchCollection,
clear,
clearError,
// Filtering helpers
filterByType,
filterByCategory,
filterByRarity,
searchByName,
// Lookup helpers
getQuantity,
hasCard,
}
}
export type UseCollection = ReturnType<typeof useCollection>

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More