// Main App — frames, theme, tabs, sheets, simulated state. const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "platform": "ios", "dark": true, "accent": "ton", "live": true }/*EDITMODE-END*/; // Model/Backdrop/Symbol are per-collection attributes → only valid when exactly // one collection is selected. Otherwise strip them so a stale attr-filter never // applies across multiple/zero collections. function normalizeAdvFilters(f) { return (f.collection && f.collection.length === 1) ? f : { ...f, model: [], backdrop: [], symbol: [] }; } // Floating cart button — pops up when the first item is added, drops away // when the cart empties, and the count badge springs + rolls on every change. function CartFab({ count, onClick }) { const [render, setRender] = React.useState(count > 0); const [cls, setCls] = React.useState(''); React.useEffect(() => { if (count > 0) { setRender(true); setCls('is-in'); const t = setTimeout(() => setCls(''), 470); // drop anim so :active press works return () => clearTimeout(t); } if (render) { setCls('is-out'); const t = setTimeout(() => { setRender(false); setCls(''); }, 300); return () => clearTimeout(t); } }, [count > 0]); if (!render) return null; return ( ); } // AppView renders the device chrome + the active screen. The brief tab-switch // loading state (skeleton) is driven from App via `shownTab` / `tabLoading`. function AppView({ tab, setTab, shownTab, tabLoading, platform, bare, balance, onGiftClick, onDeposit, onCartOpen, onAddToCart, onRemoveFromCart, onStartSend, cart, live, cartCount, advFilters, openFilters, hiddenIds, myGifts, onGiftsChanged }) { const t = useT(); const titles = { market: { t: t('title_market'), s: t('sub_market') }, wallet: { t: t('title_wallet'), s: t('sub_wallet') }, mine: { t: t('title_mine'), s: '' }, profile: { t: t('title_profile'), s: '' }, }; const tt = titles[tab]; // Cart is a prominent floating button at the top-right of the gift area. // owns its own enter/exit + count-change animation (see below). const cartFab = (tab === 'market' && !tabLoading) ? : null; return (
{/* Симулятор 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 (
); } return (
{platform === 'ios' && (
)} {platform === 'android' && (
)} {platform === 'both' && ( <>
)} setTweak('platform', v)}/> setTweak('dark', v)}/> { const map = { '#0098EA': 'ton', '#a855f7': 'purple', '#10b981': 'mint' }; setTweak('accent', map[v] || 'ton'); }}/> setTweak('live', v)}/> ({ value: x.id, label: x.label }))} onChange={setTab}/> setViewing(MARKET_LISTINGS[0])}/> setDepositOpen(true)}/>
); } // Modal layer absolutely positions inside the device wrapper. function ModalLayer({ device, bare = false, 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 }) { const hasModal = viewing || buying || depositOpen || cartOpen || filtersOpen || sendStage; if (!hasModal) return null; const width = device === 'ios' ? 380 : 390; const height = 780; const radius = device === 'ios' ? 48 : 18; // bare (реальный Telegram): sheets на весь вьюпорт (фиксировано, по центру на десктопе). // Симулятор: абсолютно внутри фейкового телефона. const wrapStyle = bare ? { position: 'fixed', inset: 0, maxWidth: 520, margin: '0 auto', overflow: 'hidden', zIndex: 80 } : { position: 'absolute', top: 0, left: 0, width, height, borderRadius: radius, overflow: 'hidden' }; return (
{viewing && ( i.id === viewing.id)} onClose={() => setViewing(null)} onBuy={handleStartBuy} onAddToCart={addToCart}/> )} {buying && ( setBuying(null)} onSuccess={() => { commitPurchase(buying); onGiftsChanged(); }} onComplete={handleBuyComplete}/> )} {depositOpen && ( setDepositOpen(false)} onConfirm={handleDepositConfirm}/> )} {cartOpen && ( setCartOpen(false)} onRemove={removeFromCart} onSuccess={(items) => { commitPurchase(items); onGiftsChanged(); }} onCheckout={handleCartCheckout} onBrowseMore={() => setTab('market')}/> )} {sendStage === 'select' && ( setSendStage(sendIds.length ? 'recipient' : null)}> setSendStage(sendIds.length ? 'recipient' : null)} onNext={(ids) => { setSendIds(ids); setSendStage('recipient'); }}/> )} {sendStage === 'recipient' && ( setSendStage(null)}> sendIds.includes(g.id))} onBack={(ids) => { if (ids) setSendIds(ids); setSendStage('select'); }} onAddMore={(ids) => { if (ids) setSendIds(ids); setSendStage('select'); }} onSend={(r) => handleSendComplete(r)}/> )} {sendStage === 'sent' && ( { setSendStage(null); setSentInfo(null); }} dismissable={false} hideGrip> )} {filtersOpen && ( setFiltersOpen(false)} onApply={(f) => setAdvFilters(prev => { // Смена ЕДИНСТВЕННОЙ коллекции A→B обязана сбрасывать model/backdrop/symbol — // атрибуты чужой коллекции дают гарантированно пустую выдачу. const next = normalizeAdvFilters(f); return (prev.collection[0] !== next.collection[0]) ? { ...next, model: [], backdrop: [], symbol: [] } : next; })}/> )}
); } ReactDOM.createRoot(document.getElementById('root')).render();