Self-fetching MissionPanel (world-map pattern — refetches on each tab open, accept responses applied directly). One card per contract: type title + state badge, CONTRACT // #id line, objective, reward line, EXPLORE progress line. ACCEPT enabled only when AVAILABLE and nothing else is ACTIVE. Placeholder encounter content removed.
121 lines
4.5 KiB
TypeScript
121 lines
4.5 KiB
TypeScript
'use client'
|
||
|
||
import { useCallback, useEffect, useState } from 'react'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||
import type { MissionDto } from '@/lib/game/missions'
|
||
|
||
// MISSIONS tab — live data. Fetches /api/missions on mount (Radix Tabs unmounts inactive tabs, so it
|
||
// refetches whenever the tab is opened); accept responses are applied directly (no polling). ACCEPT is
|
||
// enabled only when the contract is AVAILABLE and no other mission is ACTIVE — mirroring what
|
||
// POST /api/missions/{id}/accept enforces.
|
||
|
||
export function MissionPanel() {
|
||
const [missions, setMissions] = useState<MissionDto[] | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [busyId, setBusyId] = useState<number | null>(null) // one action at a time — turn-based game
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
fetch('/api/missions')
|
||
.then(async (res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||
return (await res.json()) as MissionDto[]
|
||
})
|
||
.then((m) => {
|
||
if (!cancelled) setMissions(m)
|
||
})
|
||
.catch((err: unknown) => {
|
||
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [])
|
||
|
||
const accept = useCallback(async (mission: MissionDto) => {
|
||
setBusyId(mission.id)
|
||
setError(null)
|
||
try {
|
||
const res = await fetch(`/api/missions/${mission.id}/accept`, { method: 'POST' })
|
||
const data = (await res.json()) as MissionDto[] & { error?: string }
|
||
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`)
|
||
setMissions(data) // response is the fresh board — apply directly
|
||
} catch (err: unknown) {
|
||
setError(err instanceof Error ? err.message : String(err))
|
||
} finally {
|
||
setBusyId(null)
|
||
}
|
||
}, [])
|
||
|
||
if (!missions && !error) {
|
||
return (
|
||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING MISSIONS…</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const active = (missions ?? []).find((m) => m.state === 'ACTIVE')
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||
{error && (
|
||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>{error.toUpperCase()}</span>
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.75rem' }}>
|
||
{(missions ?? []).map((mission) => (
|
||
<MissionCard key={mission.id} mission={mission} busy={busyId === mission.id} blockedByActive={!!active && active.id !== mission.id} onAccept={accept} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MissionCard({
|
||
mission,
|
||
busy,
|
||
blockedByActive,
|
||
onAccept,
|
||
}: {
|
||
mission: MissionDto
|
||
busy: boolean
|
||
blockedByActive: boolean
|
||
onAccept: (mission: MissionDto) => void
|
||
}) {
|
||
const acceptDisabled = busy || mission.state !== 'AVAILABLE' || blockedByActive
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||
<CardTitle>{mission.type}</CardTitle>
|
||
<Badge variant={mission.state === 'ACTIVE' ? 'ACTIVE' : mission.state === 'AVAILABLE' ? 'SCANNING' : 'OFFLINE'} className="ml-auto">
|
||
{mission.state}
|
||
</Badge>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>CONTRACT // #{mission.id}</p>
|
||
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>{mission.description.toUpperCase()}</p>
|
||
<p style={{ fontSize: '0.62rem', color: 'var(--color-green)', letterSpacing: '0.08em' }}>
|
||
REWARD {mission.rewardQty}× {mission.rewardKind === 'resource' ? 'RESOURCES' : 'PARTS'}
|
||
</p>
|
||
{mission.type === 'EXPLORE' && (
|
||
<p style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||
PROGRESS // {mission.progress}/{mission.progressTarget}
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
<CardFooter>
|
||
<Button variant="OUTLINE" size="SM" disabled={acceptDisabled} onClick={() => onAccept(mission)}>
|
||
ACCEPT
|
||
</Button>
|
||
</CardFooter>
|
||
</Card>
|
||
)
|
||
}
|