Add genlocke transfer UI with transfer selection modal and backend support
When advancing to the next genlocke leg, users can now select surviving Pokemon to transfer. Transferred Pokemon are bred down to their base evolutionary form and appear as level-1 egg encounters in the next leg. A GenlockeTransfer record links source and target encounters for lineage tracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { api } from './client'
|
||||
import type { Genlocke, GenlockeListItem, GenlockeDetail, GenlockeGraveyard, CreateGenlockeInput, Region } from '../types/game'
|
||||
import type { Genlocke, GenlockeListItem, GenlockeDetail, GenlockeGraveyard, CreateGenlockeInput, Region, SurvivorEncounter, AdvanceLegInput } from '../types/game'
|
||||
|
||||
export function getGenlockes(): Promise<GenlockeListItem[]> {
|
||||
return api.get('/genlockes')
|
||||
@@ -21,6 +21,10 @@ export function getGenlockeGraveyard(id: number): Promise<GenlockeGraveyard> {
|
||||
return api.get(`/genlockes/${id}/graveyard`)
|
||||
}
|
||||
|
||||
export function advanceLeg(genlockeId: number, legOrder: number): Promise<Genlocke> {
|
||||
return api.post(`/genlockes/${genlockeId}/legs/${legOrder}/advance`, {})
|
||||
export function getLegSurvivors(genlockeId: number, legOrder: number): Promise<SurvivorEncounter[]> {
|
||||
return api.get(`/genlockes/${genlockeId}/legs/${legOrder}/survivors`)
|
||||
}
|
||||
|
||||
export function advanceLeg(genlockeId: number, legOrder: number, data?: AdvanceLegInput): Promise<Genlocke> {
|
||||
return api.post(`/genlockes/${genlockeId}/legs/${legOrder}/advance`, data ?? {})
|
||||
}
|
||||
|
||||
118
frontend/src/components/TransferModal.tsx
Normal file
118
frontend/src/components/TransferModal.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react'
|
||||
import type { SurvivorEncounter } from '../types'
|
||||
|
||||
interface TransferModalProps {
|
||||
survivors: SurvivorEncounter[]
|
||||
onSubmit: (encounterIds: number[]) => void
|
||||
onSkip: () => void
|
||||
isPending: boolean
|
||||
}
|
||||
|
||||
export function TransferModal({ survivors, onSubmit, onSkip, isPending }: TransferModalProps) {
|
||||
const [selected, setSelected] = useState<Set<number>>(
|
||||
() => new Set(survivors.map((s) => s.id)),
|
||||
)
|
||||
|
||||
const toggle = (id: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/50" />
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 rounded-t-xl">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Transfer Pokemon to Next Leg
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Selected Pokemon will be bred down to their base form and appear as level 1 encounters in the next leg.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
{survivors.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-8">
|
||||
No surviving Pokemon to transfer.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{survivors.map((survivor) => {
|
||||
const displayPokemon = survivor.currentPokemon ?? survivor.pokemon
|
||||
const isSelected = selected.has(survivor.id)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={survivor.id}
|
||||
type="button"
|
||||
onClick={() => toggle(survivor.id)}
|
||||
className={`flex flex-col items-center p-3 rounded-lg border-2 text-center transition-colors ${
|
||||
isSelected
|
||||
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{displayPokemon.spriteUrl ? (
|
||||
<img
|
||||
src={displayPokemon.spriteUrl}
|
||||
alt={displayPokemon.name}
|
||||
className="w-14 h-14"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-14 h-14 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
|
||||
{displayPokemon.name[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
||||
{survivor.nickname || displayPokemon.name}
|
||||
</span>
|
||||
{survivor.nickname && (
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{displayPokemon.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-gray-400 mt-0.5">
|
||||
{survivor.routeName}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 rounded-b-xl flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
disabled={isPending}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 disabled:opacity-50"
|
||||
>
|
||||
Skip (No Transfers)
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">
|
||||
{selected.size}/{survivors.length} selected
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selected.size === 0 || isPending}
|
||||
onClick={() => onSubmit([...selected])}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isPending ? 'Transferring...' : 'Transfer & Advance'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export { RuleBadges } from './RuleBadges'
|
||||
export { ShinyBox } from './ShinyBox'
|
||||
export { ShinyEncounterModal } from './ShinyEncounterModal'
|
||||
export { StatusChangeModal } from './StatusChangeModal'
|
||||
export { TransferModal } from './TransferModal'
|
||||
export { RuleToggle } from './RuleToggle'
|
||||
export { RulesConfiguration } from './RulesConfiguration'
|
||||
export { StatCard } from './StatCard'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke, getGenlockeGraveyard } from '../api/genlockes'
|
||||
import type { CreateGenlockeInput } from '../types/game'
|
||||
import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke, getGenlockeGraveyard, getLegSurvivors } from '../api/genlockes'
|
||||
import type { AdvanceLegInput, CreateGenlockeInput } from '../types/game'
|
||||
|
||||
export function useGenlockes() {
|
||||
return useQuery({
|
||||
@@ -41,11 +41,19 @@ export function useCreateGenlocke() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useLegSurvivors(genlockeId: number, legOrder: number, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['genlockes', genlockeId, 'legs', legOrder, 'survivors'],
|
||||
queryFn: () => getLegSurvivors(genlockeId, legOrder),
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAdvanceLeg() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ genlockeId, legOrder }: { genlockeId: number; legOrder: number }) =>
|
||||
advanceLeg(genlockeId, legOrder),
|
||||
mutationFn: ({ genlockeId, legOrder, transferEncounterIds }: { genlockeId: number; legOrder: number; transferEncounterIds?: number[] }) =>
|
||||
advanceLeg(genlockeId, legOrder, transferEncounterIds ? { transferEncounterIds } : undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['runs'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['genlockes'] })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom'
|
||||
import { useRun, useUpdateRun } from '../hooks/useRuns'
|
||||
import { useAdvanceLeg } from '../hooks/useGenlockes'
|
||||
import { useAdvanceLeg, useLegSurvivors } from '../hooks/useGenlockes'
|
||||
import { useGameRoutes } from '../hooks/useGames'
|
||||
import { useCreateEncounter, useUpdateEncounter, useBulkRandomize } from '../hooks/useEncounters'
|
||||
import { usePokemonFamilies } from '../hooks/usePokemon'
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
EncounterModal,
|
||||
EncounterMethodBadge,
|
||||
HofTeamModal,
|
||||
TransferModal,
|
||||
StatCard,
|
||||
PokemonCard,
|
||||
StatusChangeModal,
|
||||
@@ -395,6 +396,11 @@ export function RunEncounters() {
|
||||
const runIdNum = Number(runId)
|
||||
const { data: run, isLoading, error } = useRun(runIdNum)
|
||||
const advanceLeg = useAdvanceLeg()
|
||||
const { data: survivors } = useLegSurvivors(
|
||||
run?.genlocke?.genlockeId ?? 0,
|
||||
run?.genlocke?.legOrder ?? 0,
|
||||
showTransferModal && !!run?.genlocke,
|
||||
)
|
||||
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
|
||||
run?.gameId ?? null,
|
||||
)
|
||||
@@ -417,6 +423,7 @@ export function RunEncounters() {
|
||||
const [showHofModal, setShowHofModal] = useState(false)
|
||||
const [showShinyModal, setShowShinyModal] = useState(false)
|
||||
const [showEggModal, setShowEggModal] = useState(false)
|
||||
const [showTransferModal, setShowTransferModal] = useState(false)
|
||||
const [expandedBosses, setExpandedBosses] = useState<Set<number>>(new Set())
|
||||
const [showTeam, setShowTeam] = useState(true)
|
||||
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
|
||||
@@ -864,21 +871,7 @@ export function RunEncounters() {
|
||||
</div>
|
||||
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && (
|
||||
<button
|
||||
onClick={() => {
|
||||
advanceLeg.mutate(
|
||||
{ genlockeId: run.genlocke!.genlockeId, legOrder: run.genlocke!.legOrder },
|
||||
{
|
||||
onSuccess: (genlocke) => {
|
||||
const nextLeg = genlocke.legs.find(
|
||||
(l) => l.legOrder === run.genlocke!.legOrder + 1,
|
||||
)
|
||||
if (nextLeg?.runId) {
|
||||
navigate(`/runs/${nextLeg.runId}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}}
|
||||
onClick={() => setShowTransferModal(true)}
|
||||
disabled={advanceLeg.isPending}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
@@ -1454,6 +1447,53 @@ export function RunEncounters() {
|
||||
isPending={updateRun.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Transfer Modal */}
|
||||
{showTransferModal && survivors && (
|
||||
<TransferModal
|
||||
survivors={survivors}
|
||||
onSubmit={(encounterIds) => {
|
||||
advanceLeg.mutate(
|
||||
{
|
||||
genlockeId: run!.genlocke!.genlockeId,
|
||||
legOrder: run!.genlocke!.legOrder,
|
||||
transferEncounterIds: encounterIds,
|
||||
},
|
||||
{
|
||||
onSuccess: (genlocke) => {
|
||||
setShowTransferModal(false)
|
||||
const nextLeg = genlocke.legs.find(
|
||||
(l) => l.legOrder === run!.genlocke!.legOrder + 1,
|
||||
)
|
||||
if (nextLeg?.runId) {
|
||||
navigate(`/runs/${nextLeg.runId}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}}
|
||||
onSkip={() => {
|
||||
advanceLeg.mutate(
|
||||
{
|
||||
genlockeId: run!.genlocke!.genlockeId,
|
||||
legOrder: run!.genlocke!.legOrder,
|
||||
},
|
||||
{
|
||||
onSuccess: (genlocke) => {
|
||||
setShowTransferModal(false)
|
||||
const nextLeg = genlocke.legs.find(
|
||||
(l) => l.legOrder === run!.genlocke!.legOrder + 1,
|
||||
)
|
||||
if (nextLeg?.runId) {
|
||||
navigate(`/runs/${nextLeg.runId}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}}
|
||||
isPending={advanceLeg.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -285,6 +285,22 @@ export interface GenlockeDetail {
|
||||
retiredPokemon: Record<number, RetiredPokemon>
|
||||
}
|
||||
|
||||
// Transfer types
|
||||
|
||||
export interface SurvivorEncounter {
|
||||
id: number
|
||||
pokemon: Pokemon
|
||||
currentPokemon: Pokemon | null
|
||||
nickname: string | null
|
||||
catchLevel: number | null
|
||||
isShiny: boolean
|
||||
routeName: string
|
||||
}
|
||||
|
||||
export interface AdvanceLegInput {
|
||||
transferEncounterIds: number[]
|
||||
}
|
||||
|
||||
// Graveyard types
|
||||
|
||||
export interface GraveyardEntry {
|
||||
|
||||
Reference in New Issue
Block a user