// 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 && (
);
}
// Скелетон-плитка на время загрузки.
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 (