feat: v0.10.0 — right Jobs & Market pane (contracts accept/dispatch, market buy/sell/refuel with live previews); catalog + economy constants exposed in /api/state; shared applySnapshot for instant action feedback
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
'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<string | null>(null);
|
||||
const effective =
|
||||
planes.find((p) => p.id === selectedId && dispatchReason(p, contract) === null) ??
|
||||
planes.find((p) => dispatchReason(p, contract) === null) ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<div className="dispatch-controls">
|
||||
<select
|
||||
data-testid={`dispatch-plane-${contract.id}`}
|
||||
aria-label={`Dispatch plane for ${contract.originName} to ${contract.destName}`}
|
||||
value={effective?.id ?? ''}
|
||||
onChange={(e) => setSelectedId(e.target.value || null)}
|
||||
>
|
||||
{planes.map((p) => {
|
||||
const reason = dispatchReason(p, contract);
|
||||
return (
|
||||
<option key={p.id} value={p.id} disabled={reason !== null}>
|
||||
{planeName(p)}
|
||||
{reason ? ` — ${reason}` : ''}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`dispatch-${contract.id}`}
|
||||
className="pane-btn"
|
||||
disabled={busy || effective === null}
|
||||
onClick={() => {
|
||||
if (effective) onDispatch(effective.id);
|
||||
}}
|
||||
>
|
||||
Dispatch
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(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<string | null>(null);
|
||||
const [sellPlaneId, setSellPlaneId] = useState<string | null>(null);
|
||||
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(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 (
|
||||
<aside data-testid="right-pane" className="pane right-pane">
|
||||
<h2 className="pane-title">Jobs & Market</h2>
|
||||
<p data-testid="right-loading" className="pane-muted">Loading…</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<aside data-testid="right-pane" className="pane right-pane">
|
||||
<h2 className="pane-title">Jobs & Market</h2>
|
||||
|
||||
<section className="pane-section">
|
||||
<h3 className="pane-heading">Open contracts</h3>
|
||||
{open.length === 0 ? (
|
||||
<p className="pane-muted">No open contracts.</p>
|
||||
) : (
|
||||
<ul data-testid="open-contracts" className="contract-list">
|
||||
{open.map((c) => (
|
||||
<li key={c.id} data-testid={`open-contract-${c.id}`} className="contract-row">
|
||||
<div className="contract-main">
|
||||
<span className="contract-route">{c.originName} → {c.destName}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`accept-${c.id}`}
|
||||
className="pane-btn"
|
||||
disabled={busy}
|
||||
onClick={() => void runAction(`/api/contracts/${c.id}/accept`)}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</div>
|
||||
<div className="contract-sub">
|
||||
<span data-testid={`open-contract-reward-${c.id}`}>{formatCash(c.reward)}</span>
|
||||
<span>{c.cargoType} × {c.cargoCount}</span>
|
||||
<span data-testid={`open-contract-deadline-${c.id}`}>
|
||||
{formatDeadlineRelative(c.deadlineSimTime, clockMs)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="pane-section">
|
||||
<h3 className="pane-heading">Accepted</h3>
|
||||
{accepted.length === 0 ? (
|
||||
<p className="pane-muted">Nothing accepted yet.</p>
|
||||
) : (
|
||||
<ul data-testid="accepted-contracts" className="contract-list">
|
||||
{accepted.map((c) => (
|
||||
<li key={c.id} data-testid={`accepted-contract-${c.id}`} className="contract-row">
|
||||
<div className="contract-main">
|
||||
<span className="contract-route">{c.originName} → {c.destName}</span>
|
||||
{c.planeId ? (
|
||||
<span data-testid={`contract-inflight-${c.id}`} className="plane-status status-flying">
|
||||
in flight
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="contract-sub">
|
||||
<span>{formatCash(c.reward)}</span>
|
||||
<span>{c.cargoType} × {c.cargoCount}</span>
|
||||
<span>{formatDeadlineRelative(c.deadlineSimTime, clockMs)}</span>
|
||||
</div>
|
||||
{!c.planeId && (
|
||||
<DispatchControls
|
||||
contract={c}
|
||||
planes={state.planes}
|
||||
busy={busy}
|
||||
onDispatch={(planeId) => void runAction(`/api/planes/${planeId}/dispatch`, { contractId: c.id })}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="pane-section">
|
||||
<h3 className="pane-heading">Market</h3>
|
||||
|
||||
<div className="market-row">
|
||||
<select
|
||||
data-testid="market-buy-model"
|
||||
aria-label="Plane to buy"
|
||||
value={buyEntry?.model ?? ''}
|
||||
onChange={(e) => setBuyModelId(e.target.value || null)}
|
||||
>
|
||||
{catalog.map((e) => (
|
||||
<option key={e.model} value={e.model}>
|
||||
{e.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span data-testid="market-buy-price" className="market-preview">
|
||||
{buyEntry ? formatCash(buyEntry.price) : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="market-buy"
|
||||
className="pane-btn"
|
||||
disabled={busy || buyEntry === null}
|
||||
onClick={() => {
|
||||
if (buyEntry) void runAction('/api/market/buy-plane', { model: buyEntry.model });
|
||||
}}
|
||||
>
|
||||
Buy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="market-row">
|
||||
<select
|
||||
data-testid="market-sell-plane"
|
||||
aria-label="Plane to sell"
|
||||
value={sellPlane?.id ?? ''}
|
||||
onChange={(e) => setSellPlaneId(e.target.value || null)}
|
||||
>
|
||||
{grounded.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{planeName(p)} — {p.locationIcao}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span data-testid="market-sell-payout" className="market-preview">
|
||||
{sellPayout !== null ? formatCash(sellPayout) : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="market-sell"
|
||||
className="pane-btn"
|
||||
disabled={busy || sellPlane === null}
|
||||
onClick={() => {
|
||||
if (sellPlane) void runAction('/api/market/sell-plane', { planeId: sellPlane.id });
|
||||
}}
|
||||
>
|
||||
Sell
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="market-row">
|
||||
{refuelable.length === 0 ? (
|
||||
<span data-testid="market-refuel-none" className="pane-muted">
|
||||
All tanks full.
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
data-testid="market-refuel-plane"
|
||||
aria-label="Plane to refuel"
|
||||
value={refuelPlane?.id ?? ''}
|
||||
onChange={(e) => setRefuelPlaneId(e.target.value || null)}
|
||||
>
|
||||
{refuelable.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{planeName(p)} — {Math.round(p.fuel)} / {p.maxFuel} L
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span data-testid="market-refuel-cost" className="market-preview">
|
||||
{refuelCost !== null ? formatCash(refuelCost) : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="market-refuel"
|
||||
className="pane-btn"
|
||||
disabled={busy || refuelPlane === null}
|
||||
onClick={() => {
|
||||
if (refuelPlane) void runAction(`/api/planes/${refuelPlane.id}/refuel`);
|
||||
}}
|
||||
>
|
||||
Refuel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user