/* 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 (
ROSTER · NEZNAKOV BOOST
Команда
бустеров
{safeTotal} {proWord}. Подтверждённый MMR Immortal,
7+ лет на сцене, средняя оценка {avgRating} из 5
по {fmtNum(totalOrders)} выполненным заказам.
{safeTotal}
бустеров
в составе
{online != null ? online : '—'}
сейчас
онлайн
{fmtNum(avgMmr)}
средний
MMR
{fmtNum(totalOrders)}
бустов
выполнено
);
}
/* ───────── 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 (
);
}
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 ? (
})
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 (
);
})}
);
}
function BoosterCard({ b, rank, total, showReviews }) {
const ringStyle = {
'--hue': b.bannerHue,
'--avatar-color': b.avatarColor
};
return (
{/* top banner strip */}
{/* rank corner */}
N°
{String(rank).padStart(2, "0")}
/ {String(total).padStart(2, "0")}
{/* tier ribbon */}
{b.tier}
{/* top zone: avatar + identity + headline stats */}
{b.avatarUrl ? (

{ e.currentTarget.style.display = 'none'; }}
/>
) : null}
{b.initials}
✓
{b.isOnline ? "Сейчас онлайн" : b.lastSeen}
{b.rolePill} · {b.roleRu}
{b.nickname}
{b.realName}
{b.rating.toFixed(2)}
({b.reviewsCount} {pluralRu(b.reviewsCount, ['отзыв', 'отзыва', 'отзывов'])})
{b.languages.join(" · ")}
{b.years} {pluralRu(b.years, ['год', 'года', 'лет'])} на сцене
Ответ {b.response}
{fmtNum(b.mmr)}
текущий MMR
пик: {fmtNum(b.peakMmr)}
{b.winrate}%
винрейт бустов
{fmtNum(b.games)} матчей
{b.mmrPerWeek}
MMR / неделю
темп: {b.avgTime}
{/* body */}
{/* left col: bio + heroes + position */}
{b.bio}
{b.heroes && b.heroes.length > 0 && (
<>
Pool · {b.heroes.length} героев
Сигнатур-пик: {b.heroes[0].name}
{b.heroes.map((h, i) => )}
>
)}
Распределение по позициям
Бейджи
{b.badges.map((bg, i) => (
{bg.text}
))}
{showReviews && b.reviews && b.reviews.length > 0 && (
<>
Отзывы клиентов
{b.reviews.slice(0, 2).map((r, i) => (
{r.date}
«{r.text}»
— {r.author}
))}
>
)}
{/* right col: CTA + extra info */}
);
}
/* ───────── 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();