191 lines
7.6 KiB
TypeScript
191 lines
7.6 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon, TruckIcon } from "lucide-react";
|
|
import type { Shipment, GameStructure, GameVehicle, Vehicle } from "@/payload-types";
|
|
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
|
|
import { useGameTick } from "@/hooks/useGameTick";
|
|
|
|
type ActiveShipment = Shipment & {
|
|
origin: GameStructure | number;
|
|
destination: GameStructure | number;
|
|
transportVehicle: GameVehicle & { type: Vehicle };
|
|
};
|
|
|
|
const statusBadge: Record<string, string> = {
|
|
dispatched: "bg-blue-500/20 text-blue-400",
|
|
in_transit: "bg-amber-500/20 text-amber-400",
|
|
};
|
|
|
|
const statusLabel: Record<string, string> = {
|
|
dispatched: "Dispatched",
|
|
in_transit: "In Transit",
|
|
};
|
|
|
|
// Module-scoped cache so the toast persists across re-mounts (page navigation
|
|
// re-mounts this client component; without this it flashes away and back).
|
|
const store: { shipments: ActiveShipment[]; open: boolean } = {
|
|
shipments: [],
|
|
open: false,
|
|
};
|
|
|
|
export function ShipmentToasts() {
|
|
const [shipments, setShipments] = useState<ActiveShipment[]>(store.shipments);
|
|
const [open, setOpenState] = useState(store.open);
|
|
|
|
const setOpen = useCallback((value: boolean | ((v: boolean) => boolean)) => {
|
|
setOpenState((prev) => {
|
|
const next = typeof value === "function" ? value(prev) : value;
|
|
store.open = next;
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const fetchShipments = useCallback(async () => {
|
|
const params = new URLSearchParams();
|
|
params.append("where[status][in][0]", "dispatched");
|
|
params.append("where[status][in][1]", "in_transit");
|
|
params.append("sort", "estimatedArrival");
|
|
params.append("limit", "20");
|
|
params.append("depth", "2");
|
|
try {
|
|
const r = await fetch(`/api/shipments?${params.toString()}`);
|
|
const body = await r.json();
|
|
store.shipments = body.docs ?? [];
|
|
setShipments(store.shipments);
|
|
} catch {
|
|
store.shipments = [];
|
|
setShipments([]);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchShipments();
|
|
const interval = setInterval(fetchShipments, 30_000);
|
|
return () => clearInterval(interval);
|
|
}, [fetchShipments]);
|
|
|
|
useGameTick(() => fetchShipments());
|
|
|
|
if (shipments.length === 0) return null;
|
|
|
|
const count = shipments.length;
|
|
const countLabel = `${count} active shipment${count !== 1 ? "s" : ""}`;
|
|
|
|
return (
|
|
<div className="fixed bottom-4 right-4 z-50 flex flex-col items-end gap-2">
|
|
{open && (
|
|
<div className="w-80 sm:w-96 rounded-xl border border-border bg-background/95 backdrop-blur shadow-xl overflow-hidden">
|
|
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-border">
|
|
<div className="flex items-center gap-2">
|
|
<TruckIcon className="size-4 text-primary" />
|
|
<span className="text-sm font-semibold">Active Shipments</span>
|
|
<span className="text-xs font-mono text-muted-foreground">
|
|
{count}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Link
|
|
href="/logistics/shipments"
|
|
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded hover:bg-muted transition-colors"
|
|
>
|
|
View all
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(false)}
|
|
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
aria-label="Collapse shipment tracker"
|
|
>
|
|
<ChevronDownIcon className="size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="max-h-[60vh] overflow-y-auto divide-y divide-border">
|
|
{shipments.map((shipment) => {
|
|
const origin =
|
|
typeof shipment.origin === "object" && shipment.origin !== null
|
|
? (shipment.origin as GameStructure)
|
|
: null;
|
|
const destination =
|
|
typeof shipment.destination === "object" &&
|
|
shipment.destination !== null
|
|
? (shipment.destination as GameStructure)
|
|
: null;
|
|
const vehicle =
|
|
typeof shipment.transportVehicle === "object" &&
|
|
shipment.transportVehicle !== null
|
|
? (shipment.transportVehicle as GameVehicle & { type: Vehicle })
|
|
: null;
|
|
const vehicleType =
|
|
vehicle && typeof vehicle.type === "object" ? vehicle.type : null;
|
|
|
|
return (
|
|
<Link
|
|
key={shipment.id}
|
|
href={`/logistics/shipments/${shipment.id}`}
|
|
className="block px-4 py-3 hover:bg-muted/50 transition-colors"
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-sm font-medium truncate">
|
|
{shipment.name ?? `SHP-${shipment.id}`}
|
|
</span>
|
|
<span
|
|
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0 ${
|
|
statusBadge[shipment.status] ?? "bg-muted text-muted-foreground"
|
|
}`}
|
|
>
|
|
{statusLabel[shipment.status] ?? shipment.status}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<span className="truncate">
|
|
{origin?.name ?? `Structure #${shipment.origin}`}
|
|
</span>
|
|
<ArrowRightIcon className="size-3 shrink-0" />
|
|
<span className="truncate">
|
|
{destination?.name ?? `Structure #${shipment.destination}`}
|
|
</span>
|
|
</div>
|
|
{vehicleType && (
|
|
<p className="mt-0.5 text-xs text-muted-foreground/80">
|
|
{vehicleType.name}
|
|
</p>
|
|
)}
|
|
{shipment.dispatchedAt && shipment.estimatedArrival && (
|
|
<div className="mt-2">
|
|
<ShipmentProgressBar
|
|
dispatchedAt={shipment.dispatchedAt}
|
|
estimatedArrival={shipment.estimatedArrival}
|
|
/>
|
|
</div>
|
|
)}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="flex items-center gap-2 rounded-full border border-border bg-background/95 backdrop-blur px-3.5 py-2 text-sm shadow-lg hover:border-primary/50 hover:scale-[1.02] transition-all"
|
|
aria-label={open ? "Collapse shipment tracker" : "Show active shipments"}
|
|
>
|
|
<span className="relative flex size-2">
|
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
|
|
<span className="relative inline-flex size-2 rounded-full bg-primary" />
|
|
</span>
|
|
<TruckIcon className="size-4 text-primary" />
|
|
<span className="font-medium">{countLabel}</span>
|
|
{open ? (
|
|
<ChevronDownIcon className="size-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronUpIcon className="size-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|