// Filter sheet — single category at a time. // Caller decides which tab (collection / model / backdrop / symbol) via `tab` prop. // Each row shows checkbox + thumbnail + label + rarity% (or floor for collection). const BACKDROP_COLORS = { 'Onyx Black': { edge: '#0a0a10', center: '#3a3a48' }, 'Pure Gold': { edge: '#7a4d00', center: '#ffd54a' }, 'Ruby Red': { edge: '#5e0008', center: '#ff3a55' }, 'Cyan Blue': { edge: '#003a55', center: '#22c4ff' }, 'Emerald Green': { edge: '#004520', center: '#41d182' }, 'Royal Purple': { edge: '#28004f', center: '#a96cff' }, 'Silver Gray': { edge: '#33333a', center: '#cdd0d4' }, 'Rose Quartz': { edge: '#922252', center: '#ff86c0' }, 'Burning Red': { edge: '#681100', center: '#ff7028' }, 'Ocean Blue': { edge: '#001f55', center: '#3aa0ff' }, 'Cosmic Indigo': { edge: '#160033', center: '#6a3aff' }, 'Velvet': { edge: '#1a0033', center: '#7c00d4' }, 'Tropical': { edge: '#005a4c', center: '#56f5d0' }, 'Sunset': { edge: '#6a1300', center: '#ffaa66' }, 'Aurora': { edge: '#003324', center: '#00ffd2' }, }; const TAB_LABELS = { collection: 'Коллекция', model: 'Модель', backdrop: 'Фон', symbol: 'Символ', }; // Per-collection attributes (Model/Backdrop/Symbol) from @GiftChanges // (api.changes.tg, CORS-open). `?sorted` = by rarity. Memoized per collection+tab. // Сбой НЕ кешируется и резолвится в null (не []) — чтобы UI показал ошибку/ретрай, // а не мок-имена, которых нет на бэке (выбор мок-имени = гарантированно пустая выдача). const _attrCache = {}; function loadGiftAttr(collection, tab) { const key = collection + '/' + tab; if (_attrCache[key]) return _attrCache[key]; const ep = tab === 'model' ? 'models' : tab === 'symbol' ? 'symbols' : 'backdrops'; _attrCache[key] = fetch(`https://api.changes.tg/${ep}/${encodeURIComponent(collection)}?sorted`) .then(r => (r.ok ? r.json() : Promise.reject(r.status))) .then(list => (Array.isArray(list) ? list : [])) .catch(() => { delete _attrCache[key]; return null; }); return _attrCache[key]; } // Warm a collection's attribute LISTS *and* their icon images into cache, so the // Model/Backdrop/Symbol filters open instantly with no skeleton and icons never // pop in. Idempotent: lists are memoized in _attrCache, images deduped below. const _imgPreloaded = {}; function _preloadImg(url) { if (_imgPreloaded[url]) return; _imgPreloaded[url] = true; const im = new Image(); im.src = url; } function prefetchGiftAttrs(collection) { if (!collection) return; ['model', 'symbol', 'backdrop'].forEach(tab => { loadGiftAttr(collection, tab).then(list => { if (tab === 'backdrop') return; // gradient swatches — nothing to preload (list || []).forEach(a => _preloadImg( `https://api.changes.tg/${tab}/${encodeURIComponent(collection)}/${encodeURIComponent(a.name)}.png?size=256` )); }); }); } window.prefetchGiftAttrs = prefetchGiftAttrs; function CheckBox({ on }) { return (
{on && }
); } function ModelThumb({ kind = 'PlushPepe', name }) { const [ok, setOk] = React.useState(true); return (
{ok && ( setOk(false)} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'contain', padding: 4, }}/> )}
); } function SymbolThumb({ kind = 'PlushPepe', name }) { const [ok, setOk] = React.useState(true); return (
{ok && ( setOk(false)} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'contain', padding: 8, opacity: 0.7, filter: 'drop-shadow(0 0 4px rgba(255,255,255,0.18))', }}/> )}
); } 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 ( <>
{tabLabel}
setQuery(e.target.value)} style={{ flex: 1, background: 'transparent', border: 0, outline: 0, color: 'var(--text)', fontSize: 14, }}/>
{tabLabel}
setSortDesc(v => !v)} style={{ cursor: 'pointer' }}> {rightLabel}
{loading ? ( ) : attrsFailed ? (
Не удалось загрузить атрибуты
) : (
{filtered.map((it, idx) => ( toggle(it.value)}/> ))} {filtered.length === 0 && (
Ничего не нашлось
)}
)}
); } window.FilterSheet = FilterSheet; window.BACKDROP_COLORS = BACKDROP_COLORS;