// Purchase flow — РЕАЛЬНАЯ покупка: confirm → creating (POST /purchase) → // awaiting (оплата + поллинг /me/orders/{id}) → success | error. // Дизайн-экраны прежние (confirm-карточка, processing с баром, success с конфетти). // Платёжные ссылки открываем только с доверенных хостов. const _PAY_ALLOWED_HOSTS = ['platega.io', 'pay.platega.io', 'app.platega.io', 'yookassa.ru', 'yoomoney.ru', 'robokassa.ru', 'auth.robokassa.ru', 't.me', '127.0.0.1', 'localhost']; function _openPayUrl(url) { const s = String(url || ''); if (s.includes('/mock-pay/') || s === 'about:blank') return 'mock'; // dev-провайдер let parsed = null; try { parsed = new URL(s); } catch (_) { return 'blocked'; } const ok = (parsed.protocol === 'http:' || parsed.protocol === 'https:') && _PAY_ALLOWED_HOSTS.some(h => parsed.hostname === h || parsed.hostname.endsWith('.' + h)); if (!ok) return 'blocked'; const tg = window.Telegram && window.Telegram.WebApp; if (tg && tg.openLink) tg.openLink(s, { try_instant_view: false }); else window.open(s, '_blank', 'noopener,noreferrer'); return 'opened'; } function PurchaseFlow({ gift, balance, onClose, onComplete, onSuccess }) { // confirm | creating | awaiting | success | error const [stage, setStage] = React.useState('confirm'); const [note, setNote] = React.useState(''); const [error, setError] = React.useState(null); const [quote, setQuote] = React.useState(null); // серверная цена перед оплатой const pollRef = React.useRef({ stop: false }); React.useEffect(() => () => { pollRef.current.stop = true; }, []); // Авторитетная цена с бэка (POST /buy/quote) на экране подтверждения: показываем // ровно то, что спишем, и передаём свежий ton в expected_price_ton — так покупка // не спотыкается о 409 «цена выросла» из-за устаревшего кеша ленты. React.useEffect(() => { if (stage !== 'confirm' || !gift || gift.num == null) return; const DS = window.DataSource; if (!DS || !DS.getQuote) return; let cancel = false; DS.getQuote(gift.name, gift.num).then(q => { if (!cancel) setQuote(q); }).catch(() => {}); return () => { cancel = true; }; }, [stage, gift]); const billRub = (quote && quote.price_rub) ? quote.price_rub : (gift ? gift.rub : 0); // Поллинг заказа: каждые 4с до 10 минут. const pollOrder = React.useCallback(async (orderId) => { const DS = window.DataSource; const deadline = Date.now() + 10 * 60 * 1000; while (!pollRef.current.stop && Date.now() < deadline) { await new Promise(r => setTimeout(r, 4000)); if (pollRef.current.stop) return; let o = null; try { o = await DS.getOrder(orderId); } catch (_) { continue; } // сеть моргнула — повтор if (o.status === 'success') { setStage('success'); return; } if (o.status === 'failed') { try { console.warn('[purchase] order failed:', o.error_msg); } catch (_) {} setError({ kind: 'order_failed' }); setStage('error'); return; } setNote(o.status === 'processing' ? 'Оплата получена, выдаём подарок…' : 'Ждём подтверждения оплаты…'); } if (!pollRef.current.stop) setNote('Оплата ещё обрабатывается. Подарок появится в «Моих подарках».'); }, []); const startPurchase = React.useCallback(async () => { const DS = window.DataSource; try { window.Telegram?.WebApp?.HapticFeedback?.impactOccurred('medium'); } catch (_) {} setError(null); if (!DS || !DS.purchase) { // Прод в Telegram без DataSource — НЕ имитируем успех (иначе «куплено» без покупки). if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData) { setError({ kind: 'server' }); setStage('error'); return; } setStage('awaiting'); // дизайн-симулятор в браузере setTimeout(() => setStage('success'), 1800); return; } setStage('creating'); try { // Оплата с внутреннего ₽-баланса: бэк списывает и сразу ставит заказ в // processing (invoice_url пустой) → сразу поллим статус, без платёжки. // Свежий ton из quote (если получен) → expected_price_ton совпадает с бэком. const inv = await DS.purchase(quote ? { ...gift, ton: quote.price_ton } : gift, true); setStage('awaiting'); setNote('Оплачено с баланса, выдаём подарок…'); pollOrder(inv.order_id); } catch (e) { setError(e); // 402 «недостаточно средств» покажется человеческим текстом setStage('error'); } }, [gift, quote, pollOrder]); // quote в deps: иначе замыкание держит stale null и цена не подхватывается // onSuccess строго один раз и ТОЛЬКО на реальном success. const successCalledRef = React.useRef(false); React.useEffect(() => { if (stage === 'success' && !successCalledRef.current && typeof onSuccess === 'function') { successCalledRef.current = true; onSuccess(); try { window.Telegram?.WebApp?.HapticFeedback?.notificationOccurred('success'); } catch (_) {} if (window.SFX) window.SFX.success(); } if (stage === 'error') { try { window.Telegram?.WebApp?.HapticFeedback?.notificationOccurred('error'); } catch (_) {} } }, [stage, onSuccess]); if (!gift) return null; const isRare = gift.tier === 'epic' || gift.tier === 'legendary'; const errText = !error ? '' : error.kind === 'order_failed' ? 'Оплата не прошла или заказ отменён — деньги вернулись на баланс.' : (window.friendlyError ? window.friendlyError(error, 'purchase') : 'К сожалению, маркет сейчас недоступен — попробуй через минуту'); return ( {stage === 'confirm' && (
{gift.num != null &&
#{gift.num}
}
{gift.name}
К оплате
{fmtRub(billRub)}
Модель
{(gift.attrs && gift.attrs.model && gift.attrs.model.name) || '—'}
Фон
{(gift.attrs && gift.attrs.backdrop && gift.attrs.backdrop.name) || '—'}
{close => ( )}
)} {(stage === 'creating' || stage === 'awaiting') && (
{stage === 'creating' ? 'Создаём счёт…' : 'Оплачиваем покупку…'}
{stage === 'creating' ? 'Связываемся с Portals API' : (note || 'Ждём подтверждения оплаты…')}
{stage === 'awaiting' && ( {close => ( )} )}
)} {stage === 'error' && (
Не получилось
{errText}
{close => ( )}
)} {stage === 'success' && (
Подарок твой! 🎉
{gift.name}{gift.num != null ? ` #${gift.num}` : ''} добавлен в твою коллекцию. Можешь забрать его на свой Telegram-профиль или оставить здесь.
)} ); } window.PurchaseFlow = PurchaseFlow;