// 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) => (
))}
Объявления {links.length > 0 && · {links.length}}
{links.length === 0 ? (
Ссылок нет
) : (
)}
);
}
// --- 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 });