Files
nuzlocke-tracker/frontend/src/components/EncounterModal.tsx
Julian Tabel ad1eb0524c Enforce Dupes Clause and Shiny Clause rules
Dupes Clause greys out Pokemon in the encounter modal whose evolution
family has already been caught, preventing duplicate selections. Shiny
Clause adds a dedicated Shiny Box and lets shiny catches bypass the
one-per-route lock via a new is_shiny column on encounters and a
/pokemon/families endpoint that computes evolution family groups.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 21:08:25 +01:00

447 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
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 }))
}
export function EncounterModal({
route,
existing,
dupedPokemonIds,
onSubmit,
onUpdate,
onClose,
isPending,
}: EncounterModalProps) {
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
route.id,
)
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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Pokemon
</label>
{loadingPokemon ? (
<div className="flex items-center justify-center py-4">
<div className="w-6 h-6 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>
)
}