Files
nuzlocke-tracker/frontend/src/components/BossDefeatModal.tsx
Julian Tabel 4d6e1dc5b2
All checks were successful
CI / backend-tests (pull_request) Successful in 29s
CI / frontend-tests (pull_request) Successful in 39s
feat: make level field optional in boss defeat modal
Remove the level input from the boss defeat modal since the app doesn't
track levels elsewhere. Team selection is now just checkboxes without
requiring level entry.

- Remove level input UI from BossDefeatModal.tsx
- Add alembic migration to make boss_result_team.level nullable
- Update model and schemas to make level optional (defaults to null)
- Conditionally render level in boss result display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:16:15 +01:00

221 lines
8.0 KiB
TypeScript

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
}
type TeamSelection = number
export function BossDefeatModal({
boss,
aliveEncounters,
onSubmit,
onClose,
isPending,
starterName,
}: BossDefeatModalProps) {
const [selectedTeam, setSelectedTeam] = useState<Set<TeamSelection>>(new Set())
const toggleTeamMember = (encounterId: number) => {
setSelectedTeam((prev) => {
const next = new Set(prev)
if (next.has(encounterId)) {
next.delete(encounterId)
} else {
next.add(encounterId)
}
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).map((encounterId) => ({
encounterId,
}))
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 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.id)}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleTeamMember(enc.id)}
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" />
)}
<p className="flex-1 min-w-0 text-xs font-medium truncate">
{enc.nickname ?? displayPokemon.name}
</p>
</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>
)
}