{/* Симулятор iOS: спейсер под фейковый статус-бар. Реальный Telegram (bare):
вместо него отступ на системную safe-area (чёлка/динамик), а не пустые 56px. */}
{bare
?
: (platform === 'ios' &&
)}
{}}/>
{/* Маркет ВСЕГДА смонтирован (скрыт на др. вкладках/при загрузке) — лента, страница
и фильтры НЕ сбрасываются при переходе между вкладками. */}
{tabLoading ? (
) : shownTab === 'wallet' ? (
) : shownTab === 'mine' ? (
) : shownTab === 'profile' ? (
) : null}
{cartFab}
);
}
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const [tab, setTabState] = React.useState('market');
// Tab-switch loading: `tab` highlights instantly in the nav, while the
// content shown lags behind a short skeleton "loading" beat. State lives
// here in App (not AppView) because AppView re-mounts on tab change, which
// would otherwise reset the loading state.
const [shownTab, setShownTab] = React.useState('market');
const [tabLoading, setTabLoading] = React.useState(false);
const shownRef = React.useRef('market');
const tabTimer = React.useRef(0);
const setTab = React.useCallback((next) => {
setTabState(next);
if (window.SFX) window.SFX.tap();
if (next === shownRef.current) {
setTabLoading(false);
clearTimeout(tabTimer.current);
return;
}
setTabLoading(true);
document.querySelectorAll('.screen-scroll').forEach(el => { el.scrollTop = 0; });
clearTimeout(tabTimer.current);
tabTimer.current = setTimeout(() => {
shownRef.current = next;
setShownTab(next);
setTabLoading(false);
}, 440);
}, []);
React.useEffect(() => () => clearTimeout(tabTimer.current), []);
// РЕАЛЬНЫЙ ₽-баланс с бэка (таблица balances). Рефетч по bump'у balanceReload.
const [balanceReload, setBalanceReload] = React.useState(0);
const bumpBalance = React.useCallback(() => setBalanceReload(x => x + 1), []);
const _bal = (window.DataSource && window.DataSource.useBalance)
? window.DataSource.useBalance(balanceReload)
: { balance_rub: 0 };
const balance = _bal.balance_rub || 0;
const [viewing, setViewing] = React.useState(null);
const [buying, setBuying] = React.useState(null);
const [depositOpen, setDepositOpen] = React.useState(false);
const [cart, setCart] = React.useState([]);
const [cartOpen, setCartOpen] = React.useState(false);
// Listings the user has bought — used to hide them from the market grid
// so a sold-out gift doesn't linger after checkout.
const [purchasedIds, setPurchasedIds] = React.useState(() => new Set());
const [filtersOpen, setFiltersOpen] = React.useState(false);
const [filterTab, setFilterTab] = React.useState('collection');
const [advFilters, setAdvFilters] = React.useState({ collection: [], model: [], backdrop: [], symbol: [] });
const [sendStage, setSendStage] = React.useState(null);
const [sendIds, setSendIds] = React.useState([]);
const [sentInfo, setSentInfo] = React.useState(null);
const [lang, setLang] = React.useState('ru');
// Реальные купленные (held) подарки юзера с бэка; refetch по bump'у giftsReload.
const [giftsReload, setGiftsReload] = React.useState(0);
// Scroll-lock фона авторитетно из состояния App: пока открыта ЛЮБАЯ модалка —
// body.sheet-open (CSS душит `.screen-scroll{overflow:hidden}`), иначе снят.
// Единый владелец класса (SheetShell больше его не трогает) — нет гонки, из-за
// которой скролл ленты залипал после закрытия карточки/оплаты.
const anySheet = !!(viewing || buying || depositOpen || cartOpen || filtersOpen || sendStage);
React.useEffect(() => {
document.body.classList.toggle('sheet-open', anySheet);
return () => document.body.classList.remove('sheet-open');
}, [anySheet]);
// ── Telegram WebApp: bare-режим (на весь вьюпорт, без фейкового телефона) когда
// запущены ВНУТРИ Telegram. В браузере — прежний дизайн-симулятор с рамкой.
// ?bare=1 форсит bare, ?frame=1 форсит симулятор (для отладки).
const bare = React.useMemo(() => {
try {
const p = new URLSearchParams(window.location.search);
if (p.get('frame') === '1') return false;
if (p.get('bare') === '1') return true;
const tg = window.Telegram && window.Telegram.WebApp;
return !!(tg && tg.initData);
} catch (_) { return false; }
}, []);
React.useEffect(() => {
const tg = window.Telegram && window.Telegram.WebApp;
if (!tg) return;
try {
tg.ready();
if (tg.expand) tg.expand();
// Свайп-вниз Telegram не закрывает мини-апп и не конфликтует со скроллом ленты.
if (tg.disableVerticalSwipes) tg.disableVerticalSwipes();
if (tg.setHeaderColor) tg.setHeaderColor('#140f22');
if (tg.setBackgroundColor) tg.setBackgroundColor('#140f22');
} catch (_) {}
}, []);
const _mg = (window.DataSource && window.DataSource.useMyGifts)
? window.DataSource.useMyGifts(giftsReload)
: { data: null };
const myGifts = (_mg.data && _mg.data.items) || [];
const onGiftsChanged = React.useCallback(() => setGiftsReload(x => x + 1), []);
React.useEffect(() => {
document.body.setAttribute('data-theme', t.dark ? 'dark' : 'light');
document.body.setAttribute('data-accent', t.accent);
}, [t.dark, t.accent]);
// Warm the real collection floors into cache on launch, so the collection
// filter opens with real prices instantly (no mock-price flash, no spinner).
React.useEffect(() => { if (window.loadCollectionFloors) window.loadCollectionFloors(); }, []);
// When exactly one collection is selected, warm its model/symbol/backdrop
// attribute lists + icon images so those filters open instantly and crisp.
const singleCollection = advFilters.collection.length === 1 ? advFilters.collection[0] : null;
React.useEffect(() => {
if (singleCollection && window.prefetchGiftAttrs) window.prefetchGiftAttrs(singleCollection);
}, [singleCollection]);
// Runtime i18n: walks the DOM after each render and swaps RU phrases for
// EN equivalents while `lang === 'en'`. Restores originals on switch back.
React.useEffect(() => {
if (typeof window.__i18nSetLang === 'function') window.__i18nSetLang(lang);
}, [lang, tab, viewing, buying, depositOpen, cartOpen, sendStage]);
const handleStartBuy = (gift) => {
setViewing(null);
setTimeout(() => setBuying(gift), 200);
};
// Commit a purchase: deduct balance, hide bought listings from the market,
// and (for cart checkout) empty the cart. Called from the *flow* the
// instant the success stage is entered, so dragging the sheet shut after
// confetti still leaves consistent state.
// Баланс уже списан на бэке (pay_with_balance) — тут только прячем купленные
// лоты с маркета, чистим корзину и рефетчим реальный баланс + «Мои».
const commitPurchase = React.useCallback((items) => {
const list = Array.isArray(items) ? items : [items];
if (list.length === 0) return;
setPurchasedIds(prev => {
const next = new Set(prev);
list.forEach(i => next.add(i.id));
return next;
});
setCart(prev => prev.filter(i => !list.some(b => b.id === i.id)));
bumpBalance();
onGiftsChanged();
}, [bumpBalance, onGiftsChanged]);
// Single-gift purchase: PurchaseFlow now reports success via onSuccess as
// soon as confetti starts. onComplete only handles the closing nav action.
const handleBuyComplete = (_gift, action) => {
setBuying(null);
if (action === 'goto') setTab('mine');
};
// Реальное пополнение: /me/deposit → invoice; mock-провайдер подтверждаем
// webhook'ом (как покупку). Возвращаем промис — DepositSheet ждёт и закрывается.
const handleDepositConfirm = async (amount, method) => {
const DS = window.DataSource;
try {
if (DS && DS.deposit) {
// method = выбранная платёжка ('cryptobot'/'platega'). Бэк: настроена →
// реальный invoice; не настроена в dev → mock-fallback.
const inv = await DS.deposit(amount, method); // {payment_id, order_id, amount_rub, invoice_url, provider}
const isMock = inv.provider === 'mock'
|| !inv.invoice_url || inv.invoice_url.includes('/mock-pay/') || inv.invoice_url === 'about:blank';
if (isMock && DS.mockPay) {
await DS.mockPay(inv); // dev: зачисляем сразу через локальный webhook
} else if (inv.invoice_url) {
// Реальная платёжка: открываем оплату; баланс зачислит webhook после оплаты.
const tg = window.Telegram && window.Telegram.WebApp;
const url = inv.invoice_url;
try {
if (url.startsWith('https://t.me/') && tg && tg.openTelegramLink) tg.openTelegramLink(url);
else if (tg && tg.openLink) tg.openLink(url);
else window.open(url, '_blank', 'noopener,noreferrer');
} catch (_) { try { window.open(url, '_blank'); } catch (__) {} }
}
bumpBalance(); // рефетч (mock — сразу; реальная — после оплаты)
if (window.SFX) window.SFX.success();
}
} catch (e) {
console.warn('[deposit]', e); // тихо: баланс просто не изменится
} finally {
setDepositOpen(false);
}
};
const addToCart = (listing) => {
setCart(prev => prev.find(i => i.id === listing.id) ? prev : [...prev, listing]);
setViewing(null);
if (window.SFX) window.SFX.click();
};
const removeFromCart = (id) => { setCart(prev => prev.filter(i => i.id !== id)); if (window.SFX) window.SFX.pop(); };
// Cart checkout: balance/cart already committed when the sheet entered
// 'success' — this only handles where to send the user next.
const handleCartCheckout = (action) => {
setCartOpen(false);
if (action === 'goto') setTab('mine');
};
const handleStartSend = (giftIds) => {
const ids = Array.isArray(giftIds) ? giftIds : [giftIds];
setSendIds(ids);
// If invoked with a specific gift (the common "Send" button on a single
// gift card), jump straight to the recipient step — the user already
// chose what to send. The "+" button there lets them add more.
// Without a preselection, start with the multi-select picker.
setSendStage(ids.length > 0 ? 'recipient' : 'select');
};
const handleSendComplete = (info) => {
// Снапшот отправленных ДО рефетча: после onGiftsChanged они исчезнут из myGifts,
// а success-шиту ещё нужно их показать.
const sent = myGifts.filter(g => sendIds.includes(g.id));
setSentInfo({ ...info, gifts: sent });
setSendStage('sent');
onGiftsChanged(); // отправленные ушли из held — рефетч «Моих»
if (window.SFX) window.SFX.success();
};
const tgPlatform = (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.platform) || '';
const platform = bare ? (tgPlatform === 'android' ? 'android' : 'ios') : t.platform;
const shared = {
tab, setTab, shownTab, tabLoading, platform, bare, balance, live: t.live,
onGiftClick: setViewing,
onDeposit: () => setDepositOpen(true),
onCartOpen: () => setCartOpen(true),
onAddToCart: addToCart,
onRemoveFromCart: removeFromCart,
onStartSend: handleStartSend,
cart,
cartCount: cart.length,
advFilters,
hiddenIds: purchasedIds,
myGifts,
onGiftsChanged,
openFilters: (which) => {
if (which === '__reset') {
setAdvFilters({ collection: [], model: [], backdrop: [], symbol: [] });
return;
}
setFilterTab(which);
setFiltersOpen(true);
},
};
const modalProps = {
viewing, buying, depositOpen, cartOpen, filtersOpen, cart, balance,
filterTab, advFilters, setAdvFilters,
sendStage, sendIds, setSendStage, setSendIds, handleSendComplete, sentInfo, setSentInfo,
setViewing, setBuying, setDepositOpen, setCartOpen, setFiltersOpen,
handleStartBuy, handleBuyComplete, handleDepositConfirm,
addToCart, removeFromCart, handleCartCheckout, setTab,
commitPurchase, myGifts, onGiftsChanged,
};
// Реальный Telegram: рендерим на весь вьюпорт без симулятора и tweaks-панели.
if (bare) {
return (