// Market — ПЛОСКАЯ лента реальных NFT-подарков (отдельные NFT, не категории): // по умолчанию самые дёшевые по всему маркету (useMarketFeed). Выбор коллекции в // фильтрах → её листинги (useGifts). Модель/Фон/Символ — только при 1 коллекции. // Данные через window.DataSource. Картинка карточки = реальное photo_url NFT (с фоном). // Add-to-cart button. Tap to add; once in the cart it shows a green check — // press & hold ~0.5s (a sweeping ring fills) to remove it from the cart. function AddCartButton({ inCart, onAdd, onRemove }) { const [holding, setHolding] = React.useState(false); const timer = React.useRef(0); const fired = React.useRef(false); const start = (e) => { e.stopPropagation(); fired.current = false; if (!inCart) return; setHolding(true); clearTimeout(timer.current); timer.current = setTimeout(() => { fired.current = true; setHolding(false); onRemove(); if (navigator.vibrate) navigator.vibrate(25); }, 500); }; const cancel = () => { clearTimeout(timer.current); setHolding(false); }; const click = (e) => { e.stopPropagation(); if (inCart || fired.current) return; // in cart → use long-press to remove onAdd(); }; React.useEffect(() => () => clearTimeout(timer.current), []); return ( ); } function GiftCard({ listing, onClick, onAddToCart, onRemoveFromCart, inCart, idx = 0 }) { return (
{/* Реальная скидка от floor коллекции — зелёный бейдж */} {(Number(listing.discount_pct) || 0) >= 1 && (
−{Math.round(listing.discount_pct)}%
)} {listing.num != null && (
#{listing.num}
)}
{listing.name}
{fmtRub(listing.rub)}
{listing.ton > 0 && (
{listing.ton} TON
)}
{onAddToCart && ( onAddToCart(listing)} onRemove={() => onRemoveFromCart && onRemoveFromCart(listing.id)}/> )}
); } // Скелетон-плитка на время загрузки. function CardSkeleton({ idx = 0 }) { return (
); } // Кольцевой лоадер ожидания (CSS .mp-spinner). small — для подгрузки внутри ленты // (горизонтально, компактно). Большой — по центру области, текст ПОД спиннером. function Spinner({ small = false, label }) { if (small) { return (
{label && {label}}
); } return (
{label && {label}}
); } function MarketScreen({ onGift, onAddToCart, onRemoveFromCart, cart, advFilters, openFilters, hiddenIds }) { const PAGE_SIZE = 50; const BUFFER_PAGES = 2; // запас ~3 страницы вперёд (каждая = неск. Portals-поисков ~5с, не жадничаем) const hidden = hiddenIds || new Set(); const DS = window.DataSource || {}; const [sort, setSort] = React.useState('feed'); // feed = по возрастанию цены (лента) const [query, setQuery] = React.useState(''); const [page, setPage] = React.useState(1); // Коллекция выбрана (ровно одна) → режим коллекции; иначе → общая лента. // Имя резолвим по живой карте /collections (канонично для Portals), фоллбэк — хардкод GIFT_KINDS. const slug = advFilters.collection.length === 1 ? advFilters.collection[0] : null; const collectionName = slug ? ((window.__COLLECTION_NAMES && window.__COLLECTION_NAMES[slug]) || (window.GIFT_KINDS[slug] && window.GIFT_KINDS[slug].name) || null) : null; const apiSort = sort === 'price-desc' ? 'price_desc' : 'price_asc'; const csv = (a) => Array.isArray(a) ? a.join(',') : (a || ''); const advKey = JSON.stringify(advFilters); // Оба хука вызываются ВСЕГДА (правила хуков); реально грузит лишь активный: // feed — когда коллекция НЕ выбрана; gifts — когда выбрана. const feed = DS.useMarketFeed ? DS.useMarketFeed({ enabled: !collectionName }) : { items: [], loading: false, hasMore: false, error: null, loadMore: () => {} }; const gifts = DS.useGifts ? DS.useGifts({ gift: collectionName || '', model: csv(advFilters.model), backdrop: csv(advFilters.backdrop), symbol: csv(advFilters.symbol), sort: apiSort, }) : { items: [], loading: false, hasMore: false, error: null, loadMore: () => {} }; const active = collectionName ? gifts : feed; // Сброс на 1-ю страницу при смене контекста. React.useEffect(() => { setPage(1); }, [collectionName, sort, advKey, query]); // Догружаем С ЗАПАСОМ (~BUFFER_PAGES страниц вперёд), чтобы «Дальше» открывалась без ожидания. React.useEffect(() => { const target = (page + BUFFER_PAGES) * PAGE_SIZE; if (active.items.length < target && active.hasMore && !active.loading) active.loadMore(); }, [page, collectionName, active.items.length, active.hasMore, active.loading]); const q = query.toLowerCase().trim(); let visible = active.items.filter(l => !(hidden.has && hidden.has(l.id))); // 2+ коллекций: бэк per-collection не умеет — фильтруем ленту клиентски по слагам. if (!collectionName && advFilters.collection.length > 1) { visible = visible.filter(l => advFilters.collection.includes(l.kind)); } if (q) visible = visible.filter(l => (l.name || '').toLowerCase().includes(q) || String(l.num || '').includes(q)); // Сортируем ВЕСЬ загруженный пул до нарезки страниц (не только текущую страницу). if (sort !== 'feed') { visible = visible.slice().sort((a, b) => sort === 'price-asc' ? a.rub - b.rub : b.rub - a.rub); } const pageItems = visible.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); const hasNext = (!q && active.hasMore) || visible.length > page * PAGE_SIZE; const advCount = Object.values(advFilters).reduce((s, a) => s + (a ? a.length : 0), 0); const goPage = (p) => { if (p < 1) return; setPage(p); if (window.SFX) window.SFX.click(); document.querySelectorAll('.screen-scroll').forEach(el => { el.scrollTop = 0; }); }; const cycleSort = () => { setSort(s => s === 'feed' ? 'price-desc' : s === 'price-desc' ? 'price-asc' : 'feed'); if (window.SFX) window.SFX.click(); }; const ToolBtn = ({ children, onClick, badge }) => ( ); // Чип категории фильтра. Модель/Фон/Символ — атрибуты экземпляра, доступны // только когда выбрана ровно одна коллекция. const CategoryChip = ({ tab, label }) => { const n = advFilters[tab]?.length || 0; const on = n > 0; const locked = tab !== 'collection' && advFilters.collection.length !== 1; return ( // locked-чип НЕ дизейблим: на таче title-тултип не работает — по тапу ведём // юзера сразу в выбор коллекции. ); }; return (
setQuery(e.target.value)} style={{ background: 'transparent', padding: '6px 0', flex: 1, fontSize: 14 }}/>
{/* Toolbar: фильтр + сортировка + категории. Верт. padding — чтобы бейдж счётчика (top:-3) не обрезался горизонтальным overflow. */}
openFilters('collection')} badge={advCount}> {sort === 'feed' ? : sort === 'price-desc' ? : }
{collectionName ? collectionName : 'Все подарки'}{pageItems.length ? ` · стр. ${page}` : ''}
{sort === 'feed' ? (collectionName ? 'дешёвые первыми' : 'самые дешёвые') : sort === 'price-desc' ? 'сначала дорогие' : 'сначала дешёвые'}
{(pageItems.length === 0 && !active.error && (active.loading || (active.hasMore && !q))) ? ( ) : active.error && pageItems.length === 0 ? (
Маркет прилёг отдохнуть
{window.friendlyError ? window.friendlyError(active.error, 'market') : 'К сожалению, маркет сейчас недоступен — попробуй через минуту'}
) : !pageItems.length ? (
Ничего не нашлось
{collectionName ? 'В этой коллекции нет подарков под фильтры.' : 'Попробуй убрать часть фильтров или поиск.'}
{(advCount > 0 || query) && ( )}
) : ( <>
{pageItems.map((l, i) => ( onGift(l)} onAddToCart={onAddToCart} onRemoveFromCart={onRemoveFromCart} inCart={cart?.some(x => x.id === l.id)}/> ))}
{page}
)}
); } window.MarketScreen = MarketScreen; window.GiftCard = GiftCard;