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
11
.claude/frontend-poc/.env.development
Normal 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
|
||||
11
.claude/frontend-poc/.env.production
Normal 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
@ -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?
|
||||
553
.claude/frontend-poc/CLAUDE.md
Normal 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
|
||||
989
.claude/frontend-poc/PROJECT_PLAN_FRONTEND.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
5
.claude/frontend-poc/README.md
Normal 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).
|
||||
37
.claude/frontend-poc/eslint.config.js
Normal 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'],
|
||||
},
|
||||
]
|
||||
13
.claude/frontend-poc/index.html
Normal 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
45
.claude/frontend-poc/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
.claude/frontend-poc/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
195
.claude/frontend-poc/project_plans/PHASE_F0_foundation.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
382
.claude/frontend-poc/project_plans/PHASE_F1_authentication.json
Normal 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"
|
||||
]
|
||||
}
|
||||
624
.claude/frontend-poc/project_plans/PHASE_F2_deck_management.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@ -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)"
|
||||
]
|
||||
}
|
||||
2167
.claude/frontend-poc/project_plans/TASK_F3-012_types_detailed.md
Normal file
BIN
.claude/frontend-poc/public/game/cards/a1/033-charmander.webp
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/034-charmeleon.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/035-charizard.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/037-vulpix.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/039-growlithe.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/040-arcanine.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/042-ponyta.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/043-rapidash.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/044-magmar.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/094-pikachu.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/095-raichu.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/097-magnemite.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/098-magneton.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/099-voltorb.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/100-electrode.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/101-electabuzz.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/105-blitzle.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/106-zebstrika.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/216-helix-fossil.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/217-dome-fossil.webp
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/218-old-amber.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/221-blaine.webp
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/224-brock.webp
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
.claude/frontend-poc/public/game/cards/a1/226-lt-surge.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
.claude/frontend-poc/public/game/cards/basic/fire.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
.claude/frontend-poc/public/game/cards/basic/grass.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
.claude/frontend-poc/public/game/cards/basic/lightning.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
.claude/frontend-poc/public/game/cards/basic/psychic.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
.claude/frontend-poc/public/game/cards/basic/water.webp
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
.claude/frontend-poc/public/game/cards/card_back.webp
Normal file
|
After Width: | Height: | Size: 77 KiB |
1
.claude/frontend-poc/public/vite.svg
Normal 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 |
234
.claude/frontend-poc/src/App.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
91
.claude/frontend-poc/src/App.vue
Normal 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>
|
||||
304
.claude/frontend-poc/src/api/client.spec.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
180
.claude/frontend-poc/src/api/client.ts
Normal 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
|
||||
11
.claude/frontend-poc/src/api/index.ts
Normal 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'
|
||||
100
.claude/frontend-poc/src/api/types.ts
Normal 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
|
||||
}
|
||||
74
.claude/frontend-poc/src/assets/main.css
Normal 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;
|
||||
}
|
||||
}
|
||||
307
.claude/frontend-poc/src/components/NavBottomTabs.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
95
.claude/frontend-poc/src/components/NavBottomTabs.vue
Normal 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>
|
||||
269
.claude/frontend-poc/src/components/NavSidebar.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
123
.claude/frontend-poc/src/components/NavSidebar.vue
Normal 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>
|
||||
59
.claude/frontend-poc/src/components/cards/AttackDisplay.vue
Normal 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>
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
370
.claude/frontend-poc/src/components/cards/CardDetailModal.vue
Normal 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>
|
||||
500
.claude/frontend-poc/src/components/cards/CardDisplay.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
199
.claude/frontend-poc/src/components/cards/CardDisplay.vue
Normal 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>
|
||||
105
.claude/frontend-poc/src/components/cards/CardImage.vue
Normal 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>
|
||||
86
.claude/frontend-poc/src/components/cards/EnergyCost.vue
Normal 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>
|
||||
79
.claude/frontend-poc/src/components/cards/TypeBadge.vue
Normal 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>
|
||||
335
.claude/frontend-poc/src/components/deck/CollectionPicker.vue
Normal 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>
|
||||
@ -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>
|
||||
520
.claude/frontend-poc/src/components/deck/DeckCard.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
217
.claude/frontend-poc/src/components/deck/DeckCard.vue
Normal 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>
|
||||
248
.claude/frontend-poc/src/components/deck/DeckCardRow.vue
Normal 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>
|
||||
124
.claude/frontend-poc/src/components/deck/DeckCardsList.vue
Normal 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>
|
||||
291
.claude/frontend-poc/src/components/deck/DeckContents.vue
Normal 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>
|
||||
256
.claude/frontend-poc/src/components/deck/DeckEditor.vue
Normal 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>
|
||||
209
.claude/frontend-poc/src/components/deck/DeckEnergySection.vue
Normal 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>
|
||||
48
.claude/frontend-poc/src/components/deck/DeckHeader.vue
Normal 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>
|
||||
269
.claude/frontend-poc/src/components/game/PhaserGame.spec.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
124
.claude/frontend-poc/src/components/game/PhaserGame.vue
Normal 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>
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
225
.claude/frontend-poc/src/components/ui/ConfirmDialog.vue
Normal 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>
|
||||
89
.claude/frontend-poc/src/components/ui/EmptyState.vue
Normal 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>
|
||||
189
.claude/frontend-poc/src/components/ui/ErrorBoundary.spec.ts
Normal 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!')
|
||||
})
|
||||
})
|
||||
})
|
||||
77
.claude/frontend-poc/src/components/ui/ErrorBoundary.vue
Normal 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">
|
||||
⚠
|
||||
</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>
|
||||
284
.claude/frontend-poc/src/components/ui/FilterBar.vue
Normal 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>
|
||||
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
55
.claude/frontend-poc/src/components/ui/LoadingOverlay.vue
Normal 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>
|
||||
115
.claude/frontend-poc/src/components/ui/ProgressBar.vue
Normal 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>
|
||||
81
.claude/frontend-poc/src/components/ui/SkeletonCard.vue
Normal 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>
|
||||
168
.claude/frontend-poc/src/components/ui/ToastContainer.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
86
.claude/frontend-poc/src/components/ui/ToastContainer.vue
Normal 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>
|
||||
340
.claude/frontend-poc/src/composables/useAccountLinking.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
174
.claude/frontend-poc/src/composables/useAccountLinking.ts
Normal 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>
|
||||
955
.claude/frontend-poc/src/composables/useAuth.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
375
.claude/frontend-poc/src/composables/useAuth.ts
Normal 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>
|
||||
476
.claude/frontend-poc/src/composables/useCollection.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
237
.claude/frontend-poc/src/composables/useCollection.ts
Normal 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>
|
||||