/* P.E.S.T Web App — token prices: real prices via nc-proxy.php?action=getPrices
(server-cached 5min from CoinGecko). Watchlist, picker, guest strip. */
/* ---------- live price engine: pulls from server every 5 min ---------- */
const PriceEngine = (function () {
// Map: token.id → { price, change }
let state = {};
let tokensRef = [];
const subs = new Set();
function publish() { subs.forEach((fn) => fn()); }
function applyResponse(resp) {
// resp.prices is keyed by CoinGecko id ("solana", "bitcoin", …)
const cg = (resp && resp.prices) || {};
const ns = {};
tokensRef.forEach((t) => {
const live = t.gecko && cg[t.gecko];
if (live) {
ns[t.id] = {
price: typeof live.usd === "number" ? live.usd : t.price,
change: typeof live.usd_24h_change === "number" ? live.usd_24h_change : t.change24h,
};
} else {
// Fallback: use seed values until next pull (or token has no gecko id)
const prev = state[t.id];
ns[t.id] = prev || { price: t.price, change: t.change24h };
}
});
state = ns;
publish();
}
async function pull() {
try {
const resp = await window.PEST_APP.api.getPrices();
applyResponse(resp);
} catch (e) { /* keep current state on failure */ }
}
function ensure(tokens) {
tokensRef = tokens || [];
// Seed any missing entries so the UI has something to render before the first pull lands
tokensRef.forEach((t) => { if (!state[t.id]) state[t.id] = { price: t.price, change: t.change24h }; });
}
// Refresh every 5 min; first call happens on initial ensure() via useLivePrices.
let timer = null, started = false;
function start() {
if (started) return;
started = true;
pull();
timer = setInterval(pull, 5 * 60 * 1000);
}
return {
get: () => state,
ensure: (tokens) => { ensure(tokens); start(); },
subscribe: (fn) => { subs.add(fn); return () => subs.delete(fn); },
refresh: pull,
};
})();
function useLivePrices(tokens) {
const [, force] = useState(0);
if (tokens) PriceEngine.ensure(tokens);
useEffect(() => PriceEngine.subscribe(() => force((x) => x + 1)), []);
return PriceEngine.get();
}
/* ---------- formatting ---------- */
function fmtPrice(p) {
if (p >= 1000) return "$" + p.toLocaleString("en-US", { maximumFractionDigits: 0 });
if (p >= 1) return "$" + p.toFixed(2);
if (p >= 0.01) return "$" + p.toFixed(4);
if (p >= 0.0001) return "$" + p.toFixed(6);
return "$" + p.toFixed(8);
}
function fmtChange(c) { return (c >= 0 ? "+" : "") + c.toFixed(2) + "%"; }
/* ---------- token tile: image if logo present, else deterministic-hue symbol tile ---------- */
function TokenTile({ token, className }) {
const src = window.resolveLogo(token.logo);
if (src) {
return (
);
}
const h = hueFromString(token.symbol + token.name);
const bg = `linear-gradient(150deg, hsl(${h} 68% 28%), hsl(${(h + 42) % 360} 62% 16%))`;
return (
{token.symbol.length > 4 ? token.symbol.slice(0, 4) : token.symbol}
);
}
/* ---------- dashboard watchlist card ---------- */
function TokenWatchlist({ onManage }) {
const store = useStore();
const prices = useLivePrices(store.tokens);
const byId = (id) => store.tokens.find((t) => t.id === id);
const watched = (store.user.watchTokens || []).map(byId).filter(Boolean);
return (
Markets
Your watchlist
{watched.length === 0 ? (
No tokens yet. Pick the coins you track and watch them live here.
) : (
{watched.map((t) => {
const live = prices[t.id] || { price: t.price, change: t.change24h };
const up = live.change >= 0;
return (
{t.symbol}
{t.name}
{fmtPrice(live.price)}
{fmtChange(live.change)}
);
})}
)}
);
}
/* ---------- token picker modal ---------- */
function TokenPicker({ onClose }) {
const store = useStore();
const [q, setQ] = useState("");
useEffect(() => {
const k = (e) => e.key === "Escape" && onClose();
window.addEventListener("keydown", k);
return () => window.removeEventListener("keydown", k);
}, []);
const watched = store.user.watchTokens || [];
const MAX_WATCH = window.PEST_APP.MAX_WATCH;
const full = watched.length >= MAX_WATCH;
const ql = q.trim().toLowerCase();
const list = store.tokens.filter(
(t) => !ql || t.symbol.toLowerCase().includes(ql) || t.name.toLowerCase().includes(ql)
);
const toggle = (t) => {
if (!watched.includes(t.id) && full) { toast("Watchlist is full (" + MAX_WATCH + " max)", "err"); return; }
store.toggleToken(t.id);
};
return ReactDOM.createPortal(
e.stopPropagation()}>
Pick the tokens you want on your dashboard. {watched.length}/{MAX_WATCH} selected · the community's top 6 show on the public board.
setQ(e.target.value)} />
{list.map((t) => {
const on = watched.includes(t.id);
const blocked = !on && full;
return (
);
})}
{list.length === 0 &&
No tokens match “{q}”.
}
,
document.body
);
}
/* ---------- public board: top 6 most-watched tokens ---------- */
function topCommunityTokens(store, n) {
const mine = new Set(store.user.watchTokens || []);
return [...store.tokens]
.map((t) => ({ t, score: t.watchers + (mine.has(t.id) ? 1 : 0) }))
.sort((a, b) => b.score - a.score)
.slice(0, n)
.map((x) => x.t);
}
function GuestTokenStrip({ tokens }) {
const store = useStore();
// Guests get the pre-ranked public-board tokens via prop; fall back to the store otherwise.
const top = ((tokens && tokens.length) ? tokens : topCommunityTokens(store, 8)).slice(0, 8);
const prices = useLivePrices(top);
if (!top.length) return null;
return (
Most-watched by the Core
{top.map((t) => {
const live = prices[t.id] || { price: t.price, change: t.change24h };
const up = live.change >= 0;
return (
{t.symbol}
{fmtChange(live.change)}
{fmtPrice(live.price)}
);
})}
);
}
Object.assign(window, {
PriceEngine, useLivePrices, fmtPrice, fmtChange, TokenTile,
TokenWatchlist, TokenPicker, GuestTokenStrip, topCommunityTokens,
});