Files
nuzlocke-tracker/frontend/src/components/BossDefeatModal.tsx

245 lines
9.0 KiB
TypeScript
Raw Normal View History

import { type FormEvent, useMemo, useState } from 'react'
import type {
BossBattle,
BossResultTeamMemberInput,
CreateBossResultInput,
EncounterDetail,
} from '../types/game'
import { ConditionBadge } from './ConditionBadge'
interface BossDefeatModalProps {
boss: BossBattle
aliveEncounters: EncounterDetail[]
onSubmit: (data: CreateBossResultInput) => void
onClose: () => void
isPending?: boolean
starterName?: string | null
}
function matchVariant(labels: string[], starterName?: string | null): string | null {
if (!starterName || labels.length === 0) return null
const lower = starterName.toLowerCase()
const matches = labels.filter((l) => l.toLowerCase().includes(lower))
return matches.length === 1 ? (matches[0] ?? null) : null
}
interface TeamSelection {
encounterId: number
level: number
}
export function BossDefeatModal({
boss,
aliveEncounters,
onSubmit,
onClose,
isPending,
starterName,
}: BossDefeatModalProps) {
const [selectedTeam, setSelectedTeam] = useState<Map<number, TeamSelection>>(new Map())
const toggleTeamMember = (enc: EncounterDetail) => {
setSelectedTeam((prev) => {
const next = new Map(prev)
if (next.has(enc.id)) {
next.delete(enc.id)
} else {
next.set(enc.id, { encounterId: enc.id, level: enc.catchLevel ?? 1 })
}
return next
})
}
const updateLevel = (encounterId: number, level: number) => {
setSelectedTeam((prev) => {
const next = new Map(prev)
const existing = next.get(encounterId)
if (existing) {
next.set(encounterId, { ...existing, level })
}
return next
})
}
const variantLabels = useMemo(() => {
const labels = new Set<string>()
for (const bp of boss.pokemon) {
if (bp.conditionLabel) labels.add(bp.conditionLabel)
}
return [...labels].sort()
}, [boss.pokemon])
const hasVariants = variantLabels.length > 0
const autoMatch = useMemo(
() => matchVariant(variantLabels, starterName),
[variantLabels, starterName]
)
const showPills = hasVariants && autoMatch === null
const [selectedVariant, setSelectedVariant] = useState<string | null>(
autoMatch ?? (hasVariants ? (variantLabels[0] ?? null) : null)
)
const displayedPokemon = useMemo(() => {
if (!hasVariants) return boss.pokemon
return boss.pokemon.filter(
(bp) => bp.conditionLabel === selectedVariant || bp.conditionLabel === null
)
}, [boss.pokemon, hasVariants, selectedVariant])
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
const team: BossResultTeamMemberInput[] = Array.from(selectedTeam.values())
onSubmit({
bossBattleId: boss.id,
result: 'won',
attempts: 1,
team,
})
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-surface-1 rounded-lg shadow-xl max-w-md w-full mx-4">
<div className="px-6 py-4 border-b border-border-default">
<h2 className="text-lg font-semibold">Battle: {boss.name}</h2>
<p className="text-sm text-text-tertiary">{boss.location}</p>
</div>
{/* Boss team preview */}
{boss.pokemon.length > 0 && (
<div className="px-6 py-3 border-b border-border-default">
{showPills && (
<div className="flex gap-1 mb-2 flex-wrap">
{variantLabels.map((label) => (
<button
key={label}
type="button"
onClick={() => setSelectedVariant(label)}
className={`px-2 py-0.5 text-xs font-medium rounded-full transition-colors ${
selectedVariant === label
? 'bg-accent-600 text-white'
: 'bg-surface-2 text-text-secondary hover:bg-surface-3'
}`}
>
{label}
</button>
))}
</div>
)}
<div className="flex flex-wrap gap-3">
{[...displayedPokemon]
.sort((a, b) => a.order - b.order)
.map((bp) => {
const moves = [bp.move1, bp.move2, bp.move3, bp.move4].filter(Boolean)
return (
<div key={bp.id} className="flex flex-col items-center">
{bp.pokemon.spriteUrl ? (
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" />
) : (
<div className="w-10 h-10 bg-surface-3 rounded-full" />
)}
<span className="text-xs text-text-tertiary capitalize">{bp.pokemon.name}</span>
<span className="text-xs font-medium text-text-secondary">Lv.{bp.level}</span>
<ConditionBadge condition={bp.conditionLabel} size="xs" />
{bp.ability && (
<span className="text-[10px] text-text-muted">{bp.ability.name}</span>
)}
{bp.heldItem && (
<span className="text-[10px] text-yellow-500/80">{bp.heldItem}</span>
)}
{moves.length > 0 && (
<div className="text-[9px] text-text-muted text-center leading-tight max-w-[80px]">
{moves.map((m) => m!.name).join(', ')}
</div>
)}
</div>
)
})}
</div>
</div>
)}
{/* Team selection */}
{aliveEncounters.length > 0 && (
<div className="px-6 py-3 border-b border-border-default">
<p className="text-sm font-medium text-text-secondary mb-2">Your team (optional)</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
{aliveEncounters.map((enc) => {
const isSelected = selectedTeam.has(enc.id)
const selection = selectedTeam.get(enc.id)
const displayPokemon = enc.currentPokemon ?? enc.pokemon
return (
<div
key={enc.id}
className={`flex items-center gap-2 p-2 rounded border cursor-pointer transition-colors ${
isSelected
? 'border-accent-500 bg-accent-500/10'
: 'border-border-default hover:bg-surface-2'
}`}
onClick={() => toggleTeamMember(enc)}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleTeamMember(enc)}
className="sr-only"
/>
{displayPokemon.spriteUrl ? (
<img
src={displayPokemon.spriteUrl}
alt={displayPokemon.name}
className="w-8 h-8"
/>
) : (
<div className="w-8 h-8 bg-surface-3 rounded-full" />
)}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">
{enc.nickname ?? displayPokemon.name}
</p>
{isSelected && (
<input
type="number"
min={1}
max={100}
value={selection?.level ?? enc.catchLevel ?? 1}
onChange={(e) => {
e.stopPropagation()
updateLevel(enc.id, Number.parseInt(e.target.value, 10) || 1)
}}
onClick={(e) => e.stopPropagation()}
className="w-14 text-xs px-1 py-0.5 mt-1 rounded border border-border-default bg-surface-1"
placeholder="Lv"
/>
)}
</div>
</div>
)
})}
</div>
</div>
)}
<form onSubmit={handleSubmit}>
<div className="px-6 py-4 border-t border-border-default flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-md border border-border-default hover:bg-surface-2"
>
Cancel
</button>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 text-sm font-medium rounded-md bg-accent-600 text-white hover:bg-accent-500 disabled:opacity-50"
>
{isPending ? 'Saving...' : 'Save Result'}
</button>
</div>
</form>
</div>
</div>
)
}