Spaces:
Paused
Paused
| 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> | |
| ); | |
| } | |