// 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')} ₽
Поведенческие факторы · {pricePerUnit} ₽ за просмотр
{error &&
{error}
}
{/* LEFT */}
Рекомендация
Начните с 15–30 просм./день без контактов в течение недели.
После оживления органики постепенно добавляйте 5–8 контактов.
Резкий рост контактов может временно снизить позиции.