strat-gameplay-webapp/frontend-sba/components/Player/PlayerCardModal.vue
Cal Corum be31e2ccb4 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>
2026-01-23 15:23:38 -06:00

352 lines
7.4 KiB
Vue

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