strat-gameplay-webapp/frontend-sba/components/Gameplay/OutcomeWizard.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

505 lines
12 KiB
Vue

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