// 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\.)?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 }) { return (
{value}{suffix}
onChange(Number(e.target.value))} /> { let v = Number(e.target.value); if (v < min) v = min; if (v > max) v = max; onChange(v); }} />
{min}{suffix}{max}{suffix}
{hint &&
{hint}
}
); } function OrderFormPage({ user, balance, prefilledFrom, onNavigate, onOrderPlaced }) { // Step 1 fields const [inputText, setInputText] = useOrderState(''); const [links, setLinks] = useOrderState(() => Array.isArray(prefilledFrom?.links) ? prefilledFrom.links : []); // 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); // start_date: ISO "YYYY-MM-DD". По умолчанию = сегодня (Москва). const _todayISO = () => { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); }; const [startDate, setStartDate] = useOrderState(() => prefilledFrom?.start_date || _todayISO()); // 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 } 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 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]); setInputText(val); }; 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 ? 'объявления' : 'объявлений'} )}