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>
221 lines
8.0 KiB
TypeScript
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>
|
|
)
|
|
}
|