'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { GeoJsonFeatureCollection } from '@/lib/airports'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import { PLANE_TYPES } from '@/lib/game/catalog'; import { abandonPenalty } from '@/lib/game/penalty'; import { formatCash, formatDeadlineRelative } from '@/lib/format'; import type { HoverTarget } from './hoverTypes'; interface RightPaneProps { state: StateSnapshot | null; loading: boolean; // Push the full snapshot returned by an action endpoint into the shared shell state (v0.10.0), // so the pane updates on click without waiting for the next poll tick. applySnapshot: (snapshot: StateSnapshot) => void; // ISS-002 (v0.15.0): report row hovers to the shell so the map can highlight the plane or // the package's endpoint airports. onHover: (target: HoverTarget | null) => void; // ISS-008 (v0.17.0): report a failed action to the shell as an error toast — in ADDITION // to the inline pane error, which stays (both surfaces by design). onError?: (message: string) => void; } const planeName = (p: PlaneState): string => PLANE_TYPES[p.model]?.name ?? p.model; // Packages occupying a plane's slots — 'loaded' cargo plus 'expired' cargo, which // keeps its slot until it is abandoned or the plane lands at its destination. export function slotsUsedFor(state: StateSnapshot, planeId: string): number { return state.packages.filter( (p) => p.planeId === planeId && (p.status === 'loaded' || p.status === 'expired'), ).length; } // Planes that can load `pkg` right now: GROUNDED at the package's origin with a // free slot (capacity from the snapshot catalog). The Load button is enabled iff // this list is non-empty; exactly one entry loads directly, several show a picker. export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): PlaneState[] { return state.planes.filter((p) => { if (p.status !== 'available') return false; if (p.locationIcao !== pkg.originIcao) return false; const capacity = state.catalog.find((e) => e.model === p.model)?.capacity ?? 0; return slotsUsedFor(state, p.id) < capacity; }); } // Load controls for one open package: a single Load button when exactly one plane // is eligible (it acts on that plane directly), a small plane picker + Load when // several are, and a disabled button with the reason otherwise. Each row owns its // selection in local state and defaults to the "Select a plane…" placeholder — // never preselecting — so rows stay visibly independent (v0.10.1 pattern). function LoadControls({ pkg, eligible, busy, onLoad, }: { pkg: PackageState; eligible: PlaneState[]; busy: boolean; onLoad: (planeId: string) => void; }) { const [selectedId, setSelectedId] = useState(null); // The user's pick while it stays eligible, else nothing (placeholder). const effective = eligible.find((p) => p.id === selectedId) ?? null; if (eligible.length === 1) { return ( ); } return (
); } export default function RightPane({ state, loading, applySnapshot, onHover, onError }: RightPaneProps) { const [busy, setBusy] = useState(false); // Inline error from a failed action — auto-clears on the next successful poll (the shell's // snapshot prop changes every tick) or the next successful action. const [actionError, setActionError] = useState(null); useEffect(() => { if (state !== null) setActionError(null); }, [state]); // Market picker selections — same "effective selection" pattern as LoadControls: the // user's pick while it stays valid, else a sensible default (first catalog entry / first plane). const [buyModelId, setBuyModelId] = useState(null); const [sellPlaneId, setSellPlaneId] = useState(null); const [refuelPlaneId, setRefuelPlaneId] = useState(null); // ISS-010 (v0.18.0): Relocate row selections — same "effective selection" pattern as the rows above: // the user's pick while it stays valid, else a sensible default (first grounded plane / first airport). const [relocatePlaneId, setRelocatePlaneId] = useState(null); const [relocateDestIcao, setRelocateDestIcao] = useState(null); // ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the // Relocate row, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx; // on failure airportOptions stays null and the Relocate button simply remains disabled. const [airportOptions, setAirportOptions] = useState | null>(null); const airportsPromiseRef = useRef> | null>(null); useEffect(() => { if (!airportsPromiseRef.current) { airportsPromiseRef.current = fetch('/api/airports', { cache: 'no-store' }) .then((res) => { if (!res.ok) throw new Error(`GET /api/airports → HTTP ${res.status}`); return res.json() as Promise; }) .then((fc) => fc.features .map((f) => ({ name: f.properties.name, icao: f.properties.icao })) .sort((a, b) => a.name.localeCompare(b.name, 'en')), ); } void airportsPromiseRef.current.then(setAirportOptions).catch(() => {}); }, []); // POST an action endpoint. Every success returns the FULL post-action snapshot (v0.8.0 // convention) — pushed into the shared state immediately; a 4xx {error} body becomes the // inline pane error and the stale UI stays until the next poll. Buttons are disabled while // a request is in flight, so at most one action runs at a time. const runAction = useCallback( async (path: string, body?: unknown) => { if (busy) return; setBusy(true); try { const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), cache: 'no-store', }); const data = (await res.json()) as StateSnapshot & { error?: string }; if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`); setActionError(null); applySnapshot(data); } catch (err) { const message = err instanceof Error ? err.message : String(err); setActionError(message); // inline pane error stays… onError?.(message); // …and the shell mirrors it as a bottom-right toast (ISS-008) } finally { setBusy(false); } }, [applySnapshot, busy, onError], ); if (loading && !state) { return ( ); } if (!state) return null; const clockMs = state.simState.clockMs; const open = state.packages.filter((p) => p.status === 'open'); // On board (v0.12.0): every plane that carries cargo or is mid-flight gets a block — // its loaded/expired packages with fly-to-destination + abandon controls, and flying // planes show their current leg. const onboard = state.planes.filter( (p) => p.status === 'flying' || slotsUsedFor(state, p.id) > 0, ); const catalog = state.catalog; const buyEntry = catalog.find((e) => e.model === buyModelId) ?? catalog[0] ?? null; const grounded = state.planes.filter((p) => p.status === 'available'); const sellPlane = state.planes.find((p) => p.id === sellPlaneId && p.status === 'available') ?? grounded[0] ?? null; const refuelable = grounded.filter((p) => p.fuel < p.maxFuel); const refuelPlane = state.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel) ?? refuelable[0] ?? null; // ISS-010 (v0.18.0): Relocate — any GROUNDED plane can be repositioned to any airport via the // existing fly endpoint (pure repositioning, no package needed since v0.12.0). const relocatePlane = grounded.find((p) => p.id === relocatePlaneId) ?? grounded[0] ?? null; const relocateDest = airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null; // Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0). const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined; const sellPayout = sellEntry ? Math.round(sellEntry.price * state.sellFraction * 100) / 100 : null; const refuelCost = refuelPlane ? (refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL : null; return ( ); }