Add boss battles, level caps, and badge tracking

Introduces full boss battle system: data models (BossBattle, BossPokemon,
BossResult), API endpoints for CRUD and per-run defeat tracking, and frontend
UI including a sticky level cap bar with badge display on the run page,
interleaved boss battle cards in the encounter list, and an admin panel
section for managing boss battles and their pokemon teams.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 11:16:13 +01:00
parent 3b87397432
commit 190b08eb26
23 changed files with 1614 additions and 61 deletions

View File

@@ -0,0 +1,120 @@
import { type FormEvent, useState } from 'react'
import type { BossBattle, CreateBossResultInput } from '../types/game'
interface BossDefeatModalProps {
boss: BossBattle
onSubmit: (data: CreateBossResultInput) => void
onClose: () => void
isPending?: boolean
}
export function BossDefeatModal({ boss, onSubmit, onClose, isPending }: BossDefeatModalProps) {
const [result, setResult] = useState<'won' | 'lost'>('won')
const [attempts, setAttempts] = useState('1')
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
onSubmit({
bossBattleId: boss.id,
result,
attempts: Number(attempts) || 1,
})
}
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-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold">Battle: {boss.name}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">{boss.location}</p>
</div>
{/* Boss team preview */}
{boss.pokemon.length > 0 && (
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700">
<div className="flex flex-wrap gap-3">
{boss.pokemon
.sort((a, b) => a.order - b.order)
.map((bp) => (
<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-gray-200 dark:bg-gray-700 rounded-full" />
)}
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
{bp.pokemon.name}
</span>
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">
Lv.{bp.level}
</span>
</div>
))}
</div>
</div>
)}
<form onSubmit={handleSubmit}>
<div className="px-6 py-4 space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Result</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setResult('won')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md border transition-colors ${
result === 'won'
? 'bg-green-600 text-white border-green-600'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
Won
</button>
<button
type="button"
onClick={() => setResult('lost')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md border transition-colors ${
result === 'lost'
? 'bg-red-600 text-white border-red-600'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
Lost
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Attempts</label>
<input
type="number"
min={1}
value={attempts}
onChange={(e) => setAttempts(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? 'Saving...' : 'Save Result'}
</button>
</div>
</form>
</div>
</div>
)
}