WarTracker / client /src /components /WatchlistTable.jsx
skander101's picture
initial commit: vibes only, probably bloated
6512187
Raw
History Blame Contribute Delete
7.69 kB
import { useState, useEffect, useCallback, useRef } from "react";
import { useWatchlist } from "../context/WatchlistContext.jsx";
const STATIC_ASSETS = "https://warframe.market/static/assets";
function computeStats(orders) {
const sells = orders
.filter((o) => o.type === "sell" && o.user?.status !== "invisible")
.sort((a, b) => a.platinum - b.platinum);
const buys = orders
.filter((o) => o.type === "buy" && o.user?.status !== "invisible")
.sort((a, b) => b.platinum - a.platinum);
const lowestSell = sells.length > 0 ? sells[0].platinum : null;
const highestBuy = buys.length > 0 ? buys[0].platinum : null;
const activeSells = sells.length;
return { lowestSell, highestBuy, activeSells };
}
export default function WatchlistTable({ priceMin, priceMax, onItemSelect }) {
const { watchlist, toggleItem } = useWatchlist();
const [catalog, setCatalog] = useState({});
const [ordersCache, setOrdersCache] = useState({});
const [loading, setLoading] = useState(true);
const [errors, setErrors] = useState({});
const [sortKey, setSortKey] = useState("name");
const [sortAsc, setSortAsc] = useState(true);
const intervalRef = useRef(null);
// Fetch catalog for watchlist items
useEffect(() => {
let cancelled = false;
async function loadCatalog() {
try {
const res = await fetch("/api/items");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (!cancelled) {
const map = {};
for (const item of data) map[item.slug] = item;
setCatalog(map);
}
} catch (err) {
console.error("Failed to load catalog:", err);
} finally {
if (!cancelled) setLoading(false);
}
}
loadCatalog();
return () => { cancelled = true; };
}, []);
const fetchOrders = useCallback(async () => {
for (const slug of watchlist) {
try {
const res = await fetch(`/api/orders/${encodeURIComponent(slug)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setOrdersCache((prev) => ({ ...prev, [slug]: data }));
setErrors((prev) => {
const next = { ...prev };
delete next[slug];
return next;
});
} catch (err) {
setErrors((prev) => ({ ...prev, [slug]: err.message }));
}
}
}, [watchlist]);
useEffect(() => {
if (watchlist.length === 0) return;
fetchOrders();
intervalRef.current = setInterval(fetchOrders, 60000);
return () => clearInterval(intervalRef.current);
}, [watchlist, fetchOrders]);
const rows = watchlist
.filter((slug) => catalog[slug])
.map((slug) => {
const item = catalog[slug];
const orders = ordersCache[slug] || [];
const stats = computeStats(orders);
const name = item.i18n?.en?.name || slug;
const thumb = item.i18n?.en?.thumb;
const tags = item.tags || [];
return { slug, name, thumb, tags, ...stats, hasError: !!errors[slug] };
});
const sorted = [...rows].sort((a, b) => {
let va, vb;
if (sortKey === "name") { va = a.name; vb = b.name; }
else if (sortKey === "platinum") { va = a.lowestSell ?? 99999; vb = b.lowestSell ?? 99999; }
else if (sortKey === "buy") { va = a.highestBuy ?? -1; vb = b.highestBuy ?? -1; }
else if (sortKey === "spread") {
va = (a.lowestSell != null && a.highestBuy != null) ? a.lowestSell - a.highestBuy : 99999;
vb = (b.lowestSell != null && b.highestBuy != null) ? b.lowestSell - b.highestBuy : 99999;
}
else { va = a.activeSells; vb = b.activeSells; }
if (va < vb) return sortAsc ? -1 : 1;
if (va > vb) return sortAsc ? 1 : -1;
return 0;
});
const filtered = sorted.filter((r) => {
if (r.lowestSell == null && r.highestBuy == null) return true;
const effective = r.lowestSell ?? 0;
return effective >= priceMin && effective <= priceMax;
});
const sortArrow = (key) => {
if (sortKey !== key) return "";
return <span className="sort-arrow">{sortAsc ? "\u25B2" : "\u25BC"}</span>;
};
if (loading) {
return <div className="loading"><div className="spinner" /> Loading catalog...</div>;
}
if (watchlist.length === 0) {
return (
<div className="empty-msg">
Your watchlist is empty. Go to the Catalog tab and star some items.
</div>
);
}
return (
<div className="table-wrap">
<table>
<thead>
<tr>
<th style={{ width: 40 }}></th>
<th onClick={() => { setSortKey("name"); setSortAsc(sortKey === "name" ? !sortAsc : true); }}>
Name{sortArrow("name")}
</th>
<th>Tags</th>
<th onClick={() => { setSortKey("platinum"); setSortAsc(sortKey === "platinum" ? !sortAsc : true); }}>
Platinum{sortArrow("platinum")}
</th>
<th onClick={() => { setSortKey("buy"); setSortAsc(sortKey === "buy" ? !sortAsc : true); }}>
Highest Buy{sortArrow("buy")}
</th>
<th onClick={() => { setSortKey("spread"); setSortAsc(sortKey === "spread" ? !sortAsc : true); }}>
Spread{sortArrow("spread")}
</th>
<th onClick={() => { setSortKey("sells"); setSortAsc(sortKey === "sells" ? !sortAsc : true); }}>
# Sells{sortArrow("sells")}
</th>
</tr>
</thead>
<tbody>
{filtered.map((row) => {
const spread =
row.lowestSell != null && row.highestBuy != null
? row.lowestSell - row.highestBuy
: null;
return (
<tr key={row.slug} style={{ cursor: "pointer" }} onClick={() => onItemSelect(row.slug)}>
<td>
<button
className="star-btn starred"
onClick={(e) => { e.stopPropagation(); toggleItem(row.slug); }}
title="Remove from watchlist"
>
{"\u2605"}
</button>
</td>
<td>
<div className="item-name">
{row.thumb && (
<img
className="item-thumb"
src={`${STATIC_ASSETS}/${row.thumb}`}
alt=""
loading="lazy"
/>
)}
<span>{row.name}</span>
{row.hasError && <span style={{ color: "var(--red)", fontSize: "0.75rem" }}> (error)</span>}
</div>
</td>
<td>
{row.tags.slice(0, 3).map((tag) => (
<span key={tag} className="tag-badge">{tag}</span>
))}
</td>
<td className="price-sell">
{row.lowestSell != null ? `${row.lowestSell}\u25BC` : "\u2014"}
</td>
<td className="price-buy">
{row.highestBuy != null ? `${row.highestBuy}\u25B2` : "\u2014"}
</td>
<td className="price-spread">
{spread != null ? (spread >= 0 ? `+${spread}` : spread) : "\u2014"}
</td>
<td>{row.activeSells}</td>
</tr>
);
})}
{filtered.length === 0 && (
<tr>
<td colSpan={7} className="empty-msg">
No watchlist items match the price filter.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}