Per-route: Randomize/Re-roll button in EncounterModal picks a uniform
random pokemon from eligible (non-duped) encounters. Bulk: new
POST /runs/{run_id}/encounters/bulk-randomize endpoint fills all
remaining routes in order, respecting dupes clause cascading, pinwheel
zones, and route group locking. Frontend Randomize All button on the
run page triggers the bulk endpoint with a confirm dialog.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
485 lines
18 KiB
TypeScript
485 lines
18 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react'
|
||
import { useRoutePokemon } from '../hooks/useGames'
|
||
import {
|
||
EncounterMethodBadge,
|
||
getMethodLabel,
|
||
METHOD_ORDER,
|
||
} from './EncounterMethodBadge'
|
||
import type {
|
||
Route,
|
||
EncounterDetail,
|
||
EncounterStatus,
|
||
RouteEncounterDetail,
|
||
} from '../types'
|
||
|
||
interface EncounterModalProps {
|
||
route: Route
|
||
gameId: number
|
||
existing?: EncounterDetail
|
||
dupedPokemonIds?: Set<number>
|
||
onSubmit: (data: {
|
||
routeId: number
|
||
pokemonId: number
|
||
nickname?: string
|
||
status: EncounterStatus
|
||
catchLevel?: number
|
||
}) => void
|
||
onUpdate?: (data: {
|
||
id: number
|
||
data: {
|
||
nickname?: string
|
||
status?: EncounterStatus
|
||
faintLevel?: number
|
||
deathCause?: string
|
||
}
|
||
}) => void
|
||
onClose: () => void
|
||
isPending: boolean
|
||
}
|
||
|
||
const statusOptions: { value: EncounterStatus; label: string; color: string }[] =
|
||
[
|
||
{
|
||
value: 'caught',
|
||
label: 'Caught',
|
||
color:
|
||
'bg-green-100 text-green-800 border-green-300 dark:bg-green-900/40 dark:text-green-300 dark:border-green-700',
|
||
},
|
||
{
|
||
value: 'fainted',
|
||
label: 'Fainted',
|
||
color:
|
||
'bg-red-100 text-red-800 border-red-300 dark:bg-red-900/40 dark:text-red-300 dark:border-red-700',
|
||
},
|
||
{
|
||
value: 'missed',
|
||
label: 'Missed / Ran',
|
||
color:
|
||
'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600',
|
||
},
|
||
]
|
||
|
||
const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
|
||
|
||
function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokemon: RouteEncounterDetail[] }[] {
|
||
const groups = new Map<string, RouteEncounterDetail[]>()
|
||
for (const rp of pokemon) {
|
||
const list = groups.get(rp.encounterMethod) ?? []
|
||
list.push(rp)
|
||
groups.set(rp.encounterMethod, list)
|
||
}
|
||
return [...groups.entries()]
|
||
.sort(([a], [b]) => {
|
||
const ai = METHOD_ORDER.indexOf(a)
|
||
const bi = METHOD_ORDER.indexOf(b)
|
||
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
|
||
})
|
||
.map(([method, pokemon]) => ({ method, pokemon }))
|
||
}
|
||
|
||
function pickRandomPokemon(
|
||
pokemon: RouteEncounterDetail[],
|
||
dupedIds?: Set<number>,
|
||
): RouteEncounterDetail | null {
|
||
const eligible = dupedIds
|
||
? pokemon.filter((rp) => !dupedIds.has(rp.pokemonId))
|
||
: pokemon
|
||
if (eligible.length === 0) return null
|
||
return eligible[Math.floor(Math.random() * eligible.length)]
|
||
}
|
||
|
||
export function EncounterModal({
|
||
route,
|
||
gameId,
|
||
existing,
|
||
dupedPokemonIds,
|
||
onSubmit,
|
||
onUpdate,
|
||
onClose,
|
||
isPending,
|
||
}: EncounterModalProps) {
|
||
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
|
||
route.id,
|
||
gameId,
|
||
)
|
||
|
||
const [selectedPokemon, setSelectedPokemon] =
|
||
useState<RouteEncounterDetail | null>(null)
|
||
const [status, setStatus] = useState<EncounterStatus>(
|
||
existing?.status ?? 'caught',
|
||
)
|
||
const [nickname, setNickname] = useState(existing?.nickname ?? '')
|
||
const [catchLevel, setCatchLevel] = useState<string>(
|
||
existing?.catchLevel?.toString() ?? '',
|
||
)
|
||
const [faintLevel, setFaintLevel] = useState<string>('')
|
||
const [deathCause, setDeathCause] = useState('')
|
||
const [search, setSearch] = useState('')
|
||
|
||
const isEditing = !!existing
|
||
|
||
// Pre-select pokemon when editing
|
||
useEffect(() => {
|
||
if (existing && routePokemon) {
|
||
const match = routePokemon.find(
|
||
(rp) => rp.pokemonId === existing.pokemonId,
|
||
)
|
||
if (match) setSelectedPokemon(match)
|
||
}
|
||
}, [existing, routePokemon])
|
||
|
||
const filteredPokemon = routePokemon?.filter((rp) =>
|
||
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()),
|
||
)
|
||
|
||
const groupedPokemon = useMemo(
|
||
() => (filteredPokemon ? groupByMethod(filteredPokemon) : []),
|
||
[filteredPokemon],
|
||
)
|
||
const hasMultipleGroups = groupedPokemon.length > 1
|
||
|
||
const handleSubmit = () => {
|
||
if (isEditing && onUpdate) {
|
||
onUpdate({
|
||
id: existing.id,
|
||
data: {
|
||
nickname: nickname || undefined,
|
||
status,
|
||
faintLevel: faintLevel ? Number(faintLevel) : undefined,
|
||
deathCause: deathCause || undefined,
|
||
},
|
||
})
|
||
} else if (selectedPokemon) {
|
||
onSubmit({
|
||
routeId: route.id,
|
||
pokemonId: selectedPokemon.pokemonId,
|
||
nickname: nickname || undefined,
|
||
status,
|
||
catchLevel: catchLevel ? Number(catchLevel) : undefined,
|
||
})
|
||
}
|
||
}
|
||
|
||
const canSubmit = isEditing || selectedPokemon
|
||
|
||
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-lg w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 rounded-t-xl">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||
{isEditing ? 'Edit Encounter' : 'Log Encounter'}
|
||
</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>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||
{route.name}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="px-6 py-4 space-y-4">
|
||
{/* Pokemon Selection (only for new encounters) */}
|
||
{!isEditing && (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-1">
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
Pokemon
|
||
</label>
|
||
{!loadingPokemon && routePokemon && routePokemon.length > 0 && (
|
||
<button
|
||
type="button"
|
||
disabled={
|
||
loadingPokemon ||
|
||
!routePokemon ||
|
||
(dupedPokemonIds
|
||
? routePokemon.every((rp) => dupedPokemonIds.has(rp.pokemonId))
|
||
: false)
|
||
}
|
||
onClick={() => {
|
||
if (routePokemon) {
|
||
setSelectedPokemon(
|
||
pickRandomPokemon(routePokemon, dupedPokemonIds),
|
||
)
|
||
}
|
||
}}
|
||
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-300 dark:border-purple-600 text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
{selectedPokemon ? 'Re-roll' : 'Randomize'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{loadingPokemon ? (
|
||
<div className="flex items-center justify-center py-4">
|
||
<div className="w-10 h-10 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||
</div>
|
||
) : filteredPokemon && filteredPokemon.length > 0 ? (
|
||
<>
|
||
{(routePokemon?.length ?? 0) > 6 && (
|
||
<input
|
||
type="text"
|
||
placeholder="Search pokemon..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="w-full px-3 py-1.5 mb-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
)}
|
||
<div className="max-h-64 overflow-y-auto space-y-3">
|
||
{groupedPokemon.map(({ method, pokemon }, groupIdx) => (
|
||
<div key={method}>
|
||
{groupIdx > 0 && (
|
||
<div className="border-t border-gray-200 dark:border-gray-700 mb-3" />
|
||
)}
|
||
{hasMultipleGroups && (
|
||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
|
||
{getMethodLabel(method)}
|
||
</div>
|
||
)}
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{pokemon.map((rp) => {
|
||
const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false
|
||
return (
|
||
<button
|
||
key={rp.id}
|
||
type="button"
|
||
onClick={() => !isDuped && setSelectedPokemon(rp)}
|
||
disabled={isDuped}
|
||
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
|
||
isDuped
|
||
? 'opacity-40 cursor-not-allowed border-gray-200 dark:border-gray-700'
|
||
: selectedPokemon?.id === rp.id
|
||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30'
|
||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||
}`}
|
||
>
|
||
{rp.pokemon.spriteUrl ? (
|
||
<img
|
||
src={rp.pokemon.spriteUrl}
|
||
alt={rp.pokemon.name}
|
||
className="w-10 h-10"
|
||
/>
|
||
) : (
|
||
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
|
||
{rp.pokemon.name[0].toUpperCase()}
|
||
</div>
|
||
)}
|
||
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
||
{rp.pokemon.name}
|
||
</span>
|
||
{isDuped && (
|
||
<span className="text-[10px] text-gray-400 italic">
|
||
already caught
|
||
</span>
|
||
)}
|
||
{!isDuped && SPECIAL_METHODS.includes(rp.encounterMethod) && (
|
||
<EncounterMethodBadge method={rp.encounterMethod} />
|
||
)}
|
||
{!isDuped && (
|
||
<span className="text-[10px] text-gray-400">
|
||
Lv. {rp.minLevel}
|
||
{rp.maxLevel !== rp.minLevel && `–${rp.maxLevel}`}
|
||
</span>
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 py-2">
|
||
No pokemon data for this route
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Editing: show pokemon info */}
|
||
{isEditing && existing && (
|
||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
||
{existing.pokemon.spriteUrl ? (
|
||
<img
|
||
src={existing.pokemon.spriteUrl}
|
||
alt={existing.pokemon.name}
|
||
className="w-12 h-12"
|
||
/>
|
||
) : (
|
||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
|
||
{existing.pokemon.name[0].toUpperCase()}
|
||
</div>
|
||
)}
|
||
<div>
|
||
<div className="font-medium text-gray-900 dark:text-gray-100 capitalize">
|
||
{existing.pokemon.name}
|
||
</div>
|
||
<div className="text-xs text-gray-500">
|
||
Caught at Lv. {existing.catchLevel ?? '?'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Status */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
Status
|
||
</label>
|
||
<div className="flex gap-2">
|
||
{statusOptions.map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
onClick={() => setStatus(opt.value)}
|
||
className={`flex-1 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||
status === opt.value
|
||
? opt.color
|
||
: 'border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Nickname (for caught) */}
|
||
{status === 'caught' && (
|
||
<div>
|
||
<label
|
||
htmlFor="nickname"
|
||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||
>
|
||
Nickname
|
||
</label>
|
||
<input
|
||
id="nickname"
|
||
type="text"
|
||
value={nickname}
|
||
onChange={(e) => setNickname(e.target.value)}
|
||
placeholder="Give it a name..."
|
||
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-blue-500"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Level (for new caught encounters) */}
|
||
{!isEditing && status === 'caught' && (
|
||
<div>
|
||
<label
|
||
htmlFor="catch-level"
|
||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||
>
|
||
Catch Level
|
||
</label>
|
||
<input
|
||
id="catch-level"
|
||
type="number"
|
||
min={1}
|
||
max={100}
|
||
value={catchLevel}
|
||
onChange={(e) => setCatchLevel(e.target.value)}
|
||
placeholder={
|
||
selectedPokemon
|
||
? `${selectedPokemon.minLevel}–${selectedPokemon.maxLevel}`
|
||
: '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-blue-500"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Faint Level + Death Cause (only when editing a caught pokemon to mark dead) */}
|
||
{isEditing &&
|
||
existing?.status === 'caught' &&
|
||
existing?.faintLevel === null && (
|
||
<>
|
||
<div>
|
||
<label
|
||
htmlFor="faint-level"
|
||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||
>
|
||
Faint Level{' '}
|
||
<span className="font-normal text-gray-400">
|
||
(mark as dead)
|
||
</span>
|
||
</label>
|
||
<input
|
||
id="faint-level"
|
||
type="number"
|
||
min={1}
|
||
max={100}
|
||
value={faintLevel}
|
||
onChange={(e) => setFaintLevel(e.target.value)}
|
||
placeholder="Leave empty if still alive"
|
||
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-blue-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={deathCause}
|
||
onChange={(e) => setDeathCause(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-blue-500"
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 rounded-b-xl flex justify-end gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!canSubmit || isPending}
|
||
onClick={handleSubmit}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
{isPending
|
||
? 'Saving...'
|
||
: isEditing
|
||
? 'Update'
|
||
: 'Log Encounter'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|