// PF Order Form — 3-step wizard (params → auth choice → payment picker)
// Used both for authenticated users (skip step 2) and guests.
const { useState: useOrderState, useEffect: useOrderEffect } = React;
function parseAvitoUrls(text) {
if (!text) return [];
// Переводы строк → пробелы. Так \S+ regex останавливается на границе строки,
// и две отдельные ссылки в столбик не склеятся в один матч.
// (Раньше нормализация СТИРАЛА \n между \S → два URL склеивались в один,
// split('?')[0] оставлял только первый.)
const normalized = text.replace(/[\r\n]+/g, ' ');
const raw = normalized.match(/https?:\/\/(?:(?:www|m)\.)?avito\.ru\/\S+/g) || [];
const seen = new Set();
return raw
.map(u => u.replace(/["')\].,;]+$/, '').split('?')[0])
.filter(u => { if (seen.has(u)) return false; seen.add(u); return true; });
}
function SliderField({ label, min, max, step, value, onChange, suffix = '', hint }) {
const [draft, setDraft] = React.useState(String(value));
React.useEffect(() => { setDraft(String(value)); }, [value]);
function commitDraft(raw) {
const n = Number(raw);
const committed = (!raw || isNaN(n)) ? min : Math.min(max, Math.max(min, n));
setDraft(String(committed));
onChange(committed);
}
return (
{label}
{value}{suffix}
onChange(Number(e.target.value))} />
setDraft(e.target.value)}
onBlur={e => commitDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commitDraft(e.target.value); e.target.blur(); } }}
/>
{min}{suffix} {max}{suffix}
{hint &&
{hint}
}
);
}
// Маленькая info-подсказка: иконка «i» + поповер по клику (работает и на тач-экранах).
function InfoHint({ text }) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);
React.useEffect(() => {
if (!open) return;
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
return (
{ e.stopPropagation(); setOpen(v => !v); }}
aria-label="Подробнее"
style={{
width: 18, height: 18, borderRadius: '50%',
border: `1.5px solid ${open ? 'var(--primary)' : 'var(--text-3)'}`,
background: 'transparent', color: open ? 'var(--primary)' : 'var(--text-3)',
cursor: 'pointer', fontSize: '0.72rem', fontWeight: 700, lineHeight: 1,
fontFamily: 'Georgia, "Times New Roman", serif', fontStyle: 'italic',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, padding: 0,
}}
>i
{open && (
e.stopPropagation()}
style={{
position: 'absolute', top: 'calc(100% + 8px)', left: 0, zIndex: 100,
width: 280, maxWidth: '75vw',
background: 'var(--surface)', color: 'var(--text-2)',
border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)',
boxShadow: 'var(--shadow-lg)', padding: '10px 12px',
fontSize: '0.75rem', lineHeight: 1.55, fontWeight: 400,
textAlign: 'left', whiteSpace: 'normal', cursor: 'default',
}}
>
{text}
)}
);
}
function OrderFormPage({ user, balance, prefilledFrom, onNavigate, onOrderPlaced }) {
// Step 1 fields
const [inputText, setInputText] = useOrderState('');
const [links, setLinks] = useOrderState(() => Array.isArray(prefilledFrom?.links) ? prefilledFrom.links : []);
// Preview meta for each URL — survives removeLink so re-paste is instant.
// Shape: { [url]: { status: 'loading'|'ok'|'not_found'|'fetch_failed', image_url?, title? } }
const [linkMeta, setLinkMeta] = useOrderState({});
// fixCount = views per day
const [fixCount, setFixCount] = useOrderState(() => Number(prefilledFrom?.fix_count) || 30);
// For prefilled flow days is deliberately blank per plan
const [days, setDays] = useOrderState(() => {
if (prefilledFrom) return prefilledFrom.days != null ? Number(prefilledFrom.days) : '';
return 7;
});
const [contacts, setContacts] = useOrderState(() => !!prefilledFrom?.contacts);
// Минимальная дата с учётом cutoff 04:00 МСК (UTC+3):
// заказы после 04:00 МСК попадают в следующую бизнес-сутку исполнителя.
const _minStartISO = () => {
const d = new Date();
const mskHour = (d.getUTCHours() + 3) % 24;
if (mskHour >= 4) d.setDate(d.getDate() + 1);
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
};
const [startDate, setStartDate] = useOrderState(() => prefilledFrom?.start_date || _minStartISO());
// Wizard state
const [step, setStep] = useOrderState(1);
const [loading, setLoading] = useOrderState(false);
// ref-guard от двойного сабмита: setLoading(true) применяется на следующем
// рендере, а быстрый dbl-click успевает выстрелить второй submitToBackend.
const submittingRef = React.useRef(false);
const [error, setError] = useOrderState('');
const [pricePerUnit, setPricePerUnit] = useOrderState(6);
// Step 2 state (guest auth choice)
const [showPhoneInput, setShowPhoneInput] = useOrderState(false);
const [guestPhone, setGuestPhone] = useOrderState('');
// Step 3 state
const [createdOrder, setCreatedOrder] = useOrderState(null); // { order_id, price, available_methods }
// For prefilled (repeat-order) flow: kick off preview fetch on mount so the
// restored cards don't stay on shimmer forever. Paste flow handles itself via
// handleInputChange; this is the only entry point that bypasses paste.
useOrderEffect(() => {
const prefilled = Array.isArray(prefilledFrom?.links) ? prefilledFrom.links : [];
if (!prefilled.length) return;
setLinkMeta(prev => {
const next = { ...prev };
for (const u of prefilled) {
if (!next[u]) next[u] = { status: 'loading' };
}
return next;
});
_fetchPreviewsAndMerge(prefilled);
}, []);
useOrderEffect(() => {
api.get('/api/orders/pf/price').then(data => {
if (!data.__unauthorized) setPricePerUnit(data.price_per_unit || 6);
}).catch(() => {});
}, []);
const urlCount = links.length;
const daysNum = parseInt(days, 10) || 0;
const totalPrice = urlCount > 0 ? fixCount * daysNum * urlCount * pricePerUnit : 0;
const _addDays = (iso, n) => {
const d = new Date(iso + 'T00:00:00');
d.setDate(d.getDate() + n);
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
};
const endDateDisplay = startDate && daysNum > 0 ? _addDays(startDate, daysNum - 1) : null;
const handleInputChange = e => {
const val = e.target.value;
const parsed = parseAvitoUrls(val);
const toAdd = parsed.filter(u => !links.includes(u));
if (toAdd.length) {
setLinks(prev => [...prev, ...toAdd]);
// Mark new URLs as loading (skip URLs we already have meta for — re-add case).
const newlyLoading = toAdd.filter(u => !linkMeta[u]);
if (newlyLoading.length) {
setLinkMeta(prev => {
const next = { ...prev };
for (const u of newlyLoading) next[u] = { status: 'loading' };
return next;
});
_fetchPreviewsAndMerge(newlyLoading);
}
}
setInputText(val);
};
const _fetchPreviewsAndMerge = async (urls) => {
try {
const data = await api.post('/api/orders/links/preview', { urls });
if (!data || !Array.isArray(data.previews)) return;
setLinkMeta(prev => {
const next = { ...prev };
for (const p of data.previews) {
next[p.url] = {
status: p.status,
image_url: p.image_url || null,
title: p.title || null,
};
}
return next;
});
} catch (err) {
// Network/server error — flip the still-loading entries to fetch_failed so
// the card renders the placeholder instead of a perpetual skeleton.
// Best-effort feature, not blocking — the form still submits.
console.warn('[link-preview] fetch failed', err);
setLinkMeta(prev => {
const next = { ...prev };
for (const u of urls) {
if (next[u] && next[u].status === 'loading') {
next[u] = { status: 'fetch_failed' };
}
}
return next;
});
}
};
const removeLink = url => setLinks(prev => prev.filter(u => u !== url));
const submitToBackend = async (phoneArg) => {
if (submittingRef.current) return;
submittingRef.current = true;
setLoading(true); setError('');
try {
const data = await api.post('/api/orders/pf', {
links,
days: parseInt(days, 10),
fix_count: fixCount,
contacts,
agreed_privacy: true, // приняты автоматически — текст внизу формы
agreed_offer: true,
phone: phoneArg || null,
start_date: startDate || null,
});
setCreatedOrder(data);
setStep(3);
} catch (e) {
setError(e.message || 'Не удалось создать заказ');
} finally {
setLoading(false);
submittingRef.current = false;
}
};
const handleNextFromStep1 = () => {
if (urlCount === 0) return setError('Добавьте хотя бы одну ссылку на объявление');
if (!daysNum || daysNum < 1) return setError('Укажите количество дней (от 1)');
setError('');
if (user) {
submitToBackend(null);
} else {
setStep(2);
}
};
const handleGoToAuth = () => {
try {
sessionStorage.setItem('order_prefill', JSON.stringify({
links,
days: daysNum || null,
fix_count: fixCount,
contacts,
}));
} catch (_) {}
onNavigate('auth');
};
const handleGuestSubmit = () => {
if (!guestPhone || guestPhone.replace(/\D/g, '').length < 10) {
return setError('Введите корректный номер телефона');
}
setError('');
submitToBackend(guestPhone);
};
const handlePay = async (method) => {
if (!createdOrder) return;
if (submittingRef.current) return;
submittingRef.current = true;
setLoading(true); setError('');
try {
const data = await api.post(`/api/orders/pf/${createdOrder.order_id}/pay`, { method });
if (method === 'balance') {
onOrderPlaced && onOrderPlaced(createdOrder.price);
onNavigate('order-detail', { order_id: createdOrder.order_id });
} else if (method === 'yookassa') {
if (data && data.confirmation_url) {
window.location.href = data.confirmation_url;
} else {
setError('Не удалось получить ссылку оплаты');
}
}
} catch (e) {
if (e.status === 400) setError(e.message || 'Недостаточно средств');
else setError(e.message || 'Ошибка оплаты');
} finally {
setLoading(false);
submittingRef.current = false;
}
};
// ----- Step 3: payment picker -----
if (step === 3 && createdOrder) {
const methods = createdOrder.available_methods || [];
return (
Заказ создан
Заказ #{createdOrder.order_id} · сумма к оплате{' '}
{createdOrder.price.toLocaleString('ru-RU')} ₽
{error &&
{error}
}
Выберите способ оплаты
{methods.includes('balance') && (
handlePay('balance')}
disabled={loading}
>
{loading ? 'Оплачиваем...' : 'Оплатить с баланса'}
)}
{methods.includes('yookassa') && (
handlePay('yookassa')}
disabled={loading}
>
{loading ? 'Перенаправляем...' : 'Оплатить картой (ЮKassa)'}
)}
{methods.length === 0 && (
Нет доступных способов оплаты
)}
onNavigate('order-detail', { order_id: createdOrder.order_id })}
>
Перейти к заказу
);
}
// ----- Step 2: guest auth choice -----
if (step === 2) {
return (
{ setError(''); setStep(1); }}>← Назад к параметрам
Как оформить заказ?
Сумма: {totalPrice.toLocaleString('ru-RU')} ₽
{error &&
{error}
}
{!showPhoneInput ? (
<>
{ setError(''); setShowPhoneInput(true); }}
disabled={loading}
>
Быстрый заказ по телефону
или
У меня есть аккаунт — войти
>
) : (
<>
{loading ? 'Создаём заказ...' : 'Создать заказ'}
{ setShowPhoneInput(false); setError(''); }}
disabled={loading}
>
← Назад к выбору
>
)}
);
}
// ----- Step 1: parameters -----
const noUrlsWarning = inputText.length > 5 && parseAvitoUrls(inputText).length === 0 && urlCount === 0;
return (
Авито ПФ
Поведенческие факторы · {pricePerUnit} ₽ за просмотр
{error &&
{error}
}
{/* LEFT */}
Рекомендация
Начните с 15–30 просм./день без контактов в течение недели.
После оживления органики постепенно добавляйте 5–8 контактов.
Резкий рост контактов может временно снизить позиции.
Ссылки на объявления
{urlCount > 0 && (
✓ {urlCount} {urlCount === 1 ? 'объявление' : urlCount < 5 ? 'объявления' : 'объявлений'}
)}
{noUrlsWarning && (
⚠ Авито-ссылки не найдены
)}
{links.length > 0 && (
Добавленные объявления
{links.map(url => (
))}
)}
{urlCount === 0 && (
Каждое уникальное объявление — отдельная строка в счёте
)}
{/* RIGHT */}
Дата старта
с {_minStartISO().split('-').reverse().join('.')}
setStartDate(e.target.value)}
/>
Когда начать показы — исполнитель стартует в этот день
{endDateDisplay && (
Дата завершения
{endDateDisplay}
)}
setContacts(v => !v)} style={{ userSelect: 'none', cursor: 'pointer' }}>
{/* Price preview */}
Стоимость заказа
{totalPrice.toLocaleString('ru-RU')} ₽
{[
{ label: 'Просмотров в день', val: fixCount },
{ label: 'Количество дней', val: daysNum || '—' },
{ label: 'Объявлений', val: urlCount },
{ label: 'Цена за просмотр', val: `${pricePerUnit} ₽` },
].map((row, i, arr) => (
{row.label}
× {row.val}
{i < arr.length - 1 &&
}
))}
{loading ? 'Создаём заказ...' : (user ? 'Создать заказ' : 'Далее')}
{/* Согласия — мелким текстом под кнопкой */}
{/* Mobile sticky footer */}
Итого:
{totalPrice.toLocaleString('ru-RU')} ₽
{loading ? 'Создаём...' : (user ? 'Создать заказ' : 'Далее')}
);
}
Object.assign(window, { OrderFormPage, SliderField });