feat(ui): MISSIONS tab renders the live mission board

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.
This commit is contained in:
2026-09-29 01:55:49 +00:00
parent 786a5ec001
commit 684a126a5b
2 changed files with 126 additions and 59 deletions
+120
View File
@@ -0,0 +1,120 @@
'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>
)
}