// 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 (
{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 ( {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') && ( )} {methods.includes('yookassa') && ( )} {methods.length === 0 && (
Нет доступных способов оплаты
)}
); } // ----- Step 2: guest auth choice ----- if (step === 2) { return (

Как оформить заказ?

Сумма: {totalPrice.toLocaleString('ru-RU')} ₽

{error &&
{error}
}
{!showPhoneInput ? ( <>
или
) : ( <>
setGuestPhone(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleGuestSubmit()} autoFocus />
На этот номер привяжем заказ — потом сможете войти по SMS
)}
); } // ----- 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 ? 'объявления' : 'объявлений'} )}