2026-03-20 15:09:02 +01:00
|
|
|
import { type FormEvent, useMemo, useState } from 'react'
|
2026-03-20 21:41:38 +01:00
|
|
|
import type {
|
|
|
|
|
BossBattle,
|
|
|
|
|
BossResultTeamMemberInput,
|
|
|
|
|
CreateBossResultInput,
|
|
|
|
|
EncounterDetail,
|
|
|
|
|
} from '../types/game'
|
2026-02-16 21:17:32 +01:00
|
|
|
import { ConditionBadge } from './ConditionBadge'
|
2026-02-08 11:16:13 +01:00
|
|
|
|
|
|
|
|
interface BossDefeatModalProps {
|
|
|
|
|
boss: BossBattle
|
2026-03-20 21:41:38 +01:00
|
|
|
aliveEncounters: EncounterDetail[]
|
2026-02-08 11:16:13 +01:00
|
|
|
onSubmit: (data: CreateBossResultInput) => void
|
|
|
|
|
onClose: () => void
|
|
|
|
|
isPending?: boolean
|
2026-02-08 21:33:28 +01:00
|
|
|
starterName?: string | null
|
2026-02-08 11:16:13 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-16 20:39:41 +01:00
|
|
|
function matchVariant(labels: string[], starterName?: string | null): string | null {
|
2026-02-08 21:33:28 +01:00
|
|
|
if (!starterName || labels.length === 0) return null
|
|
|
|
|
const lower = starterName.toLowerCase()
|
|
|
|
|
const matches = labels.filter((l) => l.toLowerCase().includes(lower))
|
2026-02-16 20:39:41 +01:00
|
|
|
return matches.length === 1 ? (matches[0] ?? null) : null
|
2026-02-08 21:33:28 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 21:41:38 +01:00
|
|
|
interface TeamSelection {
|
|
|
|
|
encounterId: number
|
|
|
|
|
level: number
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 16:41:24 +01:00
|
|
|
export function BossDefeatModal({
|
|
|
|
|
boss,
|
2026-03-20 21:41:38 +01:00
|
|
|
aliveEncounters,
|
2026-02-14 16:41:24 +01:00
|
|
|
onSubmit,
|
|
|
|
|
onClose,
|
|
|
|
|
isPending,
|
|
|
|
|
starterName,
|
|
|
|
|
}: BossDefeatModalProps) {
|
2026-03-20 21:41:38 +01:00
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-02-08 11:16:13 +01:00
|
|
|
|
2026-03-20 21:41:38 +01:00
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-02-08 21:20:30 +01:00
|
|
|
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
|
2026-02-14 16:41:24 +01:00
|
|
|
const autoMatch = useMemo(
|
|
|
|
|
() => matchVariant(variantLabels, starterName),
|
|
|
|
|
[variantLabels, starterName]
|
|
|
|
|
)
|
2026-02-08 21:33:28 +01:00
|
|
|
const showPills = hasVariants && autoMatch === null
|
2026-02-08 21:20:30 +01:00
|
|
|
const [selectedVariant, setSelectedVariant] = useState<string | null>(
|
2026-02-16 20:39:41 +01:00
|
|
|
autoMatch ?? (hasVariants ? (variantLabels[0] ?? null) : null)
|
2026-02-08 21:20:30 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const displayedPokemon = useMemo(() => {
|
|
|
|
|
if (!hasVariants) return boss.pokemon
|
|
|
|
|
return boss.pokemon.filter(
|
2026-02-16 20:39:41 +01:00
|
|
|
(bp) => bp.conditionLabel === selectedVariant || bp.conditionLabel === null
|
2026-02-08 21:20:30 +01:00
|
|
|
)
|
|
|
|
|
}, [boss.pokemon, hasVariants, selectedVariant])
|
|
|
|
|
|
2026-02-08 11:16:13 +01:00
|
|
|
const handleSubmit = (e: FormEvent) => {
|
|
|
|
|
e.preventDefault()
|
2026-03-20 21:41:38 +01:00
|
|
|
const team: BossResultTeamMemberInput[] = Array.from(selectedTeam.values())
|
2026-02-08 11:16:13 +01:00
|
|
|
onSubmit({
|
|
|
|
|
bossBattleId: boss.id,
|
2026-03-20 15:09:02 +01:00
|
|
|
result: 'won',
|
|
|
|
|
attempts: 1,
|
2026-03-20 21:41:38 +01:00
|
|
|
team,
|
2026-02-08 11:16:13 +01:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
|
|
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
2026-02-17 20:48:42 +01:00
|
|
|
<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">
|
2026-02-08 11:16:13 +01:00
|
|
|
<h2 className="text-lg font-semibold">Battle: {boss.name}</h2>
|
2026-02-17 20:48:42 +01:00
|
|
|
<p className="text-sm text-text-tertiary">{boss.location}</p>
|
2026-02-08 11:16:13 +01:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Boss team preview */}
|
|
|
|
|
{boss.pokemon.length > 0 && (
|
2026-02-17 20:48:42 +01:00
|
|
|
<div className="px-6 py-3 border-b border-border-default">
|
2026-02-08 21:33:28 +01:00
|
|
|
{showPills && (
|
2026-02-08 21:20:30 +01:00
|
|
|
<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
|
2026-02-17 21:08:53 +01:00
|
|
|
? 'bg-accent-600 text-white'
|
2026-02-17 20:48:42 +01:00
|
|
|
: 'bg-surface-2 text-text-secondary hover:bg-surface-3'
|
2026-02-08 21:20:30 +01:00
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-08 11:16:13 +01:00
|
|
|
<div className="flex flex-wrap gap-3">
|
2026-02-08 21:20:30 +01:00
|
|
|
{[...displayedPokemon]
|
2026-02-08 11:16:13 +01:00
|
|
|
.sort((a, b) => a.order - b.order)
|
2026-03-20 21:41:38 +01:00
|
|
|
.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"
|
|
|
|
|
/>
|
2026-02-08 11:16:13 +01:00
|
|
|
) : (
|
2026-03-20 21:41:38 +01:00
|
|
|
<div className="w-8 h-8 bg-surface-3 rounded-full" />
|
2026-02-08 11:16:13 +01:00
|
|
|
)}
|
2026-03-20 21:41:38 +01:00
|
|
|
<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>
|
2026-02-08 11:16:13 +01:00
|
|
|
</div>
|
2026-03-20 21:41:38 +01:00
|
|
|
)
|
|
|
|
|
})}
|
2026-02-08 11:16:13 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<form onSubmit={handleSubmit}>
|
2026-02-17 20:48:42 +01:00
|
|
|
<div className="px-6 py-4 border-t border-border-default flex justify-end gap-3">
|
2026-02-08 11:16:13 +01:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onClose}
|
2026-02-17 20:48:42 +01:00
|
|
|
className="px-4 py-2 text-sm font-medium rounded-md border border-border-default hover:bg-surface-2"
|
2026-02-08 11:16:13 +01:00
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={isPending}
|
2026-02-17 20:48:42 +01:00
|
|
|
className="px-4 py-2 text-sm font-medium rounded-md bg-accent-600 text-white hover:bg-accent-500 disabled:opacity-50"
|
2026-02-08 11:16:13 +01:00
|
|
|
>
|
|
|
|
|
{isPending ? 'Saving...' : 'Save Result'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|