'use client'; import { useState } from 'react'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import { PLANE_TYPES } from '@/lib/game/catalog'; import { formatCash, formatDeadlineRelative } from '@/lib/format'; import { useGameAction } from '@/lib/useGameAction'; 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) { // ISS-021 (v0.20.0): shared action runner — busy gate, inline pane error + shell toast on // failure, full post-action snapshot pushed into the shared state on success. const { runAction, busy, actionError } = useGameAction(state, applySnapshot, onError); // 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-023 (v0.21.0): the Relocate row moved to the left pane — it belongs with everything else // about our planes, not in the market. Its state + /api/airports fetch went with it. if (loading && !state) { return ( ); } if (!state) return null; const clockMs = state.simState.clockMs; const open = state.packages.filter((p) => p.status === 'open'); 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; // 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 ( ); }