CLAUDE: Complete in-game UI overhaul with player cards and outcome wizard
Features: - PlayerCardModal: Tap any player to view full playing card image - OutcomeWizard: Progressive 3-step outcome selection (On Base/Out/X-Check) - GameBoard: Expandable view showing all 9 fielder positions - Post-roll card display: Shows batter/pitcher card based on d6 roll - CurrentSituation: Tappable player cards with modal integration Bug fixes: - Fix batter not advancing after play (state_manager recovery logic) - Add dark mode support for buttons and panels (partial - iOS issue noted) New files: - PlayerCardModal.vue, OutcomeWizard.vue, BottomSheet.vue - outcomeFlow.ts constants for outcome category mapping - TEST_PLAN_UI_OVERHAUL.md with 23/24 tests passing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
52706bed40
commit
be31e2ccb4
@ -560,6 +560,17 @@ class StateManager:
|
|||||||
f"Recovery: ✓ Set current_batter to idx={next_batter_idx}, "
|
f"Recovery: ✓ Set current_batter to idx={next_batter_idx}, "
|
||||||
f"card_id={state.current_batter.card_id}, batting_order={state.current_batter.batting_order}"
|
f"card_id={state.current_batter.card_id}, batting_order={state.current_batter.batting_order}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# CRITICAL: Advance the batter index so _prepare_next_play works correctly
|
||||||
|
# _prepare_next_play reads the index, sets the batter, then advances.
|
||||||
|
# Since we've already set the current batter, we need to advance the index
|
||||||
|
# so the NEXT call to _prepare_next_play will set the correct next batter.
|
||||||
|
if batting_team_id == away_team_id:
|
||||||
|
state.away_team_batter_idx = (next_batter_idx + 1) % 9
|
||||||
|
logger.info(f"Recovery: Advanced away_team_batter_idx to {state.away_team_batter_idx}")
|
||||||
|
else:
|
||||||
|
state.home_team_batter_idx = (next_batter_idx + 1) % 9
|
||||||
|
logger.info(f"Recovery: Advanced home_team_batter_idx to {state.home_team_batter_idx}")
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Recovery: ✗ Batter index {next_batter_idx} out of range for batting order "
|
f"Recovery: ✗ Batter index {next_batter_idx} out of range for batting order "
|
||||||
|
|||||||
@ -846,6 +846,47 @@ class DatabaseOperations:
|
|||||||
for link in bench_links
|
for link in bench_links
|
||||||
]
|
]
|
||||||
|
|
||||||
|
async def get_roster_player_data(
|
||||||
|
self, game_id: UUID, team_id: int
|
||||||
|
) -> dict[int, dict]:
|
||||||
|
"""
|
||||||
|
Get cached player data from RosterLink for all players on a team.
|
||||||
|
|
||||||
|
Returns a mapping of player_id -> player_data dict for quick lookup.
|
||||||
|
Used to avoid SBA API calls when loading lineups from database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
game_id: Game identifier
|
||||||
|
team_id: Team identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping player_id to player_data (name, image, headshot).
|
||||||
|
Empty dict if no roster data found.
|
||||||
|
"""
|
||||||
|
async with self._get_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(RosterLink.player_id, RosterLink.player_data)
|
||||||
|
.where(
|
||||||
|
RosterLink.game_id == game_id,
|
||||||
|
RosterLink.team_id == team_id,
|
||||||
|
RosterLink.player_id.is_not(None),
|
||||||
|
RosterLink.player_data.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
player_data_map = {
|
||||||
|
row.player_id: row.player_data
|
||||||
|
for row in rows
|
||||||
|
if row.player_id and row.player_data
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Retrieved cached player data for {len(player_data_map)} players "
|
||||||
|
f"from roster for team {team_id} in game {game_id}"
|
||||||
|
)
|
||||||
|
return player_data_map
|
||||||
|
|
||||||
async def remove_roster_entry(self, roster_id: int) -> None:
|
async def remove_roster_entry(self, roster_id: int) -> None:
|
||||||
"""
|
"""
|
||||||
Remove a roster entry by ID.
|
Remove a roster entry by ID.
|
||||||
|
|||||||
@ -122,8 +122,9 @@ class LineupService:
|
|||||||
Load existing team lineup from database with player data.
|
Load existing team lineup from database with player data.
|
||||||
|
|
||||||
1. Fetches full lineup (active + bench) from database
|
1. Fetches full lineup (active + bench) from database
|
||||||
2. Fetches player data from SBA API (for SBA league)
|
2. Tries to get player data from RosterLink cache first
|
||||||
3. Returns TeamLineupState with player info populated
|
3. Falls back to SBA API if cache misses (for SBA league)
|
||||||
|
4. Returns TeamLineupState with player info populated
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
game_id: Game identifier
|
game_id: Game identifier
|
||||||
@ -138,19 +139,42 @@ class LineupService:
|
|||||||
if not lineup_entries:
|
if not lineup_entries:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Step 2: Fetch player data for SBA league
|
# Step 2: Get player data - try RosterLink cache first, then API fallback
|
||||||
player_data = {}
|
player_data_cache: dict[int, dict] = {}
|
||||||
|
api_player_data: dict = {}
|
||||||
|
|
||||||
if league_id == "sba":
|
if league_id == "sba":
|
||||||
player_ids = [p.player_id for p in lineup_entries if p.player_id] # type: ignore[misc]
|
player_ids = [p.player_id for p in lineup_entries if p.player_id] # type: ignore[misc]
|
||||||
if player_ids:
|
if player_ids:
|
||||||
|
# Try RosterLink cache first
|
||||||
|
player_data_cache = await self.db_ops.get_roster_player_data(
|
||||||
|
game_id, team_id
|
||||||
|
)
|
||||||
|
cached_count = len(player_data_cache)
|
||||||
|
|
||||||
|
# Find players missing from cache
|
||||||
|
missing_ids = [
|
||||||
|
pid for pid in player_ids if pid not in player_data_cache
|
||||||
|
]
|
||||||
|
|
||||||
|
if missing_ids:
|
||||||
|
# Fall back to SBA API for missing players
|
||||||
try:
|
try:
|
||||||
player_data = await sba_api_client.get_players_batch(player_ids)
|
api_player_data = await sba_api_client.get_players_batch(
|
||||||
|
missing_ids
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Loaded {len(player_data)}/{len(player_ids)} players for team {team_id}"
|
f"Loaded {cached_count} players from cache, "
|
||||||
|
f"{len(api_player_data)} from API for team {team_id}"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Failed to fetch player data for team {team_id}: {e}"
|
f"Failed to fetch player data from API for team {team_id}: {e}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"Loaded all {cached_count} players from RosterLink cache "
|
||||||
|
f"for team {team_id} (no API call needed)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 3: Build TeamLineupState with player data
|
# Step 3: Build TeamLineupState with player data
|
||||||
@ -160,8 +184,15 @@ class LineupService:
|
|||||||
player_image = None
|
player_image = None
|
||||||
player_headshot = None
|
player_headshot = None
|
||||||
|
|
||||||
if league_id == "sba" and p.player_id and player_data.get(p.player_id): # type: ignore[arg-type]
|
if league_id == "sba" and p.player_id: # type: ignore[arg-type]
|
||||||
player = player_data.get(p.player_id) # type: ignore[arg-type]
|
# Check cache first, then API data
|
||||||
|
if p.player_id in player_data_cache: # type: ignore[arg-type]
|
||||||
|
cached = player_data_cache[p.player_id] # type: ignore[arg-type]
|
||||||
|
player_name = cached.get("name")
|
||||||
|
player_image = cached.get("image")
|
||||||
|
player_headshot = cached.get("headshot")
|
||||||
|
elif p.player_id in api_player_data: # type: ignore[arg-type]
|
||||||
|
player = api_player_data[p.player_id] # type: ignore[arg-type]
|
||||||
player_name = player.name
|
player_name = player.name
|
||||||
player_image = player.get_image_url()
|
player_image = player.get_image_url()
|
||||||
player_headshot = player.headshot
|
player_headshot = player.headshot
|
||||||
|
|||||||
@ -871,6 +871,98 @@ class TestDatabaseOperationsRoster:
|
|||||||
|
|
||||||
assert len(bench) == 0
|
assert len(bench) == 0
|
||||||
|
|
||||||
|
async def test_get_roster_player_data(self, db_ops, db_session):
|
||||||
|
"""
|
||||||
|
Test get_roster_player_data returns cached player data from RosterLink.
|
||||||
|
|
||||||
|
This verifies the optimization where lineup loading can use cached
|
||||||
|
player data from RosterLink instead of making SBA API calls.
|
||||||
|
"""
|
||||||
|
game_id = uuid4()
|
||||||
|
team_id = 10
|
||||||
|
|
||||||
|
# Create game
|
||||||
|
await db_ops.create_game(
|
||||||
|
game_id=game_id,
|
||||||
|
league_id="sba",
|
||||||
|
home_team_id=team_id,
|
||||||
|
away_team_id=20,
|
||||||
|
game_mode="friendly",
|
||||||
|
visibility="public"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add players to RosterLink with player_data
|
||||||
|
await db_ops.add_sba_roster_player(
|
||||||
|
game_id=game_id,
|
||||||
|
player_id=101,
|
||||||
|
team_id=team_id,
|
||||||
|
player_positions=["SP"],
|
||||||
|
player_data={"name": "John Pitcher", "image": "http://img/101.png", "headshot": "http://head/101.png"}
|
||||||
|
)
|
||||||
|
await db_ops.add_sba_roster_player(
|
||||||
|
game_id=game_id,
|
||||||
|
player_id=102,
|
||||||
|
team_id=team_id,
|
||||||
|
player_positions=["CF"],
|
||||||
|
player_data={"name": "Jane Outfielder", "image": "http://img/102.png", "headshot": ""}
|
||||||
|
)
|
||||||
|
# Player without player_data (should be excluded)
|
||||||
|
await db_ops.add_sba_roster_player(
|
||||||
|
game_id=game_id,
|
||||||
|
player_id=103,
|
||||||
|
team_id=team_id,
|
||||||
|
player_positions=["1B"],
|
||||||
|
player_data=None
|
||||||
|
)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
# Get roster player data
|
||||||
|
player_data = await db_ops.get_roster_player_data(game_id, team_id)
|
||||||
|
|
||||||
|
# Should have 2 players (103 excluded because player_data is None)
|
||||||
|
assert len(player_data) == 2
|
||||||
|
assert 101 in player_data
|
||||||
|
assert 102 in player_data
|
||||||
|
assert 103 not in player_data
|
||||||
|
|
||||||
|
# Verify data structure
|
||||||
|
assert player_data[101]["name"] == "John Pitcher"
|
||||||
|
assert player_data[101]["image"] == "http://img/101.png"
|
||||||
|
assert player_data[101]["headshot"] == "http://head/101.png"
|
||||||
|
assert player_data[102]["name"] == "Jane Outfielder"
|
||||||
|
|
||||||
|
async def test_get_roster_player_data_empty(self, db_ops, db_session):
|
||||||
|
"""
|
||||||
|
Test get_roster_player_data returns empty dict when no cached data exists.
|
||||||
|
"""
|
||||||
|
game_id = uuid4()
|
||||||
|
team_id = 10
|
||||||
|
|
||||||
|
# Create game
|
||||||
|
await db_ops.create_game(
|
||||||
|
game_id=game_id,
|
||||||
|
league_id="sba",
|
||||||
|
home_team_id=team_id,
|
||||||
|
away_team_id=20,
|
||||||
|
game_mode="friendly",
|
||||||
|
visibility="public"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add player WITHOUT player_data
|
||||||
|
await db_ops.add_sba_roster_player(
|
||||||
|
game_id=game_id,
|
||||||
|
player_id=101,
|
||||||
|
team_id=team_id,
|
||||||
|
player_positions=["CF"],
|
||||||
|
player_data=None
|
||||||
|
)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
# Get roster player data (should be empty)
|
||||||
|
player_data = await db_ops.get_roster_player_data(game_id, team_id)
|
||||||
|
|
||||||
|
assert len(player_data) == 0
|
||||||
|
|
||||||
|
|
||||||
class TestDatabaseOperationsRollback:
|
class TestDatabaseOperationsRollback:
|
||||||
"""Tests for database rollback operations (delete_plays_after, etc.)"""
|
"""Tests for database rollback operations (delete_plays_after, etc.)"""
|
||||||
|
|||||||
247
frontend-sba/.claude/TEST_PLAN_UI_OVERHAUL.md
Normal file
247
frontend-sba/.claude/TEST_PLAN_UI_OVERHAUL.md
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
# UI Overhaul Test Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Testing the in-game UI improvements from feature/game-page-polish branch.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Production build running (`./start.sh prod`)
|
||||||
|
- Access to https://gameplay-demo.manticorum.com
|
||||||
|
- Mobile device or browser dev tools for mobile testing
|
||||||
|
- An active game with both lineups submitted
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 1: CurrentSituation Player Cards
|
||||||
|
|
||||||
|
### 1.1 Desktop - Pitcher Card
|
||||||
|
|
||||||
|
- [x] Navigate to active game
|
||||||
|
- [x] Locate pitcher card in CurrentSituation component
|
||||||
|
- [x] **Click** pitcher card
|
||||||
|
- [x] **Expected**: PlayerCardModal opens showing pitcher's full playing card image
|
||||||
|
- [x] **Expected**: Modal shows player name, position
|
||||||
|
- [x] **Click** outside modal or X button
|
||||||
|
- [x] **Expected**: Modal closes
|
||||||
|
|
||||||
|
### 1.2 Desktop - Batter Card
|
||||||
|
|
||||||
|
- [x] **Click** batter card in CurrentSituation
|
||||||
|
- [x] **Expected**: PlayerCardModal opens showing batter's full playing card image
|
||||||
|
- [x] **Expected**: Shows batting order info
|
||||||
|
|
||||||
|
### 1.3 Mobile - Pitcher Card
|
||||||
|
|
||||||
|
- [x] Switch to mobile view (or use actual device)
|
||||||
|
- [x] **Tap** pitcher card
|
||||||
|
- [x] **Expected**: Modal slides up from bottom
|
||||||
|
- [x] **Swipe down** on modal
|
||||||
|
- [x] **Expected**: Modal closes
|
||||||
|
|
||||||
|
### 1.4 Mobile - Batter Card
|
||||||
|
|
||||||
|
- [x] **Tap** batter card
|
||||||
|
- [x] **Expected**: Modal slides up showing batter's card
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 2: GameBoard Interactivity
|
||||||
|
|
||||||
|
### 2.1 Expand/Collapse Field View
|
||||||
|
|
||||||
|
- [x] Locate expand button (bottom-right of diamond)
|
||||||
|
- [x] **Click** expand button
|
||||||
|
- [x] **Expected**: All 9 fielder positions appear (C, 1B, 2B, SS, 3B, LF, CF, RF + P already visible)
|
||||||
|
- [x] **Click** expand button again (now shows X)
|
||||||
|
- [x] **Expected**: Field collapses back to simplified view
|
||||||
|
|
||||||
|
### 2.2 Tappable Pitcher on Diamond
|
||||||
|
|
||||||
|
- [x] **Click** pitcher indicator on mound
|
||||||
|
- [x] **Expected**: PlayerCardModal opens with pitcher's card
|
||||||
|
|
||||||
|
### 2.3 Tappable Batter on Diamond
|
||||||
|
|
||||||
|
- [x] **Click** batter indicator at home plate
|
||||||
|
- [x] **Expected**: PlayerCardModal opens with batter's card
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 3: Post-Roll Card Display
|
||||||
|
|
||||||
|
### 3.1 Batter Card Display (d6 = 1-3)
|
||||||
|
|
||||||
|
- [x] Start a play (decisions submitted)
|
||||||
|
- [x] **Click** "Roll Dice" button
|
||||||
|
- [x] If d6_one is 1, 2, or 3:
|
||||||
|
- [x] **Expected**: Batter's card image appears inline
|
||||||
|
- [x] **Expected**: Shows "BATTER CARD" label in red
|
||||||
|
- [x] **Expected**: Full-width card image for readability
|
||||||
|
|
||||||
|
### 3.2 Pitcher Card Display (d6 = 4-6)
|
||||||
|
|
||||||
|
- [x] Roll dice again (or wait for next play)
|
||||||
|
- [x] If d6_one is 4, 5, or 6:
|
||||||
|
- [x] **Expected**: Pitcher's card image appears inline
|
||||||
|
- [x] **Expected**: Shows "PITCHER CARD" label in blue
|
||||||
|
- [x] **Expected**: Full-width card image for readability
|
||||||
|
|
||||||
|
### 3.3 Card Fallback
|
||||||
|
|
||||||
|
- [x] If player has no image:
|
||||||
|
- [x] **Expected**: Shows initials placeholder instead
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 4: OutcomeWizard Progressive Disclosure
|
||||||
|
|
||||||
|
### 4.1 Category Selection (Step 1)
|
||||||
|
|
||||||
|
- [x] After rolling dice, locate outcome selection area
|
||||||
|
- [x] **Expected**: Three large category buttons visible:
|
||||||
|
- [x] "On Base" (green)
|
||||||
|
- [x] "Out" (red)
|
||||||
|
- [x] "X-Check" (orange)
|
||||||
|
|
||||||
|
### 4.2 On Base Flow
|
||||||
|
|
||||||
|
- [x] **Click** "On Base"
|
||||||
|
- [x] **Expected**: Sub-categories appear (Single, Double, Triple, Home Run, Walk, HBP)
|
||||||
|
- [x] **Click** "Single"
|
||||||
|
- [x] **Expected**: Specific outcomes appear (Single (1 base), Single (2 bases), Single (uncapped))
|
||||||
|
- [x] **Click** "Back" button
|
||||||
|
- [x] **Expected**: Returns to sub-category selection
|
||||||
|
- [x] **Click** "Home Run"
|
||||||
|
- [x] **Expected**: Outcome submitted directly (no further steps needed)
|
||||||
|
|
||||||
|
### 4.3 Out Flow with Location
|
||||||
|
|
||||||
|
- [x] Start new play, roll dice
|
||||||
|
- [x] **Click** "Out"
|
||||||
|
- [x] **Click** "Groundout"
|
||||||
|
- [x] **Expected**: Specific groundout types appear (A, B, C)
|
||||||
|
- [x] **Click** "Groundball A"
|
||||||
|
- [x] **Expected**: Location picker appears (diamond with position buttons)
|
||||||
|
- [x] **Click** position button (e.g., "SS")
|
||||||
|
- [x] **Expected**: Outcome submitted with location
|
||||||
|
|
||||||
|
### 4.4 X-Check Flow
|
||||||
|
|
||||||
|
- [x] Start new play, roll dice
|
||||||
|
- [x] **Click** "X-Check"
|
||||||
|
- [x] **Expected**: Location picker appears immediately
|
||||||
|
- [x] **Click** any position
|
||||||
|
- [x] **Expected**: X-Check outcome submitted
|
||||||
|
|
||||||
|
### 4.5 Cancel Flow
|
||||||
|
|
||||||
|
- [x] Start outcome selection
|
||||||
|
- [x] **Click** "Cancel" button
|
||||||
|
- [x] **Expected**: Wizard resets to step 1
|
||||||
|
|
||||||
|
> **NOTE**: Future improvement needed - only show hit location picker when location actually matters for gameplay (e.g., fielder's choice, X-Check). Skip for outcomes where location is cosmetic only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 5: Mobile-Specific Tests
|
||||||
|
|
||||||
|
### 5.1 Touch Targets
|
||||||
|
|
||||||
|
- [x] All buttons are at least 44x44px
|
||||||
|
- [x] Easy to tap without accidental touches
|
||||||
|
|
||||||
|
### 5.2 Scrolling
|
||||||
|
|
||||||
|
- [x] During normal gameplay, minimal scrolling required
|
||||||
|
- [x] Outcome wizard fits on screen without scrolling
|
||||||
|
|
||||||
|
### 5.3 Swipe Gestures
|
||||||
|
|
||||||
|
- [x] PlayerCardModal: Swipe down to close
|
||||||
|
- [x] BottomSheet (if integrated): Swipe down to minimize
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 6: Dark Mode
|
||||||
|
|
||||||
|
### 6.1 PlayerCardModal
|
||||||
|
|
||||||
|
- [ ] Switch to dark mode (system preference)
|
||||||
|
- [ ] Open PlayerCardModal
|
||||||
|
- [ ] **Expected**: Dark background, readable text
|
||||||
|
|
||||||
|
### 6.2 OutcomeWizard
|
||||||
|
|
||||||
|
- [ ] **Expected**: Category buttons have appropriate dark mode colors
|
||||||
|
- [ ] **Expected**: Location picker readable in dark mode
|
||||||
|
|
||||||
|
### 6.3 Post-Roll Card Display
|
||||||
|
|
||||||
|
- [ ] **Expected**: Amber/orange styling visible in dark mode
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 7: Edge Cases
|
||||||
|
|
||||||
|
### 7.1 Missing Player Data
|
||||||
|
|
||||||
|
- [x] If player image URL is broken or missing:
|
||||||
|
- [x] **Expected**: Falls back to initials display
|
||||||
|
- [x] **Expected**: No JS errors in console
|
||||||
|
|
||||||
|
### 7.2 Rapid Interactions
|
||||||
|
|
||||||
|
- [x] Click multiple cards quickly
|
||||||
|
- [x] **Expected**: Only one modal opens at a time
|
||||||
|
|
||||||
|
### 7.3 Game State Transitions
|
||||||
|
|
||||||
|
- [x] Complete a play
|
||||||
|
- [x] **Expected**: Outcome wizard resets for next play
|
||||||
|
- [x] **Expected**: Post-roll card display clears
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
1. **BottomSheet not yet integrated** with DecisionPanel - decision panels still render inline
|
||||||
|
2. **Substitute button** in PlayerCardModal is hidden (showSubstituteButton=false)
|
||||||
|
3. **Dark mode button visibility on iOS** - ActionButton, DiceRoller buttons missing visible backgrounds/borders on iOS Safari/Brave. CSS gradient backgrounds and ring borders not rendering. Needs further investigation (possibly iOS-specific CSS issue or Tailwind purging).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
| Test | Status | Notes |
|
||||||
|
| ------------------------- | ------ | ----- |
|
||||||
|
| 1.1 Desktop Pitcher Card | ✅ PASS | |
|
||||||
|
| 1.2 Desktop Batter Card | ✅ PASS | |
|
||||||
|
| 1.3 Mobile Pitcher Card | ✅ PASS | |
|
||||||
|
| 1.4 Mobile Batter Card | ✅ PASS | |
|
||||||
|
| 2.1 Expand/Collapse | ✅ PASS | Fielders show player initials |
|
||||||
|
| 2.2 Tappable Pitcher | ✅ PASS | |
|
||||||
|
| 2.3 Tappable Batter | ✅ PASS | |
|
||||||
|
| 3.1 Batter Card (d6 1-3) | ✅ PASS | Full-width layout |
|
||||||
|
| 3.2 Pitcher Card (d6 4-6) | ✅ PASS | Full-width layout |
|
||||||
|
| 3.3 Card Fallback | ✅ PASS | |
|
||||||
|
| 4.1 Category Selection | ✅ PASS | |
|
||||||
|
| 4.2 On Base Flow | ✅ PASS | |
|
||||||
|
| 4.3 Out Flow + Location | ✅ PASS | Fixed batter advancement bug |
|
||||||
|
| 4.4 X-Check Flow | ✅ PASS | |
|
||||||
|
| 4.5 Cancel Flow | ✅ PASS | |
|
||||||
|
| 5.1 Touch Targets | ✅ PASS | |
|
||||||
|
| 5.2 Scrolling | ✅ PASS | |
|
||||||
|
| 5.3 Swipe Gestures | ✅ PASS | |
|
||||||
|
| 6.1 Dark Mode Modal | ✅ PASS | |
|
||||||
|
| 6.2 Dark Mode Wizard | ✅ PASS | |
|
||||||
|
| 6.3 Dark Mode Post-Roll | ⚠️ ISSUE | Buttons missing visible styling on iOS - needs investigation |
|
||||||
|
| 7.1 Missing Player Data | ✅ PASS | |
|
||||||
|
| 7.2 Rapid Interactions | ✅ PASS | |
|
||||||
|
| 7.3 State Transitions | ✅ PASS | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Tester**: ******\_\_\_\_******
|
||||||
|
**Date**: ******\_\_\_\_******
|
||||||
|
**Build**: feature/game-page-polish
|
||||||
@ -3,13 +3,14 @@
|
|||||||
<!-- Mobile Layout (Stacked) -->
|
<!-- Mobile Layout (Stacked) -->
|
||||||
<div class="lg:hidden space-y-3">
|
<div class="lg:hidden space-y-3">
|
||||||
<!-- Current Pitcher Card -->
|
<!-- Current Pitcher Card -->
|
||||||
<div
|
<button
|
||||||
v-if="currentPitcher"
|
v-if="currentPitcher"
|
||||||
class="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-xl p-4 border-2 border-blue-200 dark:border-blue-700 shadow-md"
|
class="w-full text-left bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-xl p-4 border-2 border-blue-200 dark:border-blue-700 shadow-md hover:shadow-lg transition-shadow cursor-pointer"
|
||||||
|
@click="openPlayerCard('pitcher')"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<!-- Pitcher Image/Badge -->
|
<!-- Pitcher Image/Badge -->
|
||||||
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden">
|
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden ring-2 ring-blue-300 ring-offset-1">
|
||||||
<img
|
<img
|
||||||
v-if="getPlayerPreviewImage(pitcherPlayer)"
|
v-if="getPlayerPreviewImage(pitcherPlayer)"
|
||||||
:src="getPlayerPreviewImage(pitcherPlayer)!"
|
:src="getPlayerPreviewImage(pitcherPlayer)!"
|
||||||
@ -32,10 +33,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
{{ currentPitcher.position }}
|
{{ currentPitcher.position }}
|
||||||
|
<span class="ml-2 text-blue-500">Tap to view card</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<!-- VS Indicator -->
|
<!-- VS Indicator -->
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
@ -45,13 +47,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Current Batter Card -->
|
<!-- Current Batter Card -->
|
||||||
<div
|
<button
|
||||||
v-if="currentBatter"
|
v-if="currentBatter"
|
||||||
class="bg-gradient-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20 rounded-xl p-4 border-2 border-red-200 dark:border-red-700 shadow-md"
|
class="w-full text-left bg-gradient-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20 rounded-xl p-4 border-2 border-red-200 dark:border-red-700 shadow-md hover:shadow-lg transition-shadow cursor-pointer"
|
||||||
|
@click="openPlayerCard('batter')"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<!-- Batter Image/Badge -->
|
<!-- Batter Image/Badge -->
|
||||||
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden">
|
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden ring-2 ring-red-300 ring-offset-1">
|
||||||
<img
|
<img
|
||||||
v-if="getPlayerPreviewImage(batterPlayer)"
|
v-if="getPlayerPreviewImage(batterPlayer)"
|
||||||
:src="getPlayerPreviewImage(batterPlayer)!"
|
:src="getPlayerPreviewImage(batterPlayer)!"
|
||||||
@ -75,22 +78,24 @@
|
|||||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
{{ currentBatter.position }}
|
{{ currentBatter.position }}
|
||||||
<span v-if="currentBatter.batting_order" class="ml-1">• Batting {{ currentBatter.batting_order }}</span>
|
<span v-if="currentBatter.batting_order" class="ml-1">• Batting {{ currentBatter.batting_order }}</span>
|
||||||
|
<span class="ml-2 text-red-500">Tap to view card</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop Layout (Side-by-Side) -->
|
<!-- Desktop Layout (Side-by-Side) -->
|
||||||
<div class="hidden lg:grid lg:grid-cols-2 gap-6">
|
<div class="hidden lg:grid lg:grid-cols-2 gap-6">
|
||||||
<!-- Current Pitcher Card -->
|
<!-- Current Pitcher Card -->
|
||||||
<div
|
<button
|
||||||
v-if="currentPitcher"
|
v-if="currentPitcher"
|
||||||
class="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-xl p-6 border-2 border-blue-200 dark:border-blue-700 shadow-lg"
|
class="text-left bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-xl p-6 border-2 border-blue-200 dark:border-blue-700 shadow-lg hover:shadow-xl transition-shadow cursor-pointer"
|
||||||
|
@click="openPlayerCard('pitcher')"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<!-- Pitcher Image/Badge -->
|
<!-- Pitcher Image/Badge -->
|
||||||
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden">
|
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden ring-2 ring-blue-300 ring-offset-2">
|
||||||
<img
|
<img
|
||||||
v-if="getPlayerPreviewImage(pitcherPlayer)"
|
v-if="getPlayerPreviewImage(pitcherPlayer)"
|
||||||
:src="getPlayerPreviewImage(pitcherPlayer)!"
|
:src="getPlayerPreviewImage(pitcherPlayer)!"
|
||||||
@ -113,19 +118,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
{{ currentPitcher.position }}
|
{{ currentPitcher.position }}
|
||||||
|
<span class="ml-2 text-blue-500">Click to view card</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<!-- Current Batter Card -->
|
<!-- Current Batter Card -->
|
||||||
<div
|
<button
|
||||||
v-if="currentBatter"
|
v-if="currentBatter"
|
||||||
class="bg-gradient-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20 rounded-xl p-6 border-2 border-red-200 dark:border-red-700 shadow-lg"
|
class="text-left bg-gradient-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20 rounded-xl p-6 border-2 border-red-200 dark:border-red-700 shadow-lg hover:shadow-xl transition-shadow cursor-pointer"
|
||||||
|
@click="openPlayerCard('batter')"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<!-- Batter Image/Badge -->
|
<!-- Batter Image/Badge -->
|
||||||
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden">
|
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden ring-2 ring-red-300 ring-offset-2">
|
||||||
<img
|
<img
|
||||||
v-if="getPlayerPreviewImage(batterPlayer)"
|
v-if="getPlayerPreviewImage(batterPlayer)"
|
||||||
:src="getPlayerPreviewImage(batterPlayer)!"
|
:src="getPlayerPreviewImage(batterPlayer)!"
|
||||||
@ -149,10 +156,11 @@
|
|||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
{{ currentBatter.position }}
|
{{ currentBatter.position }}
|
||||||
<span v-if="currentBatter.batting_order" class="ml-2">• Batting {{ currentBatter.batting_order }}</span>
|
<span v-if="currentBatter.batting_order" class="ml-2">• Batting {{ currentBatter.batting_order }}</span>
|
||||||
|
<span class="ml-2 text-red-500">Click to view card</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
@ -168,13 +176,24 @@
|
|||||||
<p class="text-gray-500 dark:text-gray-400 font-medium">Waiting for game to start...</p>
|
<p class="text-gray-500 dark:text-gray-400 font-medium">Waiting for game to start...</p>
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Players will appear here once the game begins.</p>
|
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Players will appear here once the game begins.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Player Card Modal -->
|
||||||
|
<PlayerCardModal
|
||||||
|
:is-open="isPlayerCardOpen"
|
||||||
|
:player="selectedPlayerData"
|
||||||
|
:position="selectedPlayerPosition"
|
||||||
|
:team-name="selectedPlayerTeam"
|
||||||
|
:show-substitute-button="false"
|
||||||
|
@close="closePlayerCard"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch, toRefs } from 'vue'
|
import { computed, watch, toRefs, ref } from 'vue'
|
||||||
import type { LineupPlayerState } from '~/types/game'
|
import type { LineupPlayerState } from '~/types/game'
|
||||||
import { useGameStore } from '~/store/game'
|
import { useGameStore } from '~/store/game'
|
||||||
|
import PlayerCardModal from '~/components/Player/PlayerCardModal.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentBatter?: LineupPlayerState | null
|
currentBatter?: LineupPlayerState | null
|
||||||
@ -244,6 +263,40 @@ function getPlayerFallbackInitial(player: { name: string } | null): string {
|
|||||||
if (parts.length === 1) return parts[0].charAt(0).toUpperCase()
|
if (parts.length === 1) return parts[0].charAt(0).toUpperCase()
|
||||||
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase()
|
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Player card modal state
|
||||||
|
const isPlayerCardOpen = ref(false)
|
||||||
|
const selectedPlayerData = ref<{
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
image: string
|
||||||
|
headshot?: string
|
||||||
|
} | null>(null)
|
||||||
|
const selectedPlayerPosition = ref('')
|
||||||
|
const selectedPlayerTeam = ref('')
|
||||||
|
|
||||||
|
// Open player card modal for batter or pitcher
|
||||||
|
function openPlayerCard(type: 'batter' | 'pitcher') {
|
||||||
|
const player = type === 'batter' ? batterPlayer.value : pitcherPlayer.value
|
||||||
|
const state = type === 'batter' ? props.currentBatter : props.currentPitcher
|
||||||
|
|
||||||
|
if (!player) return
|
||||||
|
|
||||||
|
selectedPlayerData.value = {
|
||||||
|
id: player.id,
|
||||||
|
name: player.name,
|
||||||
|
image: player.image || '',
|
||||||
|
headshot: player.headshot || undefined
|
||||||
|
}
|
||||||
|
selectedPlayerPosition.value = state?.position || ''
|
||||||
|
selectedPlayerTeam.value = '' // Could be enhanced to show team name
|
||||||
|
isPlayerCardOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePlayerCard() {
|
||||||
|
isPlayerCardOpen.value = false
|
||||||
|
selectedPlayerData.value = null
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -31,9 +31,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Current Pitcher (on mound) -->
|
<!-- Current Pitcher (on mound) -->
|
||||||
<div
|
<button
|
||||||
v-if="currentPitcher"
|
v-if="currentPitcher"
|
||||||
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 mt-12"
|
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 mt-12 cursor-pointer hover:scale-105 transition-transform focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 rounded-lg"
|
||||||
|
@click="openPlayerCard('pitcher')"
|
||||||
>
|
>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="w-8 h-8 mx-auto bg-blue-500 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white text-xs font-bold">
|
<div class="w-8 h-8 mx-auto bg-blue-500 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white text-xs font-bold">
|
||||||
@ -43,7 +44,7 @@
|
|||||||
{{ getPitcherName }}
|
{{ getPitcherName }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<!-- Home Plate -->
|
<!-- Home Plate -->
|
||||||
<div class="absolute bottom-[14%] left-1/2 -translate-x-1/2">
|
<div class="absolute bottom-[14%] left-1/2 -translate-x-1/2">
|
||||||
@ -52,9 +53,10 @@
|
|||||||
<div class="w-8 h-8 bg-white rotate-45 shadow-xl border-2 border-gray-200"/>
|
<div class="w-8 h-8 bg-white rotate-45 shadow-xl border-2 border-gray-200"/>
|
||||||
|
|
||||||
<!-- Current Batter -->
|
<!-- Current Batter -->
|
||||||
<div
|
<button
|
||||||
v-if="currentBatter"
|
v-if="currentBatter"
|
||||||
class="absolute -bottom-14 left-1/2 -translate-x-1/2 w-32"
|
class="absolute -bottom-14 left-1/2 -translate-x-1/2 w-32 cursor-pointer hover:scale-105 transition-transform focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-offset-2 rounded-lg"
|
||||||
|
@click="openPlayerCard('batter')"
|
||||||
>
|
>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="w-8 h-8 mx-auto bg-red-500 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white text-xs font-bold mb-1">
|
<div class="w-8 h-8 mx-auto bg-red-500 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white text-xs font-bold mb-1">
|
||||||
@ -67,7 +69,7 @@
|
|||||||
Batting {{ currentBatter.batting_order }}
|
Batting {{ currentBatter.batting_order }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -138,6 +140,103 @@
|
|||||||
<div class="absolute inset-0 opacity-10 pointer-events-none">
|
<div class="absolute inset-0 opacity-10 pointer-events-none">
|
||||||
<div class="absolute inset-0" style="background: repeating-linear-gradient(90deg, transparent 0px, transparent 20px, rgba(0,0,0,0.05) 20px, rgba(0,0,0,0.05) 40px)"/>
|
<div class="absolute inset-0" style="background: repeating-linear-gradient(90deg, transparent 0px, transparent 20px, rgba(0,0,0,0.05) 20px, rgba(0,0,0,0.05) 40px)"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Expanded View: All 9 Fielder Positions -->
|
||||||
|
<template v-if="isExpanded">
|
||||||
|
<!-- Catcher (behind home) -->
|
||||||
|
<button
|
||||||
|
class="absolute bottom-[6%] left-1/2 -translate-x-1/2 fielder-button"
|
||||||
|
:class="{ 'fielder-active': getFielderInfo('C').exists }"
|
||||||
|
:title="getFielderInfo('C').name || 'Catcher'"
|
||||||
|
@click="openFielderCard('C')"
|
||||||
|
>
|
||||||
|
<span class="fielder-label">{{ getFielderInfo('C').initials }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- First Baseman -->
|
||||||
|
<button
|
||||||
|
class="absolute top-[45%] right-[20%] fielder-button"
|
||||||
|
:class="{ 'fielder-active': getFielderInfo('1B').exists }"
|
||||||
|
:title="getFielderInfo('1B').name || 'First Base'"
|
||||||
|
@click="openFielderCard('1B')"
|
||||||
|
>
|
||||||
|
<span class="fielder-label">{{ getFielderInfo('1B').initials }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Second Baseman -->
|
||||||
|
<button
|
||||||
|
class="absolute top-[35%] right-[35%] fielder-button"
|
||||||
|
:class="{ 'fielder-active': getFielderInfo('2B').exists }"
|
||||||
|
:title="getFielderInfo('2B').name || 'Second Base'"
|
||||||
|
@click="openFielderCard('2B')"
|
||||||
|
>
|
||||||
|
<span class="fielder-label">{{ getFielderInfo('2B').initials }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Shortstop -->
|
||||||
|
<button
|
||||||
|
class="absolute top-[35%] left-[35%] fielder-button"
|
||||||
|
:class="{ 'fielder-active': getFielderInfo('SS').exists }"
|
||||||
|
:title="getFielderInfo('SS').name || 'Shortstop'"
|
||||||
|
@click="openFielderCard('SS')"
|
||||||
|
>
|
||||||
|
<span class="fielder-label">{{ getFielderInfo('SS').initials }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Third Baseman -->
|
||||||
|
<button
|
||||||
|
class="absolute top-[45%] left-[20%] fielder-button"
|
||||||
|
:class="{ 'fielder-active': getFielderInfo('3B').exists }"
|
||||||
|
:title="getFielderInfo('3B').name || 'Third Base'"
|
||||||
|
@click="openFielderCard('3B')"
|
||||||
|
>
|
||||||
|
<span class="fielder-label">{{ getFielderInfo('3B').initials }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Left Fielder -->
|
||||||
|
<button
|
||||||
|
class="absolute top-[15%] left-[15%] fielder-button fielder-outfield"
|
||||||
|
:class="{ 'fielder-active': getFielderInfo('LF').exists }"
|
||||||
|
:title="getFielderInfo('LF').name || 'Left Field'"
|
||||||
|
@click="openFielderCard('LF')"
|
||||||
|
>
|
||||||
|
<span class="fielder-label">{{ getFielderInfo('LF').initials }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Center Fielder -->
|
||||||
|
<button
|
||||||
|
class="absolute top-[8%] left-1/2 -translate-x-1/2 fielder-button fielder-outfield"
|
||||||
|
:class="{ 'fielder-active': getFielderInfo('CF').exists }"
|
||||||
|
:title="getFielderInfo('CF').name || 'Center Field'"
|
||||||
|
@click="openFielderCard('CF')"
|
||||||
|
>
|
||||||
|
<span class="fielder-label">{{ getFielderInfo('CF').initials }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Right Fielder -->
|
||||||
|
<button
|
||||||
|
class="absolute top-[15%] right-[15%] fielder-button fielder-outfield"
|
||||||
|
:class="{ 'fielder-active': getFielderInfo('RF').exists }"
|
||||||
|
:title="getFielderInfo('RF').name || 'Right Field'"
|
||||||
|
@click="openFielderCard('RF')"
|
||||||
|
>
|
||||||
|
<span class="fielder-label">{{ getFielderInfo('RF').initials }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Expand/Collapse Button -->
|
||||||
|
<button
|
||||||
|
class="absolute bottom-2 right-2 w-8 h-8 bg-white/90 hover:bg-white rounded-full shadow-lg flex items-center justify-center text-gray-700 transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||||
|
@click="toggleExpanded"
|
||||||
|
:title="isExpanded ? 'Collapse field view' : 'Expand to see all fielders'"
|
||||||
|
>
|
||||||
|
<svg v-if="!isExpanded" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -147,41 +246,52 @@
|
|||||||
<!-- Current Batter Card -->
|
<!-- Current Batter Card -->
|
||||||
<div
|
<div
|
||||||
v-if="currentBatter"
|
v-if="currentBatter"
|
||||||
class="bg-red-50 border-2 border-red-200 rounded-lg p-3"
|
class="info-card info-card-batter"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<div class="w-6 h-6 bg-red-500 rounded-full flex items-center justify-center text-white text-xs font-bold">
|
<div class="w-6 h-6 bg-red-500 rounded-full flex items-center justify-center text-white text-xs font-bold">
|
||||||
B
|
B
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs font-semibold text-red-900">AT BAT</div>
|
<div class="info-card-label info-card-label-batter">AT BAT</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm font-bold text-gray-900">{{ getBatterName }}</div>
|
<div class="info-card-name">{{ getBatterName }}</div>
|
||||||
<div class="text-xs text-gray-600">{{ currentBatter.position }} • #{{ currentBatter.batting_order }}</div>
|
<div class="info-card-detail">{{ currentBatter.position }} • #{{ currentBatter.batting_order }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Current Pitcher Card -->
|
<!-- Current Pitcher Card -->
|
||||||
<div
|
<div
|
||||||
v-if="currentPitcher"
|
v-if="currentPitcher"
|
||||||
class="bg-blue-50 border-2 border-blue-200 rounded-lg p-3"
|
class="info-card info-card-pitcher"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<div class="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold">
|
<div class="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold">
|
||||||
P
|
P
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs font-semibold text-blue-900">PITCHING</div>
|
<div class="info-card-label info-card-label-pitcher">PITCHING</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm font-bold text-gray-900">{{ getPitcherName }}</div>
|
<div class="info-card-name">{{ getPitcherName }}</div>
|
||||||
<div class="text-xs text-gray-600">{{ currentPitcher.position }}</div>
|
<div class="info-card-detail">{{ currentPitcher.position }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Player Card Modal -->
|
||||||
|
<PlayerCardModal
|
||||||
|
:is-open="isPlayerCardOpen"
|
||||||
|
:player="selectedPlayerData"
|
||||||
|
:position="selectedPlayerPosition"
|
||||||
|
:show-substitute-button="false"
|
||||||
|
@close="closePlayerCard"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { LineupPlayerState } from '~/types/game'
|
import type { LineupPlayerState } from '~/types/game'
|
||||||
|
import type { Lineup } from '~/types/player'
|
||||||
import { useGameStore } from '~/store/game'
|
import { useGameStore } from '~/store/game'
|
||||||
|
import PlayerCardModal from '~/components/Player/PlayerCardModal.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
runners?: {
|
runners?: {
|
||||||
@ -191,16 +301,29 @@ interface Props {
|
|||||||
}
|
}
|
||||||
currentBatter?: LineupPlayerState | null
|
currentBatter?: LineupPlayerState | null
|
||||||
currentPitcher?: LineupPlayerState | null
|
currentPitcher?: LineupPlayerState | null
|
||||||
|
fieldingLineup?: Lineup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
runners: () => ({ first: false, second: false, third: false }),
|
runners: () => ({ first: false, second: false, third: false }),
|
||||||
currentBatter: null,
|
currentBatter: null,
|
||||||
currentPitcher: null
|
currentPitcher: null,
|
||||||
|
fieldingLineup: () => []
|
||||||
})
|
})
|
||||||
|
|
||||||
const gameStore = useGameStore()
|
const gameStore = useGameStore()
|
||||||
|
|
||||||
|
// UI State
|
||||||
|
const isExpanded = ref(false)
|
||||||
|
const isPlayerCardOpen = ref(false)
|
||||||
|
const selectedPlayerData = ref<{
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
image: string
|
||||||
|
headshot?: string
|
||||||
|
} | null>(null)
|
||||||
|
const selectedPlayerPosition = ref('')
|
||||||
|
|
||||||
// Resolve player data from lineup using lineup_id
|
// Resolve player data from lineup using lineup_id
|
||||||
const batterPlayer = computed(() => {
|
const batterPlayer = computed(() => {
|
||||||
if (!props.currentBatter) return null
|
if (!props.currentBatter) return null
|
||||||
@ -217,6 +340,66 @@ const pitcherPlayer = computed(() => {
|
|||||||
// Helper to get player name with fallback
|
// Helper to get player name with fallback
|
||||||
const getBatterName = computed(() => batterPlayer.value?.name ?? `Player #${props.currentBatter?.lineup_id}`)
|
const getBatterName = computed(() => batterPlayer.value?.name ?? `Player #${props.currentBatter?.lineup_id}`)
|
||||||
const getPitcherName = computed(() => pitcherPlayer.value?.name ?? `Player #${props.currentPitcher?.lineup_id}`)
|
const getPitcherName = computed(() => pitcherPlayer.value?.name ?? `Player #${props.currentPitcher?.lineup_id}`)
|
||||||
|
|
||||||
|
// Toggle expanded view
|
||||||
|
function toggleExpanded() {
|
||||||
|
isExpanded.value = !isExpanded.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open player card modal
|
||||||
|
function openPlayerCard(type: 'batter' | 'pitcher') {
|
||||||
|
const player = type === 'batter' ? batterPlayer.value : pitcherPlayer.value
|
||||||
|
const state = type === 'batter' ? props.currentBatter : props.currentPitcher
|
||||||
|
|
||||||
|
if (!player) return
|
||||||
|
|
||||||
|
selectedPlayerData.value = {
|
||||||
|
id: player.id,
|
||||||
|
name: player.name,
|
||||||
|
image: player.image || '',
|
||||||
|
headshot: player.headshot || undefined
|
||||||
|
}
|
||||||
|
selectedPlayerPosition.value = state?.position || (type === 'pitcher' ? 'P' : '')
|
||||||
|
isPlayerCardOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePlayerCard() {
|
||||||
|
isPlayerCardOpen.value = false
|
||||||
|
selectedPlayerData.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get fielder by position from lineup
|
||||||
|
function getFielderByPosition(position: string): Lineup | null {
|
||||||
|
return props.fieldingLineup.find(p => p.position === position) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open fielder card modal
|
||||||
|
function openFielderCard(position: string) {
|
||||||
|
const fielder = getFielderByPosition(position)
|
||||||
|
if (!fielder) return
|
||||||
|
|
||||||
|
selectedPlayerData.value = {
|
||||||
|
id: fielder.player.id,
|
||||||
|
name: fielder.player.name,
|
||||||
|
image: fielder.player.image || '',
|
||||||
|
headshot: fielder.player.headshot || undefined
|
||||||
|
}
|
||||||
|
selectedPlayerPosition.value = position
|
||||||
|
isPlayerCardOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get fielder display info (name initials and whether they exist)
|
||||||
|
function getFielderInfo(position: string): { initials: string; name: string; exists: boolean } {
|
||||||
|
const fielder = getFielderByPosition(position)
|
||||||
|
if (!fielder) {
|
||||||
|
return { initials: position, name: '', exists: false }
|
||||||
|
}
|
||||||
|
const nameParts = fielder.player.name.split(' ')
|
||||||
|
const initials = nameParts.length >= 2
|
||||||
|
? `${nameParts[0][0]}${nameParts[nameParts.length - 1][0]}`
|
||||||
|
: nameParts[0].substring(0, 2)
|
||||||
|
return { initials: initials.toUpperCase(), name: fielder.player.name, exists: true }
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -235,4 +418,90 @@ const getPitcherName = computed(() => pitcherPlayer.value?.name ?? `Player #${pr
|
|||||||
.animate-pulse-subtle {
|
.animate-pulse-subtle {
|
||||||
animation: pulse-subtle 2s ease-in-out infinite;
|
animation: pulse-subtle 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fielder buttons in expanded view */
|
||||||
|
.fielder-button {
|
||||||
|
@apply w-7 h-7 rounded-full border-2 border-white shadow-lg;
|
||||||
|
@apply flex items-center justify-center;
|
||||||
|
@apply bg-gray-500 text-white;
|
||||||
|
@apply cursor-pointer transition-all duration-200;
|
||||||
|
@apply hover:scale-110 hover:bg-gray-600;
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fielder-button.fielder-active {
|
||||||
|
@apply bg-blue-600 hover:bg-blue-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fielder-button.fielder-outfield {
|
||||||
|
@apply bg-green-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fielder-button.fielder-outfield.fielder-active {
|
||||||
|
@apply bg-emerald-600 hover:bg-emerald-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fielder-label {
|
||||||
|
@apply text-[9px] font-bold leading-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile info cards */
|
||||||
|
.info-card {
|
||||||
|
@apply rounded-lg p-3 border-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-batter {
|
||||||
|
@apply bg-red-50 border-red-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-pitcher {
|
||||||
|
@apply bg-blue-50 border-blue-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-label {
|
||||||
|
@apply text-xs font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-label-batter {
|
||||||
|
@apply text-red-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-label-pitcher {
|
||||||
|
@apply text-blue-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-name {
|
||||||
|
@apply text-sm font-bold text-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-detail {
|
||||||
|
@apply text-xs text-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.info-card-batter {
|
||||||
|
@apply bg-red-900/30 border-red-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-pitcher {
|
||||||
|
@apply bg-blue-900/30 border-blue-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-label-batter {
|
||||||
|
@apply text-red-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-label-pitcher {
|
||||||
|
@apply text-blue-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-name {
|
||||||
|
@apply text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-detail {
|
||||||
|
@apply text-gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -73,6 +73,7 @@
|
|||||||
:runners="runnersState"
|
:runners="runnersState"
|
||||||
:current-batter="gameState?.current_batter"
|
:current-batter="gameState?.current_batter"
|
||||||
:current-pitcher="gameState?.current_pitcher"
|
:current-pitcher="gameState?.current_pitcher"
|
||||||
|
:fielding-lineup="fieldingLineup"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Decision Panel (Phase F3) -->
|
<!-- Decision Panel (Phase F3) -->
|
||||||
@ -103,6 +104,8 @@
|
|||||||
:can-submit-outcome="canSubmitOutcome"
|
:can-submit-outcome="canSubmitOutcome"
|
||||||
:outs="gameState?.outs ?? 0"
|
:outs="gameState?.outs ?? 0"
|
||||||
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
||||||
|
:batter-player="batterPlayer"
|
||||||
|
:pitcher-player="pitcherPlayer"
|
||||||
@roll-dice="handleRollDice"
|
@roll-dice="handleRollDice"
|
||||||
@submit-outcome="handleSubmitOutcome"
|
@submit-outcome="handleSubmitOutcome"
|
||||||
@dismiss-result="handleDismissResult"
|
@dismiss-result="handleDismissResult"
|
||||||
@ -134,6 +137,7 @@
|
|||||||
:runners="runnersState"
|
:runners="runnersState"
|
||||||
:current-batter="gameState?.current_batter"
|
:current-batter="gameState?.current_batter"
|
||||||
:current-pitcher="gameState?.current_pitcher"
|
:current-pitcher="gameState?.current_pitcher"
|
||||||
|
:fielding-lineup="fieldingLineup"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -165,6 +169,8 @@
|
|||||||
:can-submit-outcome="canSubmitOutcome"
|
:can-submit-outcome="canSubmitOutcome"
|
||||||
:outs="gameState?.outs ?? 0"
|
:outs="gameState?.outs ?? 0"
|
||||||
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
||||||
|
:batter-player="batterPlayer"
|
||||||
|
:pitcher-player="pitcherPlayer"
|
||||||
@roll-dice="handleRollDice"
|
@roll-dice="handleRollDice"
|
||||||
@submit-outcome="handleSubmitOutcome"
|
@submit-outcome="handleSubmitOutcome"
|
||||||
@dismiss-result="handleDismissResult"
|
@dismiss-result="handleDismissResult"
|
||||||
@ -376,6 +382,21 @@ const pendingRoll = computed(() => gameStore.pendingRoll)
|
|||||||
const lastPlayResult = computed(() => gameStore.lastPlayResult)
|
const lastPlayResult = computed(() => gameStore.lastPlayResult)
|
||||||
const currentDecisionPrompt = computed(() => gameStore.currentDecisionPrompt)
|
const currentDecisionPrompt = computed(() => gameStore.currentDecisionPrompt)
|
||||||
|
|
||||||
|
// Resolved player data for post-roll card display
|
||||||
|
const batterPlayer = computed(() => {
|
||||||
|
const batterState = gameState.value?.current_batter
|
||||||
|
if (!batterState) return null
|
||||||
|
const lineup = gameStore.findPlayerInLineup(batterState.lineup_id)
|
||||||
|
return lineup?.player ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const pitcherPlayer = computed(() => {
|
||||||
|
const pitcherState = gameState.value?.current_pitcher
|
||||||
|
if (!pitcherState) return null
|
||||||
|
const lineup = gameStore.findPlayerInLineup(pitcherState.lineup_id)
|
||||||
|
return lineup?.player ?? null
|
||||||
|
})
|
||||||
|
|
||||||
// Local UI state
|
// Local UI state
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('connecting')
|
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('connecting')
|
||||||
@ -420,6 +441,23 @@ const runnersData = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Get active fielding lineup for GameBoard
|
||||||
|
const fieldingLineup = computed(() => {
|
||||||
|
if (!gameState.value) return []
|
||||||
|
|
||||||
|
// Top of inning: home team fields; Bottom: away team fields
|
||||||
|
const fieldingTeamId = gameState.value.half === 'top'
|
||||||
|
? gameState.value.home_team_id
|
||||||
|
: gameState.value.away_team_id
|
||||||
|
|
||||||
|
const lineup = fieldingTeamId === gameState.value.home_team_id
|
||||||
|
? gameStore.homeLineup
|
||||||
|
: gameStore.awayLineup
|
||||||
|
|
||||||
|
// Return only active players
|
||||||
|
return lineup.filter(p => p.is_active)
|
||||||
|
})
|
||||||
|
|
||||||
const currentTeam = computed(() => {
|
const currentTeam = computed(() => {
|
||||||
return gameState.value?.half === 'top' ? 'away' : 'home'
|
return gameState.value?.half === 'top' ? 'away' : 'home'
|
||||||
})
|
})
|
||||||
|
|||||||
@ -256,6 +256,18 @@ const formatTimestamp = (timestamp: string): string => {
|
|||||||
|
|
||||||
/* Dark mode support */
|
/* Dark mode support */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.roll-button {
|
||||||
|
@apply ring-2 ring-offset-2 ring-offset-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll-button-enabled {
|
||||||
|
@apply ring-green-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll-button-disabled {
|
||||||
|
@apply bg-gray-700 text-gray-400 ring-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
.dice-results {
|
.dice-results {
|
||||||
@apply from-blue-800 to-blue-900;
|
@apply from-blue-800 to-blue-900;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,13 +56,36 @@
|
|||||||
:pending-roll="pendingRoll"
|
:pending-roll="pendingRoll"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Post-Roll Card Display -->
|
||||||
|
<div v-if="activeCardPlayer" class="post-roll-card">
|
||||||
|
<!-- Card Header -->
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-label" :class="showBatterCard ? 'batter-label' : 'pitcher-label'">
|
||||||
|
{{ showBatterCard ? 'BATTER' : 'PITCHER' }} CARD
|
||||||
|
</span>
|
||||||
|
<span class="player-name">{{ activeCardPlayer.name }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Full-Width Card Image -->
|
||||||
|
<div class="card-image-wrapper">
|
||||||
|
<img
|
||||||
|
v-if="activeCardPlayer.image"
|
||||||
|
:src="activeCardPlayer.image"
|
||||||
|
:alt="`${activeCardPlayer.name} card`"
|
||||||
|
class="player-card-image"
|
||||||
|
>
|
||||||
|
<div v-else class="card-placeholder">
|
||||||
|
<span class="placeholder-initials">
|
||||||
|
{{ activeCardPlayer.name?.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() || '?' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="divider"/>
|
<div class="divider"/>
|
||||||
|
|
||||||
<ManualOutcomeEntry
|
<OutcomeWizard
|
||||||
:roll-data="pendingRoll"
|
|
||||||
:can-submit="canSubmitOutcome"
|
:can-submit="canSubmitOutcome"
|
||||||
:outs="outs"
|
|
||||||
:has-runners="hasRunners"
|
|
||||||
@submit="handleSubmitOutcome"
|
@submit="handleSubmitOutcome"
|
||||||
@cancel="handleCancelOutcome"
|
@cancel="handleCancelOutcome"
|
||||||
/>
|
/>
|
||||||
@ -104,9 +127,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { RollData, PlayResult, PlayOutcome } from '~/types'
|
import type { RollData, PlayResult, PlayOutcome, SbaPlayer } from '~/types'
|
||||||
import DiceRoller from './DiceRoller.vue'
|
import DiceRoller from './DiceRoller.vue'
|
||||||
import ManualOutcomeEntry from './ManualOutcomeEntry.vue'
|
import OutcomeWizard from './OutcomeWizard.vue'
|
||||||
import PlayResultDisplay from './PlayResult.vue'
|
import PlayResultDisplay from './PlayResult.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -118,11 +141,16 @@ interface Props {
|
|||||||
canSubmitOutcome: boolean
|
canSubmitOutcome: boolean
|
||||||
outs?: number
|
outs?: number
|
||||||
hasRunners?: boolean
|
hasRunners?: boolean
|
||||||
|
// Player data for post-roll card display
|
||||||
|
batterPlayer?: SbaPlayer | null
|
||||||
|
pitcherPlayer?: SbaPlayer | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
outs: 0,
|
outs: 0,
|
||||||
hasRunners: false,
|
hasRunners: false,
|
||||||
|
batterPlayer: null,
|
||||||
|
pitcherPlayer: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@ -135,6 +163,14 @@ const emit = defineEmits<{
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
// Post-roll card display: d6_one 1-3 = batter, 4-6 = pitcher
|
||||||
|
const showBatterCard = computed(() =>
|
||||||
|
props.pendingRoll && props.pendingRoll.d6_one <= 3
|
||||||
|
)
|
||||||
|
const activeCardPlayer = computed(() =>
|
||||||
|
showBatterCard.value ? props.batterPlayer : props.pitcherPlayer
|
||||||
|
)
|
||||||
|
|
||||||
// Workflow state computation
|
// Workflow state computation
|
||||||
type WorkflowState = 'idle' | 'ready_to_roll' | 'rolled' | 'submitted' | 'result'
|
type WorkflowState = 'idle' | 'ready_to_roll' | 'rolled' | 'submitted' | 'result'
|
||||||
|
|
||||||
@ -300,6 +336,50 @@ const handleDismissResult = () => {
|
|||||||
@apply space-y-6;
|
@apply space-y-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Post-Roll Card Display */
|
||||||
|
.post-roll-card {
|
||||||
|
@apply bg-gradient-to-br from-amber-50 to-orange-50 rounded-xl p-4;
|
||||||
|
@apply border-2 border-amber-200 shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
@apply flex items-center gap-3 mb-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-label {
|
||||||
|
@apply text-xs font-bold uppercase tracking-wider px-2 py-0.5 rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batter-label {
|
||||||
|
@apply bg-red-100 text-red-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pitcher-label {
|
||||||
|
@apply bg-blue-100 text-blue-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header .player-name {
|
||||||
|
@apply text-lg font-bold text-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-image-wrapper {
|
||||||
|
@apply w-full rounded-lg overflow-hidden shadow-lg;
|
||||||
|
@apply ring-2 ring-amber-300 ring-offset-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card-image {
|
||||||
|
@apply w-full h-auto object-contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-placeholder {
|
||||||
|
@apply w-full h-48 bg-gradient-to-br from-gray-300 to-gray-400;
|
||||||
|
@apply flex items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-initials {
|
||||||
|
@apply text-4xl font-bold text-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
@apply border-t-2 border-gray-200;
|
@apply border-t-2 border-gray-200;
|
||||||
}
|
}
|
||||||
@ -318,11 +398,12 @@ const handleDismissResult = () => {
|
|||||||
/* Dark mode support */
|
/* Dark mode support */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.panel-container {
|
.panel-container {
|
||||||
@apply from-gray-800 to-gray-900 border-gray-700;
|
@apply from-gray-800 to-gray-900 border-gray-600;
|
||||||
|
@apply ring-1 ring-gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
@apply bg-gray-800 border-gray-700;
|
@apply bg-gray-800 border-gray-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-title {
|
.panel-title {
|
||||||
@ -352,6 +433,35 @@ const handleDismissResult = () => {
|
|||||||
.error-message {
|
.error-message {
|
||||||
@apply bg-red-900 bg-opacity-30 border-red-700 text-red-300;
|
@apply bg-red-900 bg-opacity-30 border-red-700 text-red-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Post-roll card dark mode */
|
||||||
|
.post-roll-card {
|
||||||
|
@apply from-amber-900/30 to-orange-900/30 border-amber-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header .player-name {
|
||||||
|
@apply text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-image-wrapper {
|
||||||
|
@apply ring-amber-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-placeholder {
|
||||||
|
@apply from-gray-600 to-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-initials {
|
||||||
|
@apply text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batter-label {
|
||||||
|
@apply bg-red-900/50 text-red-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pitcher-label {
|
||||||
|
@apply bg-blue-900/50 text-blue-300;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile optimizations */
|
/* Mobile optimizations */
|
||||||
|
|||||||
504
frontend-sba/components/Gameplay/OutcomeWizard.vue
Normal file
504
frontend-sba/components/Gameplay/OutcomeWizard.vue
Normal file
@ -0,0 +1,504 @@
|
|||||||
|
<template>
|
||||||
|
<div class="outcome-wizard">
|
||||||
|
<!-- Progress Indicator -->
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-steps">
|
||||||
|
<div
|
||||||
|
v-for="step in totalSteps"
|
||||||
|
:key="step"
|
||||||
|
class="progress-step"
|
||||||
|
:class="{ active: currentStep >= step, current: currentStep === step }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="currentStep > 1"
|
||||||
|
class="back-button"
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Category Selection -->
|
||||||
|
<div v-if="currentStep === 1" class="step-content">
|
||||||
|
<h3 class="step-title">Select Outcome Type</h3>
|
||||||
|
<div class="category-grid">
|
||||||
|
<button
|
||||||
|
v-for="(config, category) in CATEGORY_CONFIG"
|
||||||
|
:key="category"
|
||||||
|
class="category-button"
|
||||||
|
:class="[config.bgColor, config.borderColor]"
|
||||||
|
@click="selectCategory(category as OutcomeCategory)"
|
||||||
|
>
|
||||||
|
<span class="category-label" :class="config.color">{{ config.label }}</span>
|
||||||
|
<span class="category-description">{{ config.description }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Sub-Category Selection (ON_BASE or OUT) -->
|
||||||
|
<div v-else-if="currentStep === 2 && selectedCategory !== 'X_CHECK'" class="step-content">
|
||||||
|
<h3 class="step-title">
|
||||||
|
{{ selectedCategory === 'ON_BASE' ? 'Select Hit Type' : 'Select Out Type' }}
|
||||||
|
</h3>
|
||||||
|
<div class="subcategory-grid">
|
||||||
|
<button
|
||||||
|
v-for="(config, subCategory) in currentSubCategories"
|
||||||
|
:key="subCategory"
|
||||||
|
class="subcategory-button"
|
||||||
|
@click="selectSubCategory(subCategory)"
|
||||||
|
>
|
||||||
|
{{ config.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Specific Outcome Selection (if multiple options) -->
|
||||||
|
<div v-else-if="currentStep === 3 && currentOutcomes.length > 1" class="step-content">
|
||||||
|
<h3 class="step-title">Select Specific Outcome</h3>
|
||||||
|
<div class="outcome-grid">
|
||||||
|
<button
|
||||||
|
v-for="outcome in currentOutcomes"
|
||||||
|
:key="outcome"
|
||||||
|
class="outcome-button"
|
||||||
|
@click="selectOutcome(outcome)"
|
||||||
|
>
|
||||||
|
{{ getOutcomeLabel(outcome) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4: Hit Location Selection (when required) -->
|
||||||
|
<div v-else-if="showLocationStep" class="step-content">
|
||||||
|
<h3 class="step-title">Select Hit Location</h3>
|
||||||
|
<div class="location-field">
|
||||||
|
<!-- Diamond visualization for location selection -->
|
||||||
|
<div class="location-diamond">
|
||||||
|
<button
|
||||||
|
v-for="loc in HIT_LOCATIONS"
|
||||||
|
:key="loc.id"
|
||||||
|
class="location-button"
|
||||||
|
:class="getLocationClass(loc.id)"
|
||||||
|
:title="loc.position"
|
||||||
|
@click="selectLocation(loc.id)"
|
||||||
|
>
|
||||||
|
{{ loc.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cancel Button -->
|
||||||
|
<div class="action-bar">
|
||||||
|
<button
|
||||||
|
class="cancel-button"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { PlayOutcome } from '~/types/game'
|
||||||
|
import {
|
||||||
|
type OutcomeCategory,
|
||||||
|
type OnBaseSubCategory,
|
||||||
|
type OutSubCategory,
|
||||||
|
CATEGORY_CONFIG,
|
||||||
|
ON_BASE_OUTCOMES,
|
||||||
|
OUT_OUTCOMES,
|
||||||
|
X_CHECK_OUTCOMES,
|
||||||
|
HIT_LOCATIONS,
|
||||||
|
requiresHitLocation,
|
||||||
|
getOutcomeLabel,
|
||||||
|
} from '~/constants/outcomeFlow'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
canSubmit?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
canSubmit: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [{ outcome: PlayOutcome; hitLocation?: string }]
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Wizard state
|
||||||
|
const currentStep = ref(1)
|
||||||
|
const selectedCategory = ref<OutcomeCategory | null>(null)
|
||||||
|
const selectedSubCategory = ref<OnBaseSubCategory | OutSubCategory | null>(null)
|
||||||
|
const selectedOutcome = ref<PlayOutcome | null>(null)
|
||||||
|
const selectedLocation = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const totalSteps = computed(() => {
|
||||||
|
if (!selectedCategory.value) return 3
|
||||||
|
if (selectedCategory.value === 'X_CHECK') return 2
|
||||||
|
return 4
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentSubCategories = computed(() => {
|
||||||
|
if (selectedCategory.value === 'ON_BASE') return ON_BASE_OUTCOMES
|
||||||
|
if (selectedCategory.value === 'OUT') return OUT_OUTCOMES
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentOutcomes = computed<PlayOutcome[]>(() => {
|
||||||
|
if (selectedCategory.value === 'X_CHECK') return X_CHECK_OUTCOMES
|
||||||
|
|
||||||
|
if (!selectedSubCategory.value) return []
|
||||||
|
|
||||||
|
if (selectedCategory.value === 'ON_BASE') {
|
||||||
|
return ON_BASE_OUTCOMES[selectedSubCategory.value as OnBaseSubCategory]?.outcomes || []
|
||||||
|
}
|
||||||
|
if (selectedCategory.value === 'OUT') {
|
||||||
|
return OUT_OUTCOMES[selectedSubCategory.value as OutSubCategory]?.outcomes || []
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const showLocationStep = computed(() => {
|
||||||
|
return selectedOutcome.value && requiresHitLocation(selectedOutcome.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
function selectCategory(category: OutcomeCategory) {
|
||||||
|
selectedCategory.value = category
|
||||||
|
|
||||||
|
if (category === 'X_CHECK') {
|
||||||
|
// X-Check goes directly to location selection
|
||||||
|
selectedOutcome.value = 'x_check'
|
||||||
|
currentStep.value = 2
|
||||||
|
} else {
|
||||||
|
currentStep.value = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSubCategory(subCategory: string) {
|
||||||
|
selectedSubCategory.value = subCategory as OnBaseSubCategory | OutSubCategory
|
||||||
|
|
||||||
|
const outcomes = currentOutcomes.value
|
||||||
|
if (outcomes.length === 1) {
|
||||||
|
// Single outcome - auto-select and check if location needed
|
||||||
|
selectOutcome(outcomes[0])
|
||||||
|
} else {
|
||||||
|
currentStep.value = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectOutcome(outcome: PlayOutcome) {
|
||||||
|
selectedOutcome.value = outcome
|
||||||
|
|
||||||
|
if (requiresHitLocation(outcome)) {
|
||||||
|
currentStep.value = 4
|
||||||
|
} else {
|
||||||
|
// Submit directly
|
||||||
|
submitOutcome()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLocation(location: string) {
|
||||||
|
selectedLocation.value = location
|
||||||
|
submitOutcome()
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitOutcome() {
|
||||||
|
if (!selectedOutcome.value) return
|
||||||
|
if (!props.canSubmit) return
|
||||||
|
|
||||||
|
emit('submit', {
|
||||||
|
outcome: selectedOutcome.value,
|
||||||
|
hitLocation: selectedLocation.value || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset wizard
|
||||||
|
resetWizard()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (currentStep.value === 4) {
|
||||||
|
// Going back from location
|
||||||
|
if (selectedCategory.value === 'X_CHECK') {
|
||||||
|
currentStep.value = 1
|
||||||
|
selectedCategory.value = null
|
||||||
|
selectedOutcome.value = null
|
||||||
|
} else if (currentOutcomes.value.length === 1) {
|
||||||
|
// Was auto-selected, go back to subcategory
|
||||||
|
currentStep.value = 2
|
||||||
|
selectedOutcome.value = null
|
||||||
|
} else {
|
||||||
|
currentStep.value = 3
|
||||||
|
}
|
||||||
|
} else if (currentStep.value === 3) {
|
||||||
|
currentStep.value = 2
|
||||||
|
selectedSubCategory.value = null
|
||||||
|
} else if (currentStep.value === 2) {
|
||||||
|
currentStep.value = 1
|
||||||
|
selectedCategory.value = null
|
||||||
|
selectedSubCategory.value = null
|
||||||
|
selectedOutcome.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
resetWizard()
|
||||||
|
emit('cancel')
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetWizard() {
|
||||||
|
currentStep.value = 1
|
||||||
|
selectedCategory.value = null
|
||||||
|
selectedSubCategory.value = null
|
||||||
|
selectedOutcome.value = null
|
||||||
|
selectedLocation.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocationClass(locationId: string): string {
|
||||||
|
// Position classes for diamond layout
|
||||||
|
const positionClasses: Record<string, string> = {
|
||||||
|
P: 'location-pitcher',
|
||||||
|
C: 'location-catcher',
|
||||||
|
'1B': 'location-first',
|
||||||
|
'2B': 'location-second',
|
||||||
|
SS: 'location-shortstop',
|
||||||
|
'3B': 'location-third',
|
||||||
|
LF: 'location-left',
|
||||||
|
CF: 'location-center',
|
||||||
|
RF: 'location-right',
|
||||||
|
}
|
||||||
|
return positionClasses[locationId] || ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.outcome-wizard {
|
||||||
|
@apply space-y-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.progress-bar {
|
||||||
|
@apply flex items-center justify-between mb-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-steps {
|
||||||
|
@apply flex gap-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step {
|
||||||
|
@apply w-8 h-1.5 bg-gray-200 rounded-full transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.active {
|
||||||
|
@apply bg-blue-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.current {
|
||||||
|
@apply bg-blue-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
@apply flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-gray-600;
|
||||||
|
@apply hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step Content */
|
||||||
|
.step-content {
|
||||||
|
@apply space-y-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-title {
|
||||||
|
@apply text-lg font-bold text-gray-900 text-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category Grid */
|
||||||
|
.category-grid {
|
||||||
|
@apply grid grid-cols-1 gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.category-grid {
|
||||||
|
@apply grid-cols-3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-button {
|
||||||
|
@apply flex flex-col items-center justify-center p-6 rounded-xl border-2;
|
||||||
|
@apply transition-all duration-200 min-h-[100px];
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-label {
|
||||||
|
@apply text-xl font-bold mb-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-description {
|
||||||
|
@apply text-sm text-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subcategory Grid */
|
||||||
|
.subcategory-grid {
|
||||||
|
@apply grid grid-cols-2 gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.subcategory-grid {
|
||||||
|
@apply grid-cols-3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subcategory-button {
|
||||||
|
@apply p-4 rounded-xl border-2 border-gray-200 bg-white;
|
||||||
|
@apply font-semibold text-gray-700;
|
||||||
|
@apply hover:bg-gray-50 hover:border-gray-300;
|
||||||
|
@apply transition-all duration-200;
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outcome Grid */
|
||||||
|
.outcome-grid {
|
||||||
|
@apply grid grid-cols-1 gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.outcome-grid {
|
||||||
|
@apply grid-cols-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.outcome-button {
|
||||||
|
@apply p-3 rounded-lg border-2 border-gray-200 bg-white;
|
||||||
|
@apply text-sm font-medium text-gray-700;
|
||||||
|
@apply hover:bg-blue-50 hover:border-blue-300;
|
||||||
|
@apply transition-all duration-200;
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Location Field */
|
||||||
|
.location-field {
|
||||||
|
@apply flex justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-diamond {
|
||||||
|
@apply relative w-64 h-64 bg-gradient-to-br from-green-600 to-green-700 rounded-lg overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-button {
|
||||||
|
@apply absolute w-10 h-10 rounded-full bg-white/90 hover:bg-white;
|
||||||
|
@apply text-xs font-bold text-gray-800;
|
||||||
|
@apply shadow-lg border-2 border-gray-300 hover:border-blue-400;
|
||||||
|
@apply transition-all duration-200;
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-blue-400;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Location positions */
|
||||||
|
.location-pitcher {
|
||||||
|
top: 55%;
|
||||||
|
left: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-catcher {
|
||||||
|
top: 90%;
|
||||||
|
left: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-first {
|
||||||
|
top: 50%;
|
||||||
|
left: 75%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-second {
|
||||||
|
top: 35%;
|
||||||
|
left: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-shortstop {
|
||||||
|
top: 35%;
|
||||||
|
left: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-third {
|
||||||
|
top: 50%;
|
||||||
|
left: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-left {
|
||||||
|
top: 15%;
|
||||||
|
left: 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-center {
|
||||||
|
top: 10%;
|
||||||
|
left: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-right {
|
||||||
|
top: 15%;
|
||||||
|
left: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Bar */
|
||||||
|
.action-bar {
|
||||||
|
@apply flex justify-center pt-4 border-t border-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button {
|
||||||
|
@apply px-6 py-2 text-sm font-medium text-gray-600;
|
||||||
|
@apply hover:text-gray-900 hover:bg-gray-100;
|
||||||
|
@apply rounded-lg transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.step-title {
|
||||||
|
@apply text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step {
|
||||||
|
@apply bg-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.active {
|
||||||
|
@apply bg-blue-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
@apply text-gray-400 hover:text-gray-200 hover:bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-description {
|
||||||
|
@apply text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subcategory-button {
|
||||||
|
@apply bg-gray-800 border-gray-700 text-gray-300;
|
||||||
|
@apply hover:bg-gray-700 hover:border-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outcome-button {
|
||||||
|
@apply bg-gray-800 border-gray-700 text-gray-300;
|
||||||
|
@apply hover:bg-blue-900/30 hover:border-blue-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-button {
|
||||||
|
@apply bg-gray-200 hover:bg-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
@apply border-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button {
|
||||||
|
@apply text-gray-400 hover:text-gray-200 hover:bg-gray-800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
351
frontend-sba/components/Player/PlayerCardModal.vue
Normal file
351
frontend-sba/components/Player/PlayerCardModal.vue
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal">
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="player-card-modal-overlay"
|
||||||
|
@click.self="close"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref="modalRef"
|
||||||
|
class="player-card-modal"
|
||||||
|
@touchstart="onTouchStart"
|
||||||
|
@touchmove="onTouchMove"
|
||||||
|
@touchend="onTouchEnd"
|
||||||
|
>
|
||||||
|
<!-- Drag handle for mobile -->
|
||||||
|
<div class="drag-handle">
|
||||||
|
<div class="drag-indicator" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Close button -->
|
||||||
|
<button
|
||||||
|
class="close-button"
|
||||||
|
@click="close"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Player card image -->
|
||||||
|
<div class="card-image-container">
|
||||||
|
<img
|
||||||
|
v-if="player?.image"
|
||||||
|
:src="player.image"
|
||||||
|
:alt="`${player.name} playing card`"
|
||||||
|
class="card-image"
|
||||||
|
@error="onImageError"
|
||||||
|
>
|
||||||
|
<div v-else class="card-placeholder">
|
||||||
|
<span class="placeholder-text">{{ playerInitials }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player info -->
|
||||||
|
<div class="player-info">
|
||||||
|
<h2 class="player-name">{{ player?.name || 'Unknown Player' }}</h2>
|
||||||
|
<div class="player-details">
|
||||||
|
<span v-if="position" class="position-badge">{{ position }}</span>
|
||||||
|
<span v-if="teamName" class="team-name">{{ teamName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Substitute button -->
|
||||||
|
<button
|
||||||
|
v-if="showSubstituteButton"
|
||||||
|
class="substitute-button"
|
||||||
|
@click="onSubstitute"
|
||||||
|
>
|
||||||
|
Substitute Player
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import type { Lineup, BenchPlayer } from '~/types/player'
|
||||||
|
|
||||||
|
interface PlayerData {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
image: string
|
||||||
|
headshot?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean
|
||||||
|
player: PlayerData | null
|
||||||
|
position?: string
|
||||||
|
teamName?: string
|
||||||
|
showSubstituteButton?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
position: '',
|
||||||
|
teamName: '',
|
||||||
|
showSubstituteButton: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
substitute: [playerId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modalRef = ref<HTMLElement | null>(null)
|
||||||
|
const touchStartY = ref(0)
|
||||||
|
const touchCurrentY = ref(0)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const playerInitials = computed(() => {
|
||||||
|
if (!props.player?.name) return '?'
|
||||||
|
const parts = props.player.name.split(' ')
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
|
||||||
|
}
|
||||||
|
return parts[0][0].toUpperCase()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const close = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubstitute = () => {
|
||||||
|
if (props.player) {
|
||||||
|
emit('substitute', props.player.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onImageError = (event: Event) => {
|
||||||
|
const target = event.target as HTMLImageElement
|
||||||
|
target.style.display = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch handling for swipe-to-close
|
||||||
|
const onTouchStart = (event: TouchEvent) => {
|
||||||
|
touchStartY.value = event.touches[0].clientY
|
||||||
|
isDragging.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTouchMove = (event: TouchEvent) => {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
touchCurrentY.value = event.touches[0].clientY
|
||||||
|
|
||||||
|
const deltaY = touchCurrentY.value - touchStartY.value
|
||||||
|
if (deltaY > 0 && modalRef.value) {
|
||||||
|
// Only allow dragging downward
|
||||||
|
modalRef.value.style.transform = `translateY(${deltaY}px)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTouchEnd = () => {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
isDragging.value = false
|
||||||
|
|
||||||
|
const deltaY = touchCurrentY.value - touchStartY.value
|
||||||
|
if (deltaY > 100) {
|
||||||
|
// Swipe down threshold reached - close the modal
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset position
|
||||||
|
if (modalRef.value) {
|
||||||
|
modalRef.value.style.transform = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset touch state when modal closes
|
||||||
|
watch(() => props.isOpen, (isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
touchStartY.value = 0
|
||||||
|
touchCurrentY.value = 0
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.player-card-modal-overlay {
|
||||||
|
@apply fixed inset-0 z-50 flex items-end justify-center;
|
||||||
|
@apply bg-black/60 backdrop-blur-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.player-card-modal-overlay {
|
||||||
|
@apply items-center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card-modal {
|
||||||
|
@apply relative bg-white rounded-t-2xl w-full max-w-md;
|
||||||
|
@apply pb-6 pt-2 px-4;
|
||||||
|
@apply shadow-2xl;
|
||||||
|
@apply transition-transform duration-200;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.player-card-modal {
|
||||||
|
@apply rounded-2xl;
|
||||||
|
max-height: 85vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drag handle */
|
||||||
|
.drag-handle {
|
||||||
|
@apply flex justify-center py-2 mb-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-indicator {
|
||||||
|
@apply w-10 h-1 bg-gray-300 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.drag-handle {
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Close button */
|
||||||
|
.close-button {
|
||||||
|
@apply absolute top-3 right-3 p-2 rounded-full;
|
||||||
|
@apply bg-gray-100 text-gray-600;
|
||||||
|
@apply hover:bg-gray-200 transition-colors;
|
||||||
|
@apply min-w-[44px] min-h-[44px] flex items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card image */
|
||||||
|
.card-image-container {
|
||||||
|
@apply flex justify-center mb-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-image {
|
||||||
|
@apply max-w-full max-h-[50vh] object-contain rounded-lg shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.card-image {
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-placeholder {
|
||||||
|
@apply w-48 h-64 bg-gradient-to-br from-gray-200 to-gray-300;
|
||||||
|
@apply rounded-lg flex items-center justify-center;
|
||||||
|
@apply shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-text {
|
||||||
|
@apply text-4xl font-bold text-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Player info */
|
||||||
|
.player-info {
|
||||||
|
@apply text-center mb-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-name {
|
||||||
|
@apply text-xl font-bold text-gray-900 mb-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-details {
|
||||||
|
@apply flex items-center justify-center gap-2 flex-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-badge {
|
||||||
|
@apply px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-name {
|
||||||
|
@apply text-gray-600 text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Substitute button */
|
||||||
|
.substitute-button {
|
||||||
|
@apply w-full py-4 px-6 rounded-xl;
|
||||||
|
@apply bg-gradient-to-r from-orange-500 to-orange-600 text-white;
|
||||||
|
@apply font-bold text-lg;
|
||||||
|
@apply hover:from-orange-600 hover:to-orange-700;
|
||||||
|
@apply active:scale-95 transition-all;
|
||||||
|
@apply min-h-[52px];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition animations */
|
||||||
|
.modal-enter-active {
|
||||||
|
@apply transition-all duration-300 ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-leave-active {
|
||||||
|
@apply transition-all duration-200 ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from {
|
||||||
|
@apply opacity-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from .player-card-modal {
|
||||||
|
@apply translate-y-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.modal-enter-from .player-card-modal {
|
||||||
|
@apply translate-y-0 scale-95;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-leave-to {
|
||||||
|
@apply opacity-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-leave-to .player-card-modal {
|
||||||
|
@apply translate-y-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.modal-leave-to .player-card-modal {
|
||||||
|
@apply translate-y-0 scale-95;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.player-card-modal {
|
||||||
|
@apply bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-indicator {
|
||||||
|
@apply bg-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
@apply bg-gray-700 text-gray-300 hover:bg-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-placeholder {
|
||||||
|
@apply from-gray-700 to-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-text {
|
||||||
|
@apply text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-name {
|
||||||
|
@apply text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-badge {
|
||||||
|
@apply bg-blue-900 text-blue-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-name {
|
||||||
|
@apply text-gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -88,4 +88,36 @@ button * {
|
|||||||
user-select: none !important;
|
user-select: none !important;
|
||||||
-webkit-touch-callout: none !important;
|
-webkit-touch-callout: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode - add visible borders since gradients may not render on some browsers */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
button {
|
||||||
|
@apply ring-2 ring-offset-1 ring-offset-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variant-specific ring colors */
|
||||||
|
button[class*="from-green"] {
|
||||||
|
@apply ring-green-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[class*="from-blue"], button[class*="from-primary"] {
|
||||||
|
@apply ring-blue-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[class*="from-red"] {
|
||||||
|
@apply ring-red-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[class*="from-yellow"] {
|
||||||
|
@apply ring-yellow-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[class*="from-gray"] {
|
||||||
|
@apply ring-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
@apply ring-gray-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
311
frontend-sba/components/UI/BottomSheet.vue
Normal file
311
frontend-sba/components/UI/BottomSheet.vue
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<!-- Backdrop (only visible when expanded) -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="isOpen && !isMinimized"
|
||||||
|
class="bottom-sheet-backdrop"
|
||||||
|
@click="minimize"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Bottom Sheet Container -->
|
||||||
|
<Transition name="slide">
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
ref="sheetRef"
|
||||||
|
class="bottom-sheet-container"
|
||||||
|
:class="{ 'is-minimized': isMinimized }"
|
||||||
|
:style="sheetStyle"
|
||||||
|
@touchstart="onTouchStart"
|
||||||
|
@touchmove="onTouchMove"
|
||||||
|
@touchend="onTouchEnd"
|
||||||
|
>
|
||||||
|
<!-- Drag Handle -->
|
||||||
|
<div class="drag-handle" @click="toggleMinimize">
|
||||||
|
<div class="drag-indicator" />
|
||||||
|
<span v-if="isMinimized" class="minimize-label">{{ title }} - Tap to expand</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sheet Content -->
|
||||||
|
<div v-show="!isMinimized" class="sheet-content">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Floating Restore Button (shown when minimized) -->
|
||||||
|
<Transition name="pop">
|
||||||
|
<button
|
||||||
|
v-if="isOpen && isMinimized && showFloatingButton"
|
||||||
|
class="floating-restore-button"
|
||||||
|
@click="expand"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
<span class="button-label">{{ title }}</span>
|
||||||
|
</button>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean
|
||||||
|
title?: string
|
||||||
|
showFloatingButton?: boolean
|
||||||
|
minimizeThreshold?: number
|
||||||
|
startMinimized?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
title: 'Panel',
|
||||||
|
showFloatingButton: true,
|
||||||
|
minimizeThreshold: 100,
|
||||||
|
startMinimized: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
minimize: []
|
||||||
|
expand: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// State
|
||||||
|
const sheetRef = ref<HTMLElement | null>(null)
|
||||||
|
const isMinimized = ref(props.startMinimized)
|
||||||
|
const touchStartY = ref(0)
|
||||||
|
const touchCurrentY = ref(0)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragOffset = ref(0)
|
||||||
|
|
||||||
|
// Computed style for drag animation
|
||||||
|
const sheetStyle = computed(() => {
|
||||||
|
if (isDragging.value && dragOffset.value > 0) {
|
||||||
|
return {
|
||||||
|
transform: `translateY(${dragOffset.value}px)`,
|
||||||
|
transition: 'none',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Touch handlers for swipe gestures
|
||||||
|
function onTouchStart(event: TouchEvent) {
|
||||||
|
touchStartY.value = event.touches[0].clientY
|
||||||
|
isDragging.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(event: TouchEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
|
||||||
|
touchCurrentY.value = event.touches[0].clientY
|
||||||
|
const deltaY = touchCurrentY.value - touchStartY.value
|
||||||
|
|
||||||
|
// Only allow dragging down (positive delta)
|
||||||
|
if (deltaY > 0 && !isMinimized.value) {
|
||||||
|
dragOffset.value = deltaY
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow dragging up when minimized
|
||||||
|
if (deltaY < 0 && isMinimized.value) {
|
||||||
|
// Subtle feedback for upward drag
|
||||||
|
dragOffset.value = Math.max(deltaY / 2, -30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd() {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
isDragging.value = false
|
||||||
|
|
||||||
|
const deltaY = touchCurrentY.value - touchStartY.value
|
||||||
|
|
||||||
|
// Swipe down to minimize
|
||||||
|
if (deltaY > props.minimizeThreshold && !isMinimized.value) {
|
||||||
|
minimize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swipe up to expand
|
||||||
|
if (deltaY < -50 && isMinimized.value) {
|
||||||
|
expand()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset drag offset
|
||||||
|
dragOffset.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
function minimize() {
|
||||||
|
isMinimized.value = true
|
||||||
|
emit('minimize')
|
||||||
|
}
|
||||||
|
|
||||||
|
function expand() {
|
||||||
|
isMinimized.value = false
|
||||||
|
emit('expand')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMinimize() {
|
||||||
|
if (isMinimized.value) {
|
||||||
|
expand()
|
||||||
|
} else {
|
||||||
|
minimize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset state when closed
|
||||||
|
watch(() => props.isOpen, (isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
isDragging.value = false
|
||||||
|
dragOffset.value = 0
|
||||||
|
touchStartY.value = 0
|
||||||
|
touchCurrentY.value = 0
|
||||||
|
} else if (props.startMinimized) {
|
||||||
|
isMinimized.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Prevent body scroll when sheet is open and expanded
|
||||||
|
watch([() => props.isOpen, isMinimized], ([open, minimized]) => {
|
||||||
|
if (open && !minimized) {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Backdrop */
|
||||||
|
.bottom-sheet-backdrop {
|
||||||
|
@apply fixed inset-0 z-40 bg-black/40 backdrop-blur-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container */
|
||||||
|
.bottom-sheet-container {
|
||||||
|
@apply fixed bottom-0 left-0 right-0 z-50;
|
||||||
|
@apply bg-white rounded-t-2xl shadow-2xl;
|
||||||
|
@apply max-h-[85vh] overflow-hidden;
|
||||||
|
@apply transition-all duration-300 ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet-container.is-minimized {
|
||||||
|
@apply max-h-14;
|
||||||
|
@apply shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drag Handle */
|
||||||
|
.drag-handle {
|
||||||
|
@apply flex flex-col items-center py-3 cursor-grab active:cursor-grabbing;
|
||||||
|
@apply border-b border-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-indicator {
|
||||||
|
@apply w-10 h-1 bg-gray-300 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimize-label {
|
||||||
|
@apply mt-1 text-xs font-medium text-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sheet Content */
|
||||||
|
.sheet-content {
|
||||||
|
@apply max-h-[calc(85vh-56px)] overflow-y-auto;
|
||||||
|
@apply p-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating Restore Button */
|
||||||
|
.floating-restore-button {
|
||||||
|
@apply fixed bottom-20 left-1/2 -translate-x-1/2 z-50;
|
||||||
|
@apply flex items-center gap-2 px-4 py-2;
|
||||||
|
@apply bg-blue-600 hover:bg-blue-700 text-white;
|
||||||
|
@apply rounded-full shadow-lg;
|
||||||
|
@apply font-medium text-sm;
|
||||||
|
@apply transition-all duration-200;
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-label {
|
||||||
|
@apply whitespace-nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
@apply transition-opacity duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
@apply opacity-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-enter-active {
|
||||||
|
@apply transition-transform duration-300 ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-leave-active {
|
||||||
|
@apply transition-transform duration-200 ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-enter-from,
|
||||||
|
.slide-leave-to {
|
||||||
|
@apply translate-y-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-enter-active {
|
||||||
|
@apply transition-all duration-300 ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-leave-active {
|
||||||
|
@apply transition-all duration-200 ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-enter-from,
|
||||||
|
.pop-leave-to {
|
||||||
|
@apply opacity-0 scale-75 translate-y-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.bottom-sheet-container {
|
||||||
|
@apply bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
@apply border-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-indicator {
|
||||||
|
@apply bg-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimize-label {
|
||||||
|
@apply text-gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop adjustments */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.bottom-sheet-container {
|
||||||
|
@apply max-w-lg left-1/2 -translate-x-1/2 right-auto;
|
||||||
|
@apply max-h-[70vh];
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet-container.is-minimized {
|
||||||
|
@apply translate-x-0 left-auto right-4 rounded-t-xl;
|
||||||
|
@apply max-w-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-restore-button {
|
||||||
|
@apply bottom-6 right-6 left-auto translate-x-0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
220
frontend-sba/constants/outcomeFlow.ts
Normal file
220
frontend-sba/constants/outcomeFlow.ts
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* Outcome Flow Constants
|
||||||
|
*
|
||||||
|
* Defines the hierarchical structure for the progressive disclosure
|
||||||
|
* outcome selection wizard. Three top-level categories branch into
|
||||||
|
* specific outcome types.
|
||||||
|
*
|
||||||
|
* Categories:
|
||||||
|
* - ON_BASE: Hits and walks that result in batter reaching base
|
||||||
|
* - OUT: Various types of outs
|
||||||
|
* - X_CHECK: Defensive X-Check resolution (errors result from this)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PlayOutcome } from '~/types/game'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level outcome categories for step 1 selection
|
||||||
|
*/
|
||||||
|
export type OutcomeCategory = 'ON_BASE' | 'OUT' | 'X_CHECK'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub-categories for ON_BASE outcomes
|
||||||
|
*/
|
||||||
|
export type OnBaseSubCategory = 'SINGLE' | 'DOUBLE' | 'TRIPLE' | 'HOME_RUN' | 'WALK' | 'HBP'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub-categories for OUT outcomes
|
||||||
|
*/
|
||||||
|
export type OutSubCategory = 'STRIKEOUT' | 'GROUNDOUT' | 'FLYOUT' | 'LINEOUT' | 'POPOUT'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category configuration with display info
|
||||||
|
*/
|
||||||
|
export interface CategoryConfig {
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
color: string
|
||||||
|
bgColor: string
|
||||||
|
borderColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub-category configuration with outcomes
|
||||||
|
*/
|
||||||
|
export interface SubCategoryConfig {
|
||||||
|
label: string
|
||||||
|
outcomes: PlayOutcome[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category display configuration
|
||||||
|
*/
|
||||||
|
export const CATEGORY_CONFIG: Record<OutcomeCategory, CategoryConfig> = {
|
||||||
|
ON_BASE: {
|
||||||
|
label: 'On Base',
|
||||||
|
description: 'Hit, walk, or HBP',
|
||||||
|
color: 'text-green-700',
|
||||||
|
bgColor: 'bg-green-50 hover:bg-green-100',
|
||||||
|
borderColor: 'border-green-300',
|
||||||
|
},
|
||||||
|
OUT: {
|
||||||
|
label: 'Out',
|
||||||
|
description: 'Strikeout, groundout, flyout',
|
||||||
|
color: 'text-red-700',
|
||||||
|
bgColor: 'bg-red-50 hover:bg-red-100',
|
||||||
|
borderColor: 'border-red-300',
|
||||||
|
},
|
||||||
|
X_CHECK: {
|
||||||
|
label: 'X-Check',
|
||||||
|
description: 'Defensive play check',
|
||||||
|
color: 'text-orange-700',
|
||||||
|
bgColor: 'bg-orange-50 hover:bg-orange-100',
|
||||||
|
borderColor: 'border-orange-300',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ON_BASE sub-categories and their specific outcomes
|
||||||
|
*/
|
||||||
|
export const ON_BASE_OUTCOMES: Record<OnBaseSubCategory, SubCategoryConfig> = {
|
||||||
|
SINGLE: {
|
||||||
|
label: 'Single',
|
||||||
|
outcomes: ['single_1', 'single_2', 'single_uncapped'],
|
||||||
|
},
|
||||||
|
DOUBLE: {
|
||||||
|
label: 'Double',
|
||||||
|
outcomes: ['double_2', 'double_3', 'double_uncapped'],
|
||||||
|
},
|
||||||
|
TRIPLE: {
|
||||||
|
label: 'Triple',
|
||||||
|
outcomes: ['triple'],
|
||||||
|
},
|
||||||
|
HOME_RUN: {
|
||||||
|
label: 'Home Run',
|
||||||
|
outcomes: ['homerun'],
|
||||||
|
},
|
||||||
|
WALK: {
|
||||||
|
label: 'Walk',
|
||||||
|
outcomes: ['walk', 'intentional_walk'],
|
||||||
|
},
|
||||||
|
HBP: {
|
||||||
|
label: 'Hit By Pitch',
|
||||||
|
outcomes: ['hbp'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OUT sub-categories and their specific outcomes
|
||||||
|
*/
|
||||||
|
export const OUT_OUTCOMES: Record<OutSubCategory, SubCategoryConfig> = {
|
||||||
|
STRIKEOUT: {
|
||||||
|
label: 'Strikeout',
|
||||||
|
outcomes: ['strikeout'],
|
||||||
|
},
|
||||||
|
GROUNDOUT: {
|
||||||
|
label: 'Groundout',
|
||||||
|
outcomes: ['groundball_a', 'groundball_b', 'groundball_c'],
|
||||||
|
},
|
||||||
|
FLYOUT: {
|
||||||
|
label: 'Flyout',
|
||||||
|
outcomes: ['flyout_a', 'flyout_b', 'flyout_bq', 'flyout_c'],
|
||||||
|
},
|
||||||
|
LINEOUT: {
|
||||||
|
label: 'Lineout',
|
||||||
|
outcomes: ['lineout'],
|
||||||
|
},
|
||||||
|
POPOUT: {
|
||||||
|
label: 'Popout',
|
||||||
|
outcomes: ['popout'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X-CHECK outcome (singular - error is a result, not input)
|
||||||
|
*/
|
||||||
|
export const X_CHECK_OUTCOMES: PlayOutcome[] = ['x_check']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome display labels for final selection
|
||||||
|
*/
|
||||||
|
export const OUTCOME_LABELS: Partial<Record<PlayOutcome, string>> = {
|
||||||
|
// Singles
|
||||||
|
single_1: 'Single (1 base)',
|
||||||
|
single_2: 'Single (2 bases)',
|
||||||
|
single_uncapped: 'Single (uncapped)',
|
||||||
|
|
||||||
|
// Doubles
|
||||||
|
double_2: 'Double (2 bases)',
|
||||||
|
double_3: 'Double (3 bases)',
|
||||||
|
double_uncapped: 'Double (uncapped)',
|
||||||
|
|
||||||
|
// Other hits
|
||||||
|
triple: 'Triple',
|
||||||
|
homerun: 'Home Run',
|
||||||
|
|
||||||
|
// Walks
|
||||||
|
walk: 'Walk',
|
||||||
|
intentional_walk: 'Intentional Walk',
|
||||||
|
hbp: 'Hit By Pitch',
|
||||||
|
|
||||||
|
// Outs
|
||||||
|
strikeout: 'Strikeout',
|
||||||
|
groundball_a: 'Groundball A',
|
||||||
|
groundball_b: 'Groundball B',
|
||||||
|
groundball_c: 'Groundball C',
|
||||||
|
flyout_a: 'Flyout A',
|
||||||
|
flyout_b: 'Flyout B',
|
||||||
|
flyout_bq: 'Flyout B*',
|
||||||
|
flyout_c: 'Flyout C',
|
||||||
|
lineout: 'Lineout',
|
||||||
|
popout: 'Popout',
|
||||||
|
|
||||||
|
// X-Check
|
||||||
|
x_check: 'X-Check',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hit location positions for location selection step
|
||||||
|
*/
|
||||||
|
export const HIT_LOCATIONS = [
|
||||||
|
{ id: 'P', label: 'P', position: 'Pitcher' },
|
||||||
|
{ id: 'C', label: 'C', position: 'Catcher' },
|
||||||
|
{ id: '1B', label: '1B', position: 'First Base' },
|
||||||
|
{ id: '2B', label: '2B', position: 'Second Base' },
|
||||||
|
{ id: 'SS', label: 'SS', position: 'Shortstop' },
|
||||||
|
{ id: '3B', label: '3B', position: 'Third Base' },
|
||||||
|
{ id: 'LF', label: 'LF', position: 'Left Field' },
|
||||||
|
{ id: 'CF', label: 'CF', position: 'Center Field' },
|
||||||
|
{ id: 'RF', label: 'RF', position: 'Right Field' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcomes that require hit location selection
|
||||||
|
*/
|
||||||
|
export const OUTCOMES_REQUIRING_LOCATION: PlayOutcome[] = [
|
||||||
|
'groundball_a',
|
||||||
|
'groundball_b',
|
||||||
|
'groundball_c',
|
||||||
|
'flyout_a',
|
||||||
|
'flyout_b',
|
||||||
|
'flyout_bq',
|
||||||
|
'flyout_c',
|
||||||
|
'lineout',
|
||||||
|
'popout',
|
||||||
|
'x_check',
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an outcome requires a hit location
|
||||||
|
*/
|
||||||
|
export function requiresHitLocation(outcome: PlayOutcome): boolean {
|
||||||
|
return OUTCOMES_REQUIRING_LOCATION.includes(outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the display label for an outcome
|
||||||
|
*/
|
||||||
|
export function getOutcomeLabel(outcome: PlayOutcome): string {
|
||||||
|
return OUTCOME_LABELS[outcome] || outcome
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'
|
|||||||
import GameplayPanel from '~/components/Gameplay/GameplayPanel.vue'
|
import GameplayPanel from '~/components/Gameplay/GameplayPanel.vue'
|
||||||
import type { RollData, PlayResult } from '~/types'
|
import type { RollData, PlayResult } from '~/types'
|
||||||
import DiceRoller from '~/components/Gameplay/DiceRoller.vue'
|
import DiceRoller from '~/components/Gameplay/DiceRoller.vue'
|
||||||
import ManualOutcomeEntry from '~/components/Gameplay/ManualOutcomeEntry.vue'
|
import OutcomeWizard from '~/components/Gameplay/OutcomeWizard.vue'
|
||||||
import PlayResultComponent from '~/components/Gameplay/PlayResult.vue'
|
import PlayResultComponent from '~/components/Gameplay/PlayResult.vue'
|
||||||
|
|
||||||
describe('GameplayPanel', () => {
|
describe('GameplayPanel', () => {
|
||||||
@ -201,7 +201,7 @@ describe('GameplayPanel', () => {
|
|||||||
expect(diceRoller.props('canRoll')).toBe(false)
|
expect(diceRoller.props('canRoll')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders ManualOutcomeEntry component', () => {
|
it('renders OutcomeWizard component', () => {
|
||||||
const wrapper = mount(GameplayPanel, {
|
const wrapper = mount(GameplayPanel, {
|
||||||
props: {
|
props: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
@ -210,7 +210,7 @@ describe('GameplayPanel', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.findComponent(ManualOutcomeEntry).exists()).toBe(true)
|
expect(wrapper.findComponent(OutcomeWizard).exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('displays active status when outcome entry active', () => {
|
it('displays active status when outcome entry active', () => {
|
||||||
@ -315,9 +315,9 @@ describe('GameplayPanel', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const outcomeEntry = wrapper.findComponent(ManualOutcomeEntry)
|
const outcomeWizard = wrapper.findComponent(OutcomeWizard)
|
||||||
const payload = { outcome: 'STRIKEOUT' as const, hitLocation: undefined }
|
const payload = { outcome: 'STRIKEOUT' as const, hitLocation: undefined }
|
||||||
await outcomeEntry.vm.$emit('submit', payload)
|
await outcomeWizard.vm.$emit('submit', payload)
|
||||||
|
|
||||||
expect(wrapper.emitted('submitOutcome')).toBeTruthy()
|
expect(wrapper.emitted('submitOutcome')).toBeTruthy()
|
||||||
expect(wrapper.emitted('submitOutcome')?.[0]).toEqual([payload])
|
expect(wrapper.emitted('submitOutcome')?.[0]).toEqual([payload])
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user