Files
kamilkrzywda 684a126a5b 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.
2026-09-29 01:55:49 +00:00

121 lines
4.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)
}