// ROBOCASH chat — state machine + Hermes backend integration
// Loaded after messages.jsx
const RC2 = window.ROBOCASH;
const T = window.RCT;
const HERMES = window.HERMES || {};
// ─── State machine ─────────────────────────────────────────
// steps: welcome -> amount -> term -> purpose -> credit -> phone -> processing -> results -> free
const STEPS = ['welcome', 'amount', 'term', 'purpose', 'credit', 'phone', 'processing', 'results', 'free'];
function Chat() {
const [messages, setMessages] = useState([]);
const [step, setStep] = useState('welcome');
const [typing, setTyping] = useState(false);
const [profile, setProfile] = useState({});
const [inputValue, setInputValue] = useState('');
const [inputDisabled, setInputDisabled] = useState(true);
const [aiBusy, setAiBusy] = useState(false);
const [sessionId, setSessionId] = useState(null);
const [decision, setDecision] = useState({});
const [offerFocusId, setOfferFocusId] = useState(null);
const scrollRef = useRef(null);
const offerFocusRef = useRef(null);
const soundRef = useRef({ ctx: null, unlocked: false });
// Always scroll to bottom on new messages or typing change
useEffect(() => {
const el = scrollRef.current; if (!el) return;
requestAnimationFrame(() => {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
});
}, [messages, typing]);
useEffect(() => {
if (!offerFocusId || !scrollRef.current || !offerFocusRef.current) return;
requestAnimationFrame(() => {
scrollRef.current.scrollTo({
top: Math.max(offerFocusRef.current.offsetTop - 88, 0),
behavior: 'smooth',
});
setOfferFocusId(null);
});
}, [messages, offerFocusId]);
useEffect(() => {
const unlockSound = () => {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (!AudioContext) return;
const state = soundRef.current;
if (!state.ctx) state.ctx = new AudioContext();
state.unlocked = true;
if (state.ctx.resume) Promise.resolve(state.ctx.resume()).catch(() => {});
};
window.addEventListener('pointerdown', unlockSound, { passive: true });
window.addEventListener('keydown', unlockSound);
return () => {
window.removeEventListener('pointerdown', unlockSound);
window.removeEventListener('keydown', unlockSound);
};
}, []);
function playSolomiyaSound() {
const AudioContext = window.AudioContext || window.webkitAudioContext;
const state = soundRef.current;
if (!AudioContext || !state.unlocked) return;
const ctx = state.ctx || new AudioContext();
state.ctx = ctx;
if (ctx.state === 'suspended' && ctx.resume) Promise.resolve(ctx.resume()).catch(() => {});
const now = ctx.currentTime + 0.015;
const master = ctx.createGain();
master.gain.setValueAtTime(0.0001, now);
master.gain.exponentialRampToValueAtTime(0.045, now + 0.018);
master.gain.exponentialRampToValueAtTime(0.0001, now + 0.48);
master.connect(ctx.destination);
[
{ freq: 659.25, start: 0, duration: 0.18 },
{ freq: 880, start: 0.11, duration: 0.18 },
{ freq: 1174.66, start: 0.23, duration: 0.22 },
].forEach(tone => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(tone.freq, now + tone.start);
gain.gain.setValueAtTime(0.0001, now + tone.start);
gain.gain.exponentialRampToValueAtTime(0.75, now + tone.start + 0.018);
gain.gain.exponentialRampToValueAtTime(0.0001, now + tone.start + tone.duration);
osc.connect(gain);
gain.connect(master);
osc.start(now + tone.start);
osc.stop(now + tone.start + tone.duration + 0.03);
});
}
// Welcome bootstrap
useEffect(() => {
let alive = true;
(async () => {
const boot = await bootstrapBackend();
if (!alive) return;
const name = boot && boot.decision && boot.decision.client_name ? boot.decision.client_name : '';
const first = name
? `${name}, вітаю! Я Соломія, AI-консультант ROBOCASH.`
: 'Привіт! Я Соломія, AI-консультант ROBOCASH.';
pushAgent([{ kind: 'text', content: first }], { delay: 200 });
pushAgent([
{ kind: 'text', content: 'За 1 хвилину я підберу кредитні компанії з найвищим шансом схвалення саме під ваш профіль. Безкоштовно і без зайвих дзвінків.' },
], { delay: 1300 });
pushAgent([
{ kind: 'partners' },
{ kind: 'qr', options: [{ value: 'start', label: 'Почати підбір', emoji: '✨', primary: true }] },
], { delay: 2400, afterStep: 'amount' });
})();
return () => { alive = false; };
}, []);
async function bootstrapBackend() {
try {
const res = HERMES.decisionId
? await fetch('/api/chat/bootstrap?decision_id=' + encodeURIComponent(HERMES.decisionId))
: await fetch('/api/chat/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (!res.ok) throw new Error('bootstrap_http_' + res.status);
const data = await res.json();
setSessionId(data.session_id || null);
setDecision(data.decision || {});
return data;
} catch (e) {
pushAgent([
{ kind: 'text', content: 'Підключення до персонального профілю затримується. Можу все одно зібрати параметри і показати базовий підбір.' },
], { delay: 350 });
return null;
}
}
// ─── Message dispatcher ──────────────────────────────────
function pushAgent(items, { delay = 600, afterStep } = {}) {
setTimeout(() => {
setTyping(true);
setTimeout(() => {
setTyping(false);
setMessages(m => [...m, ...items.map(i => ({ ...i, side: 'agent', id: i.id || Math.random() }))]);
if (items.some(i => i.kind !== 'processing')) playSolomiyaSound();
if (afterStep) setStep(afterStep);
}, Math.min(900, 350 + items.length * 250));
}, delay);
}
function pushUser(content) {
setMessages(m => [...m, { kind: 'text', side: 'user', content, id: Math.random() }]);
}
// ─── Step transitions ────────────────────────────────────
function handleQuickReply(opt) {
if (step === 'amount' && opt.value === 'start') {
pushUser('Почати підбір');
askAmount();
} else if (step === 'purpose') {
pushUser(opt.label);
setProfile(p => ({ ...p, purpose: opt }));
askCredit();
} else if (step === 'credit') {
pushUser(opt.label);
setProfile(p => ({ ...p, credit: opt }));
askPhone();
} else if (step === 'results' || step === 'free') {
pushUser(opt.label);
askAI(opt.label);
}
}
function askAmount() {
pushAgent([
{ kind: 'text', content: 'Чудово! Спочатку основне — яку суму ви хочете отримати?' },
{ kind: 'slider', config: {
label: 'Сума кредиту',
min: 500, max: 100000, step: 500, defaultValue: 8000,
format: RC2.formatUAH,
presets: [3000, 8000, 15000, 30000],
onConfirm: (v) => {
pushUser(RC2.formatUAH(v));
setProfile(p => ({ ...p, amount: v }));
askTerm();
},
}},
], { delay: 500 });
}
function askTerm() {
pushAgent([
{ kind: 'text', content: 'На який термін потрібні гроші?' },
{ kind: 'slider', config: {
label: 'Термін повернення',
min: 7, max: 365, step: 1, defaultValue: 30,
format: RC2.formatDays,
presets: [14, 30, 60, 90],
onConfirm: (v) => {
pushUser(RC2.formatDays(v));
setProfile(p => ({ ...p, term: v }));
askPurpose();
},
}},
], { delay: 400 });
}
function askPurpose() {
pushAgent([
{ kind: 'text', content: 'Чудово. А на що знадобились гроші? Це впливає на підбір спецпропозицій.' },
{ kind: 'qr', options: RC2.PURPOSE_OPTIONS },
], { delay: 400, afterStep: 'purpose' });
}
function askCredit() {
pushAgent([
{ kind: 'text', content: 'Останнє важливе питання — як з кредитною історією?' },
{ kind: 'text', content: '⚠ Це не вплине на вашу КІ — ми робимо м\u02BCяку перевірку.' },
{ kind: 'qr', options: RC2.CREDIT_HISTORY_OPTIONS },
], { delay: 400, afterStep: 'credit' });
}
function askPhone() {
pushAgent([
{ kind: 'text', content: 'Готово! Залишилось номер телефону — на нього МФО надішлють SMS з підтвердженням заявки.' },
{ kind: 'phone', onConfirm: (formatted) => {
pushUser(formatted);
setProfile(p => ({ ...p, phone: formatted }));
startProcessing();
}},
], { delay: 400, afterStep: 'phone' });
}
function startProcessing() {
pushAgent([
{ kind: 'processing', onDone: () => showResults() },
], { delay: 400, afterStep: 'processing' });
}
async function showResults() {
const p = profileRef.current;
const hermes = await getHermesOffers(p);
let sorted = hermes.offers || [];
if (!sorted.length) {
sorted = fallbackOffers(p);
}
const offersId = 'offers-' + Date.now();
setOfferFocusId(offersId);
pushAgent([
{ kind: 'text', content: hermes.reply || 'Готово! Ось усі доступні пропозиції, відсортовані під ваш профіль.' },
{ kind: 'summary' },
{ kind: 'mfos', id: offersId, mfos: sorted, requestedAmount: p.amount, focusStart: true },
{ kind: 'text', content: 'Подати заявку можна одразу до кількох — це збільшує шанс одобрення. Якщо є питання — питайте, я поряд 💬' },
], { delay: 500, afterStep: 'free' });
setInputDisabled(false);
}
function fallbackOffers(p) {
let sorted = [...RC2.MFOS];
if (p.credit && (p.credit.value === 'bad' || p.credit.value === 'small')) {
sorted.sort((a, b) => (b.firstFree ? 1 : 0) - (a.firstFree ? 1 : 0));
} else {
sorted.sort((a, b) => b.approval - a.approval);
}
const reqA = p.amount || 0;
return sorted.map(m => ({
...m,
_fit: reqA <= m.maxAmount ? 1 : 0,
})).sort((a, b) => b._fit - a._fit);
}
function normalizeHermesOffer(offer, idx) {
const defaults = RC2.MFOS[idx % RC2.MFOS.length];
return {
...defaults,
...offer,
id: offer.id || offer.name || defaults.id,
name: offer.name || defaults.name,
monogram: offer.monogram || defaults.monogram,
color: offer.color || defaults.color,
bg: offer.bg || defaults.bg,
approval: Number(offer.approval || defaults.approval || 88),
maxAmount: Number(offer.maxAmount || defaults.maxAmount || 25000),
minAmount: Number(offer.minAmount || defaults.minAmount || 500),
minRate: offer.minRate || defaults.minRate || 'від 0.01%',
termRange: offer.termRange || defaults.termRange || '7-30 днів',
decision: offer.decision || defaults.decision || '5 хв',
tag: offer.tag || defaults.tag || 'Рекомендовано Hermes',
perks: offer.perks || defaults.perks || ['Онлайн на картку', 'Швидке рішення', 'Без довідки про доходи'],
url: offer.url || defaults.url,
};
}
async function getHermesOffers(p) {
try {
const res = await fetch('/api/chat/form-complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
decision_id: HERMES.decisionId || null,
amount: p.amount || 8000,
term: p.term || 30,
purpose: p.purpose || null,
credit: p.credit || null,
phone: p.phone || null,
}),
});
if (!res.ok) throw new Error('chat_http_' + res.status);
const data = await res.json();
if (data.session_id) {
sessionRef.current = data.session_id;
setSessionId(data.session_id);
}
return {
reply: data.reply || '',
offers: (data.offers || []).map(normalizeHermesOffer),
};
} catch (e) {
return { reply: '', offers: [] };
}
}
// ─── Free-text AI chat ───────────────────────────────────
const profileRef = useRef(profile);
const sessionRef = useRef(sessionId);
useEffect(() => { profileRef.current = profile; }, [profile]);
useEffect(() => { sessionRef.current = sessionId; }, [sessionId]);
async function askAI(userText) {
setAiBusy(true);
setTyping(true);
try {
const activeSessionId = sessionRef.current || sessionId;
if (!activeSessionId) throw new Error('no_session');
const res = await fetch('/api/chat/message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: activeSessionId, message: userText }),
});
if (!res.ok) throw new Error('chat_http_' + res.status);
const data = await res.json();
setTyping(false);
const next = [];
if (data.reply) next.push({ id: Math.random(), side: 'agent', kind: 'text', content: data.reply });
setMessages(m => [...m, ...next]);
if (next.length) playSolomiyaSound();
} catch (e) {
setTyping(false);
setMessages(m => [...m, { id: Math.random(), side: 'agent', kind: 'text', content: 'Напишіть суму і що кошти потрібні на картку, а я підберу найкращі варіанти.' }]);
playSolomiyaSound();
}
setAiBusy(false);
}
function sendFreeText(e) {
e && e.preventDefault();
const t = inputValue.trim();
if (!t || aiBusy) return;
pushUser(t);
setInputValue('');
askAI(t);
}
// ─── Renderers ────────────────────────────────────────────
function renderMessage(m) {
if (m.side === 'user') {
return