/* boosters-app.jsx — main React app for the Boosters roster page. Reads data from window.BOOSTERS, renders hero + filter + cards. */ const { useState, useMemo, useEffect, useRef } = React; /* ───────── tweaks defaults ───────── */ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "accent": "violet", "density": "comfortable", "showReviews": true, "animateOnEnter": true }/*EDITMODE-END*/; /* ───────── small helpers ───────── */ const fmtNum = n => Number(n || 0).toLocaleString("ru-RU"); const cls = (...xs) => xs.filter(Boolean).join(" "); /* ───────── Hero ───────── */ function pluralRu(n, forms) { // forms = [for 1, for 2-4, for many]; e.g. ['бустер', 'бустера', 'бустеров'] var mod10 = n % 10, mod100 = n % 100; if (mod10 === 1 && mod100 !== 11) return forms[0]; if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return forms[1]; return forms[2]; } // Подписка на глобальный счётчик «бустеров на связи» из boosters-online.js, // чтобы KPI «сейчас онлайн» совпадал с числом в шапке. function useBoostersOnline(fallback) { const initial = (() => { if (window.BoostersOnline && typeof window.BoostersOnline.getValue === 'function') { const v = window.BoostersOnline.getValue(); if (typeof v === 'number' && v > 0) return v; } return fallback; })(); const [val, setVal] = useState(initial); useEffect(() => { if (!window.BoostersOnline || typeof window.BoostersOnline.onChange !== 'function') return; window.BoostersOnline.onChange(setVal); }, []); return val; } // Витринное число бустеров в составе — статичное, не зависит от загруженных данных. const TOTAL_BOOSTERS = 148; function Hero({ boosters }) { const safeTotal = TOTAL_BOOSTERS; const online = useBoostersOnline(null); const avgMmr = boosters.length ? Math.round(boosters.reduce((s, b) => s + (b.mmr || 0), 0) / boosters.length) : 0; const totalOrders = boosters.reduce((s, b) => s + (b.completed || 0), 0); const avgWr = boosters.length ? Math.round(boosters.reduce((s, b) => s + (b.winrate || 0), 0) / boosters.length) : 0; const avgRating = boosters.length ? (boosters.reduce((s, b) => s + (b.rating || 0), 0) / boosters.length).toFixed(2) : '—'; const proWord = pluralRu(safeTotal, ['верифицированный профессионал', 'верифицированных профессионала', 'верифицированных профессионалов']); return (
); } /* ───────── Filters ───────── */ const FILTERS = [ { id: "all", label: "Все" }, { id: "online", label: "Онлайн", dot: true }, { id: "15k", label: "15 000+ MMR" }, { id: "14k", label: "14 000+ MMR" }, { id: "carry", label: "Керри" }, { id: "mid", label: "Мидер" }, { id: "offlane", label: "Оффлейн" }, { id: "support", label: "Саппорт" } ]; function countFor(id, boosters) { if (id === "all") return boosters.length; if (id === "online") return boosters.filter(b => b.isOnline).length; if (id === "15k") return boosters.filter(b => b.mmr >= 15000).length; if (id === "14k") return boosters.filter(b => b.mmr >= 14000).length; if (id === "carry") return boosters.filter(b => b.role === "Carry").length; if (id === "mid") return boosters.filter(b => b.role === "Mid").length; if (id === "offlane") return boosters.filter(b => b.role === "Offlane").length; if (id === "support") return boosters.filter(b => b.role === "Support").length; return 0; } function FilterBar({ value, onChange, boosters }) { return (
{FILTERS.map(f => { const c = countFor(f.id, boosters); const active = value === f.id; return ( ); })}
); } /* ───────── Sort menu ───────── */ const SORTS = [ { id: "mmr", label: "MMR" }, { id: "wr", label: "Винрейт" }, { id: "orders", label: "Заказов" }, { id: "rating", label: "Рейтинг" } ]; function SortBar({ value, onChange }) { return (
Сортировка
{SORTS.map(s => ( ))}
); } /* ───────── Card ───────── */ function StatPill({ label, value, accent }) { return (
{value}
{label}
); } function Stars({ rating }) { const full = Math.round(rating); return ( {Array.from({ length: 5 }).map((_, i) => ( ))} ); } function heroImgSrc(name){ // Files in assets/heroes are named exactly like the in-game hero: "Shadow Fiend.webp", "Anti-Mage.webp", ... return `./assets/heroes/${name}.webp`; } function HeroTile({ hero, index }) { const [imgOk, setImgOk] = useState(true); return (
{imgOk ? ( {hero.name} setImgOk(false)} /> ) : (
{hero.glyph || "★"}
)}
{hero.name}
); } function PositionBar({ positions }) { const labels = ["Pos 1", "Pos 2", "Pos 3", "Pos 4", "Pos 5"]; return (
{[1, 2, 3, 4, 5].map((p, i) => { const pct = positions[p] || 0; return (
{labels[i]}
{pct}%
); })}
); } function BoosterCard({ b, rank, total, showReviews }) { const ringStyle = { '--hue': b.bannerHue, '--avatar-color': b.avatarColor }; return (
{/* top banner strip */}
); } /* ───────── App ───────── */ function App() { const [filter, setFilter] = useState("all"); const [sort, setSort] = useState("mmr"); const [boosters, setBoosters] = useState(() => Array.isArray(window.BOOSTERS) ? window.BOOSTERS : []); const [loadState, setLoadState] = useState("loading"); // loading | ready | error const [tweaks, setTweak] = window.useTweaks ? window.useTweaks(TWEAK_DEFAULTS) : [TWEAK_DEFAULTS, () => {}]; useEffect(() => { let cancelled = false; if (!window.NeznakovBoostersAdapter) { setLoadState("ready"); return; } window.NeznakovBoostersAdapter.fetch() .then(arr => { if (cancelled) return; if (Array.isArray(arr) && arr.length) { setBoosters(arr); setLoadState("ready"); } else { setLoadState(boosters.length ? "ready" : "error"); } }) .catch(() => { if (!cancelled) setLoadState(boosters.length ? "ready" : "error"); }); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Apply theme CSS variables based on accent tweak. Violet matches the site palette (#8B5CF6/#A78BFA). useEffect(() => { const root = document.documentElement; const themes = { violet: { h: "295", accent: "#8B5CF6", accent2: "#A78BFA", dim: "rgba(139,92,246,.14)", glow: "rgba(139,92,246,.45)" }, teal: { h: "168", accent: "oklch(0.84 0.13 168)", accent2: "oklch(0.92 0.10 188)", dim: "oklch(0.84 0.13 168 / 0.14)", glow: "oklch(0.84 0.13 168 / 0.45)" }, amber: { h: "55", accent: "oklch(0.80 0.16 55)", accent2: "oklch(0.90 0.14 70)", dim: "oklch(0.80 0.16 55 / 0.14)", glow: "oklch(0.80 0.16 55 / 0.45)" }, crimson:{ h: "20", accent: "oklch(0.72 0.18 20)", accent2: "oklch(0.84 0.16 30)", dim: "oklch(0.72 0.18 20 / 0.14)", glow: "oklch(0.72 0.18 20 / 0.45)" } }; const t = themes[tweaks.accent] || themes.violet; root.style.setProperty("--accent", t.accent); root.style.setProperty("--accent-2", t.accent2); root.style.setProperty("--accent-dim", t.dim); root.style.setProperty("--accent-glow", t.glow); root.style.setProperty("--accent-h", t.h); }, [tweaks.accent]); useEffect(() => { document.documentElement.dataset.density = tweaks.density; document.documentElement.dataset.animate = tweaks.animateOnEnter ? "yes" : "no"; }, [tweaks.density, tweaks.animateOnEnter]); const filtered = useMemo(() => { let arr = boosters.slice(); if (filter === "online") arr = arr.filter(b => b.isOnline); else if (filter === "15k") arr = arr.filter(b => b.mmr >= 15000); else if (filter === "14k") arr = arr.filter(b => b.mmr >= 14000); else if (filter === "carry") arr = arr.filter(b => b.role === "Carry"); else if (filter === "mid") arr = arr.filter(b => b.role === "Mid"); else if (filter === "offlane") arr = arr.filter(b => b.role === "Offlane"); else if (filter === "support") arr = arr.filter(b => b.role === "Support"); if (sort === "mmr") arr.sort((a, b) => b.mmr - a.mmr); else if (sort === "wr") arr.sort((a, b) => b.winrate - a.winrate); else if (sort === "orders") arr.sort((a, b) => b.completed - a.completed); else if (sort === "rating") arr.sort((a, b) => b.rating - a.rating); return arr; }, [filter, sort, boosters]); return ( <>
{loadState === "loading" && boosters.length === 0 ? (
Загружаем команду…
) : loadState === "error" && boosters.length === 0 ? (
Не удалось загрузить состав. Попробуйте обновить страницу.
) : filtered.length === 0 ? (
Никого не найдено по выбранному фильтру.
) : ( <>
{filtered.map((b, i) => ( ))}
)}
{window.TweaksPanel && ( {window.TweakSection && ( setTweak("accent", v)} options={[ { value: "teal", label: "Teal" }, { value: "violet", label: "Violet" }, { value: "amber", label: "Amber" }, { value: "crimson", label: "Crimson" } ]} /> setTweak("density", v)} options={[ { value: "compact", label: "Compact" }, { value: "comfortable", label: "Comfort" }, { value: "spacious", label: "Spacious" } ]} /> )} {window.TweakSection && ( setTweak("showReviews", v)} /> setTweak("animateOnEnter", v)} /> )} )} ); } const root = ReactDOM.createRoot(document.getElementById("app")); root.render();