);
}
function BackdropSwatch({ name, hex }) {
// Real colors from GiftChanges when available; fall back to the local map.
const c = hex
? { center: hex.centerColor, edge: hex.edgeColor }
: (BACKDROP_COLORS[name] || { edge: '#222', center: '#666' });
return (
);
}
// Skeleton rows shown while real floor/attribute data loads — avoids flashing
// placeholder prices and avoids a sort jump when data arrives.
function FilterRowsSkeleton({ rows = 9 }) {
return (
{Array.from({ length: rows }).map((_, i) => (
))}
);
}
function FilterRow({ tab, item, on, onClick, idx = 0, collection }) {
let thumb = null;
if (tab === 'collection') thumb = ;
else if (tab === 'model') thumb = ;
else if (tab === 'symbol') thumb = ;
else if (tab === 'backdrop') thumb = ;
const right = item.rarity != null
? `${item.rarity}%`
: (item.floor != null ? fmtRub(item.floor) : '—');
return (
{thumb}
{item.label}
{right}
);
}
function FilterSheet({ filters, tab = 'collection', collection = null, onApply, onClose }) {
const [query, setQuery] = React.useState('');
const [sortDesc, setSortDesc] = React.useState(true);
const [local, setLocal] = React.useState(filters);
// Real floor prices from our API (collection tab only); null until loaded.
const [floors, setFloors] = React.useState(null);
React.useEffect(() => {
if (tab === 'collection' && window.loadCollectionFloors) window.loadCollectionFloors().then(setFloors);
}, [tab]);
// Real per-collection attributes from GiftChanges (model/backdrop/symbol tabs).
// null = грузится, 'error' = сбой (ретрай кнопкой), [] / [...] = загружено.
const [attrs, setAttrs] = React.useState(null);
const loadAttrs = React.useCallback(() => {
if (tab !== 'collection' && collection) {
setAttrs(null);
loadGiftAttr(collection, tab).then(list => setAttrs(list === null ? 'error' : list));
}
}, [tab, collection]);
React.useEffect(() => { loadAttrs(); }, [loadAttrs]);
const toggle = (val) => setLocal(prev => {
const arr = prev[tab] || [];
return { ...prev, [tab]: arr.includes(val) ? arr.filter(x => x !== val) : [...arr, val] };
});
// «Сбросить» применяется СРАЗУ (не теряется при закрытии крестиком/свайпом).
const reset = () => {
const next = { ...local, [tab]: [] };
setLocal(next);
onApply(next);
};
const items = React.useMemo(() => {
if (tab === 'collection') {
// Основной источник — живые /collections (реальные имена/floor); хардкод
// GIFT_LIST только как фоллбэк, пока floors не загрузились/пустые.
const floorSlugs = floors ? Object.keys(floors) : [];
const slugs = floorSlugs.length ? floorSlugs : GIFT_LIST;
return slugs.map(k => {
const real = floors && floors[k];
const realFloor = real && real.floor_rub != null ? real.floor_rub : null;
return {
value: k,
label: (real && real.name) || (GIFT_KINDS[k] && GIFT_KINDS[k].name) || k,
floor: realFloor, // null = нет реальной цены → рисуем «—»
real: realFloor != null,
};
});
}
// Model / Backdrop / Symbol — ТОЛЬКО реальные имена из GiftChanges.
// Мок-фоллбэка нет: выбор несуществующего имени ломает реальную выдачу.
if (Array.isArray(attrs)) {
return attrs.map(a => ({
value: a.name, label: a.name,
rarity: +(((a.rarityPermille || 0) / 10)).toFixed(1),
hex: a.hex,
}));
}
return [];
}, [tab, floors, attrs]);
// While real data is in flight show a skeleton (no mock-price flash, no sort jump).
const loading =
(tab === 'collection' && floors === null) ||
(tab !== 'collection' && !!collection && attrs === null);
const attrsFailed = tab !== 'collection' && attrs === 'error';
// Уже выбранные (снапшот на момент ОТКРЫТИЯ шита) закрепляем СВЕРХУ; ниже — по цене/редкости.
// deps только [tab]: шит перемонтируется при каждом открытии, а обновление filters
// после Apply не должно дёргать порядок во время анимации закрытия.
const pinnedSel = React.useMemo(() => new Set(filters[tab] || []), [tab]); // eslint-disable-line
// Выбранные значения, которых нет в загруженном списке (смена коллекции и т.п.) —
// дорисовываем призрачными строками, чтобы галку можно было снять.
const missingSel = (local[tab] || [])
.filter(v => !items.some(it => it.value === v))
.map(v => ({ value: v, label: v, floor: null, missing: true }));
const q = query.trim().toLowerCase();
const filtered = [...missingSel, ...items]
.filter(it => !q || it.label.toLowerCase().includes(q))
.sort((a, b) => {
const asel = (pinnedSel.has(a.value) || a.missing) ? 1 : 0;
const bsel = (pinnedSel.has(b.value) || b.missing) ? 1 : 0;
if (asel !== bsel) return bsel - asel; // выбранные — первыми
if (a.real !== b.real && tab === 'collection') return (b.real ? 1 : 0) - (a.real ? 1 : 0); // без цены — в конец
const av = a.rarity ?? a.floor ?? 0;
const bv = b.rarity ?? b.floor ?? 0;
return sortDesc ? bv - av : av - bv; // затем по цене/редкости
});
const tabLabel = TAB_LABELS[tab];
const rightLabel = tab === 'collection' ? 'Floor price' : 'Rarity';
const cur = local[tab] || [];
const count = cur.length;
return (
);
}
// Inner body that needs SheetCloseContext access for the X button.
function FilterSheetBody({ tabLabel, rightLabel, count, query, setQuery, sortDesc, setSortDesc,
filtered, tab, cur, toggle, reset, local, onApply, loading, collection,
attrsFailed, retryAttrs }) {
const closeAnim = React.useContext(SheetCloseContext);
return (
<>