- Evolution model with trigger, level, item, and condition fields
- Encounter.current_pokemon_id tracks evolved species separately
- Alembic migration for evolutions table and current_pokemon_id column
- Seed pipeline loads evolution data with manual overrides
- GET /pokemon/{id}/evolutions and PATCH /encounters/{id} endpoints
- Evolve button in StatusChangeModal with evolution method details
- PokemonCard shows evolved species with "Originally" label
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
352 lines
13 KiB
TypeScript
352 lines
13 KiB
TypeScript
import { useState } from 'react'
|
|
import type { EncounterDetail, UpdateEncounterInput } from '../types'
|
|
import { useEvolutions } from '../hooks/useEncounters'
|
|
|
|
interface StatusChangeModalProps {
|
|
encounter: EncounterDetail
|
|
onUpdate: (data: {
|
|
id: number
|
|
data: UpdateEncounterInput
|
|
}) => void
|
|
onClose: () => void
|
|
isPending: boolean
|
|
}
|
|
|
|
const typeColors: Record<string, string> = {
|
|
normal: 'bg-gray-400',
|
|
fire: 'bg-red-500',
|
|
water: 'bg-blue-500',
|
|
electric: 'bg-yellow-400',
|
|
grass: 'bg-green-500',
|
|
ice: 'bg-cyan-300',
|
|
fighting: 'bg-red-700',
|
|
poison: 'bg-purple-500',
|
|
ground: 'bg-amber-600',
|
|
flying: 'bg-indigo-300',
|
|
psychic: 'bg-pink-500',
|
|
bug: 'bg-lime-500',
|
|
rock: 'bg-amber-700',
|
|
ghost: 'bg-purple-700',
|
|
dragon: 'bg-indigo-600',
|
|
dark: 'bg-gray-700',
|
|
steel: 'bg-gray-400',
|
|
fairy: 'bg-pink-300',
|
|
}
|
|
|
|
function formatEvolutionMethod(evo: { trigger: string; minLevel: number | null; item: string | null; heldItem: string | null; condition: string | null }): string {
|
|
const parts: string[] = []
|
|
if (evo.trigger === 'level-up' && evo.minLevel) {
|
|
parts.push(`Level ${evo.minLevel}`)
|
|
} else if (evo.trigger === 'level-up') {
|
|
parts.push('Level up')
|
|
} else if (evo.trigger === 'use-item' && evo.item) {
|
|
parts.push(evo.item.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
|
|
} else if (evo.trigger === 'trade') {
|
|
parts.push('Trade')
|
|
} else {
|
|
parts.push(evo.trigger.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
|
|
}
|
|
if (evo.heldItem) {
|
|
parts.push(`holding ${evo.heldItem.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}`)
|
|
}
|
|
if (evo.condition) {
|
|
parts.push(evo.condition)
|
|
}
|
|
return parts.join(', ')
|
|
}
|
|
|
|
export function StatusChangeModal({
|
|
encounter,
|
|
onUpdate,
|
|
onClose,
|
|
isPending,
|
|
}: StatusChangeModalProps) {
|
|
const { pokemon, currentPokemon, route, nickname, catchLevel, faintLevel, deathCause } =
|
|
encounter
|
|
const isDead = faintLevel !== null
|
|
const displayPokemon = currentPokemon ?? pokemon
|
|
const [showConfirm, setShowConfirm] = useState(false)
|
|
const [showEvolve, setShowEvolve] = useState(false)
|
|
const [deathLevel, setDeathLevel] = useState('')
|
|
const [cause, setCause] = useState('')
|
|
|
|
const activePokemonId = currentPokemon?.id ?? pokemon.id
|
|
const { data: evolutions, isLoading: evolutionsLoading } = useEvolutions(
|
|
showEvolve ? activePokemonId : null
|
|
)
|
|
|
|
const handleConfirmDeath = () => {
|
|
onUpdate({
|
|
id: encounter.id,
|
|
data: {
|
|
faintLevel: deathLevel ? Number(deathLevel) : undefined,
|
|
deathCause: cause || undefined,
|
|
},
|
|
})
|
|
}
|
|
|
|
const handleEvolve = (toPokemonId: number) => {
|
|
onUpdate({
|
|
id: encounter.id,
|
|
data: { currentPokemonId: toPokemonId },
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
|
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-sm w-full">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
{isDead ? 'Death Details' : 'Pokemon Status'}
|
|
</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
|
>
|
|
<svg
|
|
className="w-5 h-5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="px-6 py-4">
|
|
{/* Pokemon info */}
|
|
<div className="flex items-center gap-4 mb-4">
|
|
{displayPokemon.spriteUrl ? (
|
|
<img
|
|
src={displayPokemon.spriteUrl}
|
|
alt={displayPokemon.name}
|
|
className={`w-16 h-16 ${isDead ? 'grayscale opacity-60' : ''}`}
|
|
/>
|
|
) : (
|
|
<div className="w-16 h-16 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
|
|
{displayPokemon.name[0].toUpperCase()}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<div className="font-semibold text-gray-900 dark:text-gray-100">
|
|
{nickname || displayPokemon.name}
|
|
</div>
|
|
{nickname && (
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
|
{displayPokemon.name}
|
|
</div>
|
|
)}
|
|
<div className="flex gap-1 mt-1">
|
|
{displayPokemon.types.map((type) => (
|
|
<span
|
|
key={type}
|
|
className={`px-1.5 py-0.5 rounded text-[10px] font-medium text-white ${typeColors[type] ?? 'bg-gray-500'}`}
|
|
>
|
|
{type}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
Lv. {catchLevel ?? '?'} · {route.name}
|
|
</div>
|
|
{currentPokemon && (
|
|
<div className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">
|
|
Originally: {pokemon.name}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dead pokemon: view-only details */}
|
|
{isDead && (
|
|
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-4 space-y-2">
|
|
<div className="flex items-center gap-2 text-red-700 dark:text-red-400 font-medium text-sm">
|
|
<span className="w-2 h-2 rounded-full bg-red-500" />
|
|
Deceased
|
|
</div>
|
|
{faintLevel !== null && (
|
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
|
<span className="text-gray-500 dark:text-gray-400">
|
|
Level at death:
|
|
</span>{' '}
|
|
{faintLevel}
|
|
</div>
|
|
)}
|
|
{deathCause && (
|
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
|
<span className="text-gray-500 dark:text-gray-400">
|
|
Cause:
|
|
</span>{' '}
|
|
{deathCause}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Alive pokemon: actions */}
|
|
{!isDead && !showConfirm && !showEvolve && (
|
|
<div className="flex gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowEvolve(true)}
|
|
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
|
>
|
|
Evolve
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowConfirm(true)}
|
|
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
|
|
>
|
|
Mark as Dead
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Evolution selection */}
|
|
{!isDead && showEvolve && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Evolve into:
|
|
</h3>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowEvolve(false)}
|
|
className="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
|
>
|
|
Back
|
|
</button>
|
|
</div>
|
|
{evolutionsLoading && (
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">Loading evolutions...</p>
|
|
)}
|
|
{!evolutionsLoading && evolutions && evolutions.length === 0 && (
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">No evolutions available</p>
|
|
)}
|
|
{!evolutionsLoading && evolutions && evolutions.length > 0 && (
|
|
<div className="space-y-2">
|
|
{evolutions.map((evo) => (
|
|
<button
|
|
key={evo.id}
|
|
type="button"
|
|
disabled={isPending}
|
|
onClick={() => handleEvolve(evo.toPokemon.id)}
|
|
className="w-full flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:border-blue-300 dark:hover:border-blue-600 transition-colors disabled:opacity-50"
|
|
>
|
|
{evo.toPokemon.spriteUrl ? (
|
|
<img src={evo.toPokemon.spriteUrl} alt={evo.toPokemon.name} className="w-10 h-10" />
|
|
) : (
|
|
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300">
|
|
{evo.toPokemon.name[0].toUpperCase()}
|
|
</div>
|
|
)}
|
|
<div className="text-left">
|
|
<div className="font-medium text-gray-900 dark:text-gray-100 text-sm">
|
|
{evo.toPokemon.name}
|
|
</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
{formatEvolutionMethod(evo)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Confirmation form */}
|
|
{!isDead && showConfirm && (
|
|
<div className="space-y-3">
|
|
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
|
<p className="text-sm text-red-700 dark:text-red-400 font-medium">
|
|
This cannot be undone (Nuzlocke rules).
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="death-level"
|
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
|
>
|
|
Level at Death{' '}
|
|
<span className="font-normal text-gray-400">(optional)</span>
|
|
</label>
|
|
<input
|
|
id="death-level"
|
|
type="number"
|
|
min={1}
|
|
max={100}
|
|
value={deathLevel}
|
|
onChange={(e) => setDeathLevel(e.target.value)}
|
|
placeholder="Level"
|
|
className="w-24 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-red-500"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="death-cause"
|
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
|
>
|
|
Cause of Death{' '}
|
|
<span className="font-normal text-gray-400">(optional)</span>
|
|
</label>
|
|
<input
|
|
id="death-cause"
|
|
type="text"
|
|
maxLength={100}
|
|
value={cause}
|
|
onChange={(e) => setCause(e.target.value)}
|
|
placeholder="e.g. Crit from rival's Charizard"
|
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-red-500"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-3 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowConfirm(false)}
|
|
className="flex-1 px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={isPending}
|
|
onClick={handleConfirmDeath}
|
|
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{isPending ? 'Saving...' : 'Confirm Death'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer for dead/no-confirm/no-evolve views */}
|
|
{(isDead || (!isDead && !showConfirm && !showEvolve)) && (
|
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|