Files
nuzlocke-tracker/frontend/src/components/EncounterModal.tsx
Julian Tabel d488c252b8 Add starter, gift, and fossil encounters to seed data
Define special encounter data (starters, gifts, fossils) in a new
special_encounters module and merge them into route seed JSON during
generation. Add new route locations to ROUTE_ORDER for cities that
previously had no wild encounters (Saffron City, Pewter City, etc.).
Show colored method badges in the EncounterModal UI for special
encounter types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 13:50:49 +01:00

421 lines
15 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 } from 'react'
import { useRoutePokemon } from '../hooks/useGames'
import type {
Route,
EncounterDetail,
EncounterStatus,
RouteEncounterDetail,
} from '../types'
interface EncounterModalProps {
route: Route
existing?: EncounterDetail
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 specialMethodStyles: Record<string, { label: string; color: string }> = {
starter: {
label: 'Starter',
color:
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
},
gift: {
label: 'Gift',
color: 'bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300',
},
fossil: {
label: 'Fossil',
color:
'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
},
trade: {
label: 'Trade',
color:
'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
},
}
function EncounterMethodBadge({ method }: { method: string }) {
const config = specialMethodStyles[method]
if (!config) return null
return (
<span
className={`text-[9px] font-medium px-1.5 py-0.5 rounded-full ${config.color}`}
>
{config.label}
</span>
)
}
export function EncounterModal({
route,
existing,
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 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="grid grid-cols-3 gap-2 max-h-48 overflow-y-auto">
{filteredPokemon.map((rp) => (
<button
key={rp.id}
type="button"
onClick={() => setSelectedPokemon(rp)}
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
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>
<EncounterMethodBadge method={rp.encounterMethod} />
<span className="text-[10px] text-gray-400">
Lv. {rp.minLevel}
{rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`}
</span>
</button>
))}
</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>
)
}