// 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 {m.content}; } if (m.kind === 'text') { return {m.content}; } if (m.kind === 'partners') { return ; } if (m.kind === 'qr') { return ; } if (m.kind === 'slider') { return ; } if (m.kind === 'phone') { return ; } if (m.kind === 'processing') { return ; } if (m.kind === 'summary') { const p = profileRef.current; return
{p.amount && } {p.term && } {p.purpose && } {p.credit && }
; } if (m.kind === 'mfos') { return
; } return null; } // ─── Layout ─────────────────────────────────────────────── return (
{messages.map(renderMessage)} {typing && }
); } // ─── Header (avatar + name + status + menu) ──────────────── function Header() { return (
Соломія
AI-консультант · Онлайн
); } function RobocashLogo() { return (
ROBOCASH
); } const iconBtn = { width: 32, height: 32, borderRadius: 999, border: 'none', background: 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', }; // ─── Trust strip ──────────────────────────────────────────── function TrustStrip() { const icons = { check: , shield: , gift: , bolt: , }; return (
{RC2.TRUST.map((t, i) => (
{icons[t.icon]} {t.label}
))}
); } // ─── Composer (bottom input) ──────────────────────────────── function Composer({ value, onChange, onSend, disabled, placeholder }) { return (
onChange(e.target.value)} placeholder={placeholder} style={{ flex: 1, border: 'none', background: 'transparent', fontSize: 14.5, color: T.ink, outline: 'none', fontFamily: 'inherit', padding: '9px 4px', opacity: disabled ? 0.5 : 1, }} />
); } window.Chat = Chat;