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>
505 lines
12 KiB
Vue
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>
|