// Order detail page — universal for all statuses (unpaid / paid / done / failed / // payment_failed / cancelled). Polls /payment-status every 5s while 'unpaid'. // Terminal statuses show "Повторить заказ" with prefill. const { useState: useODState, useEffect: useODEffect, useRef: useODRef } = React; function odParseLinks(s) { if (!s) return []; return String(s).split(',') .map(l => l.trim().replace(/^['"\[\] ]+|['"\[\] ]+$/g, '')) .filter(l => l.startsWith('http')); } function detectServiceType(order) { const pn = String(order.position_name || ''); if (/^\d+\/\d+$/.test(pn)) return 'avito-pf'; if (pn === 'Авито ПФ') return 'avito-pf'; return 'generic'; } function serviceDisplayName(serviceType, order) { if (serviceType === 'avito-pf') return 'Авито ПФ'; return order.position_name || '—'; } const TERMINAL_STATUSES = ['done', 'failed', 'payment_failed', 'cancelled']; // --- Avito PF specific details --- function AvitoPFDetail({ order }) { const links = odParseLinks(order.links); const m = String(order.position_name || '').match(/^(\d+)\/(\d+)$/); const days = m ? Number(m[1]) : null; const viewsPerDay = m ? Number(m[2]) : null; const totalViews = (viewsPerDay != null && days != null && links.length > 0) ? viewsPerDay * days * links.length : (viewsPerDay != null && days != null ? viewsPerDay * days : null); const params = [ days != null && { label: 'Дней накрутки', value: `${days}` }, viewsPerDay != null && { label: 'Просмотров в день', value: `${viewsPerDay}` }, { label: 'Запросы контактов', value: order.contacts ? 'Да' : 'Нет' }, links.length > 0 && { label: 'Объявлений в заказе', value: `${links.length}` }, totalViews != null && { label: 'Всего просмотров', value: totalViews.toLocaleString('ru-RU') }, { label: 'Цена за просмотр', value: (viewsPerDay && days && links.length) ? `${Math.round(order.price / totalViews)} ₽` : '—' }, ].filter(Boolean); return (

Параметры накрутки

{params.map((p, i) => (
{p.label}
{p.value}
))}

Объявления {links.length > 0 && · {links.length}}

{links.length === 0 ? (
Ссылок нет
) : (
{links.map((l, i) => ( {l.replace('https://www.avito.ru', 'avito.ru')} ))}
)}
); } // --- Fallback for unknown services --- function GenericDetail({ order }) { const links = odParseLinks(order.links); return (

Параметры

Тариф: {order.position_name || '—'}
Контакты: {order.contacts ? 'Да' : 'Нет'}
Статус: {order.status}
{links.length > 0 && (

Ссылки · {links.length}

{links.map((l, i) => ( {l} ))}
)}
); } // Registry — add new service renderers here. const SERVICE_DETAIL_RENDERERS = { 'avito-pf': AvitoPFDetail, 'generic': GenericDetail, }; function formatMmSs(seconds) { if (seconds == null || seconds < 0) return '0:00'; const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}:${String(s).padStart(2, '0')}`; } function OrderDetailPage({ order: payload, orderId: orderIdProp, user, balance, onNavigate }) { // Accept either { order_id, ... } payload from old callsites OR orderId prop. const orderId = orderIdProp != null ? orderIdProp : (payload && (payload.order_id != null ? payload.order_id : payload.increment)); const [order, setOrder] = useODState(() => (payload && payload.status) ? payload : null); const [timeRemaining, setTimeRemaining] = useODState(null); const [loadError, setLoadError] = useODState(null); const [payLoading, setPayLoading] = useODState(false); const [payError, setPayError] = useODState(''); const payRef = useODRef(false); const pollTimerRef = useODRef(null); const mountedRef = useODRef(true); // Fetch full order detail once. useODEffect(() => { if (!orderId) return; let cancelled = false; api.get(`/api/orders/pf/${orderId}`).then(data => { if (cancelled || !mountedRef.current) return; if (data && !data.__unauthorized && data.order_id) { setOrder(data); } else if (data && data.__unauthorized) { // Public endpoint should never 401, but guard anyway. } else { setLoadError('Не удалось загрузить заказ'); } }).catch(e => { if (!cancelled && mountedRef.current) setLoadError(e.message || 'Не удалось загрузить заказ'); }); return () => { cancelled = true; }; }, [orderId]); // Poll payment-status while unpaid. Stop on terminal status. useODEffect(() => { mountedRef.current = true; if (!orderId) return () => { mountedRef.current = false; }; const stop = () => { if (pollTimerRef.current) { clearTimeout(pollTimerRef.current); pollTimerRef.current = null; } }; const tick = async () => { try { const data = await api.get(`/api/orders/pf/${orderId}/payment-status`); if (!mountedRef.current) return; if (data && !data.__unauthorized) { if (data.time_remaining_seconds != null) { setTimeRemaining(data.time_remaining_seconds); } if (data.status && data.status !== (order && order.status)) { // Status changed → refetch full order. try { const fresh = await api.get(`/api/orders/pf/${orderId}`); if (mountedRef.current && fresh && !fresh.__unauthorized && fresh.order_id) { setOrder(fresh); } } catch (_) {} } if (data.status && data.status !== 'unpaid') { stop(); return; } } } catch (_) { // Network blip — keep polling. } if (mountedRef.current) { pollTimerRef.current = setTimeout(tick, 5000); } }; // Initial poll immediately so we get time_remaining_seconds right away. tick(); return () => { mountedRef.current = false; stop(); }; }, [orderId]); if (!orderId) { return (

Заказ не выбран.

); } if (!order) { return (
{loadError ? loadError : 'Загрузка заказа...'}
); } const serviceType = detectServiceType(order); const DetailComponent = SERVICE_DETAIL_RENDERERS[serviceType] || GenericDetail; const isUnpaid = order.status === 'unpaid'; const isTerminal = TERMINAL_STATUSES.includes(order.status); // "Повторить заказ" доступно для любого ушедшего из unpaid статуса — // в работе (paid), выполнен (done), неудача, отменён и т.д. const canRepeat = !isUnpaid; const handleContactSupport = () => { const text = `У меня возникли проблемы с заказом #${order.order_id}`; window.dispatchEvent(new CustomEvent('support-chat-send', { detail: { text } })); }; const handleRepeat = () => { try { const linksArr = odParseLinks(order.links); const m = String(order.position_name || '').match(/^(\d+)\/(\d+)$/); const daysVal = m ? Number(m[1]) : null; const fixVal = m ? Number(m[2]) : 30; sessionStorage.setItem('order_prefill', JSON.stringify({ links: linksArr, days: daysVal, fix_count: fixVal, contacts: !!order.contacts, })); } catch (_) {} onNavigate('order-new'); }; const handleBack = () => { if (user) onNavigate('orders'); else onNavigate('order-new'); }; // Pay actions for unpaid orders. available_methods не возвращается GET /pf/{id} — // вычисляем здесь: yookassa всегда, balance только если залогинен и хватает денег. const balanceAvailable = !!user && Number(balance || 0) >= Number(order.price || 0); const handlePay = async (method) => { if (payRef.current) return; payRef.current = true; setPayLoading(true); setPayError(''); try { const data = await api.post(`/api/orders/pf/${orderId}/pay`, { method }); if (method === 'yookassa') { if (data && data.confirmation_url) { window.location.href = data.confirmation_url; } else { setPayError('Не удалось получить ссылку оплаты'); } } else { // balance — backend перевёл в paid, перечитаем заказ. const fresh = await api.get(`/api/orders/pf/${orderId}`); if (fresh && !fresh.__unauthorized && fresh.order_id) setOrder(fresh); } } catch (e) { if (e.status === 400) setPayError(e.message || 'Недостаточно средств'); else if (e.status === 409) setPayError(e.message || 'Срок оплаты истёк или статус изменён'); else setPayError(e.message || 'Ошибка оплаты'); } finally { setPayLoading(false); payRef.current = false; } }; return (
{/* Summary */}
Заказ #{order.order_id}

{serviceDisplayName(serviceType, order)}

{order.date ? `Создан: ${typeof formatDisplay === 'function' ? formatDisplay(order.date) : order.date}` : ''}
{Number(order.price || 0).toLocaleString('ru-RU')} ₽
{isUnpaid && timeRemaining != null && timeRemaining > 0 && (
⏳ Осталось на оплату: {formatMmSs(timeRemaining)}
)} {isUnpaid && (
{payError &&
{payError}
} {balanceAvailable && ( )}
)} {canRepeat && ( )}
); } Object.assign(window, { OrderDetailPage });