From 684a126a5bcbd8f58d990f762bce0b7b36f43ec0 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 29 Sep 2026 01:55:49 +0000 Subject: [PATCH] feat(ui): MISSIONS tab renders the live mission board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- components/mission-control.tsx | 65 ++---------------- components/missions.tsx | 120 +++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 59 deletions(-) create mode 100644 components/missions.tsx diff --git a/components/mission-control.tsx b/components/mission-control.tsx index 4e72910..db87c58 100644 --- a/components/mission-control.tsx +++ b/components/mission-control.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react' import { Badge } from '@/components/ui/badge' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel' import { Progress } from '@/components/ui/progress' import { StatCard } from '@/components/ui/stat-card' @@ -11,6 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Terminal, type TerminalLine } from '@/components/ui/terminal' import { CrewPanel } from '@/components/crew-panel' import { LocalSystemPanel } from '@/components/local-system' +import { MissionPanel } from '@/components/missions' import { ShipSystemsPanel } from '@/components/ship-systems' import { SkillTreePanel } from '@/components/skill-tree' import { WorldMapPanel } from '@/components/world-map' @@ -21,9 +21,9 @@ import type { TechDto } from '@/lib/game/tech' // Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control // showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). The -// WORLD MAP, LOCAL SYSTEM, CREW, SHIP SYSTEMS and SKILL TREE tabs render live data (/api/world, -// /api/system, /api/crew, /api/ship, /api/tech); the other tabs keep placeholder content until -// their own issues land. Single player, no auth, by design. +// WORLD MAP, LOCAL SYSTEM, CREW, SHIP SYSTEMS, SKILL TREE and MISSIONS tabs render live data +// (/api/world, /api/system, /api/crew, /api/ship, /api/tech, /api/missions); the EVENT LOG tab +// keeps placeholder content until its own issue lands. Single player, no auth, by design. function useNarrow(threshold = 900): boolean { const [narrow, setNarrow] = useState( @@ -386,62 +386,9 @@ export function MissionControl() { - {/* MISSIONS TAB */} + {/* MISSIONS TAB — live data from /api/missions */} -
- - ENCOUNTER #E-102 — DERELICT FREIGHTER - - Placeholder briefing. Real encounter resolution lands with the turn engine. - - - -
- {[ - { label: 'DESTINATION', value: 'SECTOR 7G // DRIFT LANE' }, - { label: 'ETA', value: 'T-07:14 (PLACEHOLDER)' }, - { label: 'THREAT LEVEL', value: 'LOW — UNMANNED' }, - { label: 'CARGO MANIFEST', value: 'UNKNOWN' }, - ].map((item) => ( -
-
- {item.label} -
-
- {item.value} -
-
- ))} -
-
+
{/* EVENT LOG TAB */} diff --git a/components/missions.tsx b/components/missions.tsx new file mode 100644 index 0000000..9edbdf6 --- /dev/null +++ b/components/missions.tsx @@ -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(null) + const [error, setError] = useState(null) + const [busyId, setBusyId] = useState(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 ( +
+ LOADING MISSIONS… +
+ ) + } + + const active = (missions ?? []).find((m) => m.state === 'ACTIVE') + + return ( +
+ {error && ( +
+ {error.toUpperCase()} +
+ )} +
+ {(missions ?? []).map((mission) => ( + + ))} +
+
+ ) +} + +function MissionCard({ + mission, + busy, + blockedByActive, + onAccept, +}: { + mission: MissionDto + busy: boolean + blockedByActive: boolean + onAccept: (mission: MissionDto) => void +}) { + const acceptDisabled = busy || mission.state !== 'AVAILABLE' || blockedByActive + + return ( + + +
+ {mission.type} + + {mission.state} + +
+
+ +

CONTRACT // #{mission.id}

+

{mission.description.toUpperCase()}

+

+ REWARD {mission.rewardQty}× {mission.rewardKind === 'resource' ? 'RESOURCES' : 'PARTS'} +

+ {mission.type === 'EXPLORE' && ( +

+ PROGRESS // {mission.progress}/{mission.progressTarget} +

+ )} +
+ + + +
+ ) +}