'use client'; import { useCallback, useEffect, useState } from 'react'; import type { ContractState, PlaneState, StateSnapshot } from '@/lib/game/state'; import { PLANE_TYPES } from '@/lib/game/catalog'; import { formatCash, formatDeadlineRelative } from '@/lib/format'; 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; } const planeName = (p: PlaneState): string => PLANE_TYPES[p.model]?.name ?? p.model; // Why a plane cannot be dispatched on `contract` — null when it can (grounded at the origin). function dispatchReason(p: PlaneState, contract: ContractState): string | null { if (p.status !== 'available') return 'in flight'; if (p.locationIcao !== contract.originIcao) return `at ${p.locationIcao}`; return null; } // Dispatch controls for one accepted, not-yet-dispatched contract: a plane picker where only // grounded planes at the origin are enabled (others disabled with the reason), plus a dispatch // button bound to the selection. The effective selection is the user's pick while it stays // eligible, else auto-tracks the first eligible plane so a fresh contract needs one click. function DispatchControls({ contract, planes, busy, onDispatch, }: { contract: ContractState; planes: PlaneState[]; busy: boolean; onDispatch: (planeId: string) => void; }) { const [selectedId, setSelectedId] = useState(null); const effective = planes.find((p) => p.id === selectedId && dispatchReason(p, contract) === null) ?? planes.find((p) => dispatchReason(p, contract) === null) ?? null; return (
); } export default function RightPane({ state, loading, applySnapshot }: 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 DispatchControls: 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); // 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) { setActionError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }, [applySnapshot, busy], ); if (loading && !state) { return ( ); } if (!state) return null; const clockMs = state.simState.clockMs; const open = state.contracts.filter((c) => c.status === 'open'); const accepted = state.contracts.filter((c) => c.status === 'accepted'); 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 ( ); }