1
0
Fork 0
polaris-task-force/src/components/frontend/logistics/ShipmentToasts.tsx

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>
);
}