'use client'; import { useState } from 'react'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import { MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, SELL_FRACTION } from '@/lib/game/catalog'; import { guaranteedRangeKm } from '@/lib/sim/movement'; import { formatCargo, formatCash, formatDeadlineRelative, formatKm, formatSpecialLabel } 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; // Cargo weight (kg) occupying a plane's payload — 'loaded' cargo plus 'expired' // cargo, which keeps its space until it is abandoned or the plane lands at its // destination. export function cargoWeightFor(state: StateSnapshot, planeId: string): number { return state.packages .filter((p) => p.planeId === planeId && (p.status === 'loaded' || p.status === 'expired')) .reduce((sum, p) => sum + p.weightKg, 0); } // Planes that can load `pkg` right now: GROUNDED at the package's origin with enough // payload room for it (maxPayloadKg 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 maxPayloadKg = state.catalog.find((e) => e.model === p.model)?.maxPayloadKg ?? 0; // Mirrors the server check in lib/game/actions.ts loadPackage. return cargoWeightFor(state, p.id) + pkg.weightKg <= maxPayloadKg; }); } // 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; ISS-055 (v0.31.0): the multi-plane path PRESELECTS the // first eligible plane so Load is one-click out of the box (matching the single-plane // UX) while the picker still allows choosing a different plane. function LoadControls({ pkg, eligible, busy, onLoad, }: { pkg: PackageState; eligible: PlaneState[]; busy: boolean; onLoad: (planeId: string) => void; }) { const [selectedId, setSelectedId] = useState(null); // ISS-055 (v0.31.0): the user's pick while it stays eligible, else the FIRST eligible plane — // Load is enabled and one-click whenever ≥1 plane qualifies; the picker still allows a different pick. const effective = eligible.find((p) => p.id === selectedId) ?? eligible[0] ?? 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); // v0.28.0 (ISS-047): Jobs / Planes market tabs — the inactive tab's content unmounts. const [activeTab, setActiveTab] = useState<'jobs' | 'planes'>('jobs'); // 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. // ISS-035 (v0.23.0): same for the Refuel-to-full row — its state + derived values moved to the // left pane's "Refuel" section next to Relocate. 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; // 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; // v0.38.0 (ISS-072): the buy row shows the EFFECTIVE price — same rep-scaled formula as the server's buyPlane. const planeDiscountRate = repRate(state.account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT); const buyPrice = buyEntry ? Math.round(buyEntry.price * (1 - planeDiscountRate) * 100) / 100 : null; return ( ); }