/* global React, ReactDOM */ // ProjectAdvisor — Asesor Interactivo de Proyecto. Reemplaza a LeadModal.jsx. // Recorre: entrada → árbol (landing/corporativo/ecommerce/sistema/discovery) // → resumen → estimación → inversión → comparación → (scope-down opcional) // → resumen final → contacto → confirmación. // // Sin imports de Vue ni de ES modules: usa los globals ya cargados por // pricingConfig.js / flows.js / engine.js (window.BATAJ_*), y React puro. const { useState, useEffect, useMemo, useRef, useCallback } = React; const WHATSAPP_NUMBER = '5491157623850'; function buildWhatsappMessage(projectType, answers, estimate) { const service = window.BATAJ_PRICING_SEED.services[projectType]; const label = service ? service.label : projectType; const min = estimate ? estimate.min.toLocaleString('es-AR') : ''; const max = estimate ? estimate.max.toLocaleString('es-AR') : ''; return `Hola, quiero avanzar con mi proyecto: ${label}. Inversión estimada: ARS ${min} - ${max}. Vengo del Asesor de Proyecto de la web.`; } function timelineWeeks(projectType, selectionCount) { const base = { landing: [2, 3], corporativo: [3, 5], ecommerce: [4, 8], sistema: [6, 12] }[projectType] || [2, 4]; const bump = selectionCount >= 8 ? 1 : 0; return [base[0] + bump, base[1] + bump]; } function complexityDots(answers) { let n = 0; Object.values(answers).forEach((v) => { if (Array.isArray(v)) n += v.length; else if (v) n += 1; }); const level = n >= 8 ? 5 : n >= 6 ? 4 : n >= 4 ? 3 : n >= 2 ? 2 : 1; return '●'.repeat(level) + '○'.repeat(5 - level); } function fmtMoney(n) { return 'ARS ' + Math.round(n).toLocaleString('es-AR'); } function ProjectAdvisor() { const [isOpen, setIsOpen] = useState(false); const [pricing, setPricing] = useState(window.BATAJ_PRICING_SEED); const [phase, setPhase] = useState('entry'); const [projectType, setProjectType] = useState(null); const [stepIndex, setStepIndex] = useState(0); const [answers, setAnswers] = useState({}); const [pendingMulti, setPendingMulti] = useState([]); const [pendingText, setPendingText] = useState(''); const [budgetBand, setBudgetBand] = useState(null); const [budgetMatch, setBudgetMatch] = useState(null); const [scopeRemoved, setScopeRemoved] = useState({}); // "field:value" -> true const [contact, setContact] = useState({ name: '', business: '', contact: '', role: '', stage: '', start_when: '', comment: '' }); const [sending, setSending] = useState(false); const [chosenReadySystem, setChosenReadySystem] = useState(null); const [subscriptionTier, setSubscriptionTier] = useState(null); const [leadId, setLeadId] = useState(null); const [payState, setPayState] = useState('idle'); // idle | loading | error const [payError, setPayError] = useState(''); const [couponInput, setCouponInput] = useState(''); const [couponCode, setCouponCode] = useState(''); const [couponPercent, setCouponPercent] = useState(0); const [couponState, setCouponState] = useState('idle'); // idle | checking | valid | invalid const pendingMultiRef = useRef([]); const engine = window.BATAJ_ADVISOR_ENGINE; useEffect(() => { fetch('/api/pricing.php') .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (data && data.services) setPricing(data); }) .catch(() => {}); }, []); // El asesor ya no tiene botón propio: lo abre la navbar (y cualquier CTA del // sitio) disparando este evento. Ver openAdvisor() en app.jsx. useEffect(() => { const onOpen = () => setIsOpen(true); window.addEventListener('bataj:open-advisor', onOpen); return () => window.removeEventListener('bataj:open-advisor', onOpen); }, []); useEffect(() => { if (!isOpen) return; const onKeydown = (e) => { if (e.key === 'Escape') close(); }; window.addEventListener('keydown', onKeydown); return () => window.removeEventListener('keydown', onKeydown); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]); const close = useCallback(() => { setIsOpen(false); setTimeout(() => { setPhase('entry'); setProjectType(null); setStepIndex(0); setAnswers({}); setPendingMulti([]); setPendingText(''); setBudgetBand(null); setBudgetMatch(null); setScopeRemoved({}); setContact({ name: '', business: '', contact: '', role: '', stage: '', start_when: '', comment: '' }); setChosenReadySystem(null); setSubscriptionTier(null); setLeadId(null); setPayState('idle'); setPayError(''); }, 250); }, []); const currentFlow = projectType && projectType !== 'discovery' ? window.BATAJ_ADVISOR_FLOWS[projectType] : window.BATAJ_ADVISOR_FLOWS.discovery; const currentStep = phase === 'tree' && currentFlow ? currentFlow[stepIndex] : null; // Recompute selection state (pendingMulti/pendingText) whenever the step changes. useEffect(() => { if (!currentStep) return; if (currentStep.type === 'multiple') { const initial = answers[currentStep.id] || []; pendingMultiRef.current = initial; setPendingMulti(initial); } if (currentStep.type === 'text') setPendingText(answers[currentStep.id] || ''); // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase, stepIndex, projectType]); const effectiveAnswers = useMemo(() => { if (!Object.keys(scopeRemoved).length) return answers; const copy = { ...answers }; Object.keys(copy).forEach((k) => { if (Array.isArray(copy[k])) { copy[k] = copy[k].filter((v) => !scopeRemoved[`${k}:${v}`]); } }); return copy; }, [answers, scopeRemoved]); const resolvedProjectType = projectType === 'discovery' ? (window.BATAJ_DISCOVERY_ROUTING[answers.limitante] || 'landing') : projectType; // Sin descontar — esto es lo que se manda a lead.php. El descuento real se // aplica y se valida server-side a partir de coupon_code, nunca acá: si // horneáramos el % directo en lo que viaja al server, cualquiera podría // simular un cupón inventado y pagar de menos en la seña de Mercado Pago. const rawEstimate = useMemo(() => { if (!resolvedProjectType || resolvedProjectType === 'discovery') return null; return engine.computeEstimate(resolvedProjectType, effectiveAnswers, pricing); }, [resolvedProjectType, effectiveAnswers, pricing]); // Con descuento — solo para lo que ve el usuario en pantalla. const estimate = useMemo(() => { if (!rawEstimate || !couponPercent) return rawEstimate; const factor = (100 - couponPercent) / 100; return { ...rawEstimate, min: engine.round10k(rawEstimate.min * factor), max: engine.round10k(rawEstimate.max * factor), }; }, [rawEstimate, couponPercent]); async function applyCoupon() { const code = couponInput.trim(); if (!code || couponState === 'checking') return; setCouponState('checking'); try { const res = await fetch('/api/coupon-apply.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }), }); const data = await res.json().catch(() => null); if (data && data.valid) { setCouponCode(code); setCouponPercent(data.percent || 0); setCouponState('valid'); } else { setCouponCode(''); setCouponPercent(0); setCouponState('invalid'); } } catch (err) { setCouponCode(''); setCouponPercent(0); setCouponState('invalid'); } } function isDiscoveryFlow() { return projectType === 'discovery' && currentFlow === window.BATAJ_ADVISOR_FLOWS.discovery; } function goNextStep(nextAnswers) { const flow = isDiscoveryFlow() ? window.BATAJ_ADVISOR_FLOWS.discovery : window.BATAJ_ADVISOR_FLOWS[projectType]; if (stepIndex + 1 < flow.length) { setStepIndex(stepIndex + 1); return; } if (isDiscoveryFlow()) { setPhase('discoveryResolved'); return; } setPhase('summary'); } function answerSingle(step, value) { const next = { ...answers, [step.id]: value }; setAnswers(next); goNextStep(next); } function toggleMulti(value) { setPendingMulti((cur) => { const updated = cur.includes(value) ? cur.filter((v) => v !== value) : [...cur, value]; pendingMultiRef.current = updated; return updated; }); } function confirmMulti(step) { // Lee del ref (siempre al día) en vez del closure de `pendingMulti`, que // puede quedar stale si dos clicks caen en el mismo batch de React. const next = { ...answers, [step.id]: pendingMultiRef.current }; setAnswers(next); goNextStep(next); } function confirmText(step) { const next = { ...answers, [step.id]: pendingText }; setAnswers(next); goNextStep(next); } function pickEntry(value) { setProjectType(value); setStepIndex(0); setPhase('tree'); } function confirmDiscoveryRoute() { setProjectType(resolvedProjectType); setStepIndex(0); setPhase('tree'); } function pickBudgetBand(band) { setBudgetBand(band); const match = engine.classifyBudgetMatch(estimate, band); setBudgetMatch(match); setPhase('compare'); } function goScopeDown() { setPhase('scopedown'); } // Camino reactivo: solo se ofrece cuando el presupuesto no matchea la // estimación de un desarrollo a medida — nunca como opción de entrada. function goReadySystems() { setPhase('readySystems'); } function pickReadySystem(system) { setChosenReadySystem(system); setPhase('final'); } function backToCompareFromReadySystems() { setChosenReadySystem(null); setPhase('compare'); } function pickSubscription(tier) { setSubscriptionTier(tier.key); setPhase('contact'); } function depositBaseAmount() { if (chosenReadySystem) return Math.round((chosenReadySystem.priceMin + chosenReadySystem.priceMax) / 2); if (estimate) return Math.round((estimate.min + estimate.max) / 2); return 0; } function depositAmount() { return Math.round(depositBaseAmount() * (window.BATAJ_DEPOSIT_RATE || 0.3)); } async function startPayment() { if (!leadId || payState === 'loading') return; setPayState('loading'); setPayError(''); const projectLabel = chosenReadySystem ? chosenReadySystem.name : ((pricing.services[resolvedProjectType] || {}).label || 'Proyecto'); // No mandamos `amount`: checkout-create.php lo recalcula server-side a // partir del estimated_min/max que ya quedó guardado en el lead — el // valor de acá abajo es solo para mostrarlo en el botón. try { const res = await fetch('/api/checkout-create.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lead_id: leadId, title: `Anticipo 30% — ${projectLabel} · Bataj Systems`, }), }); const data = await res.json().catch(() => null); if (!res.ok || !data || !data.init_point) throw new Error((data && data.error) || 'No se pudo generar el link de pago.'); window.location.href = data.init_point; } catch (err) { setPayState('error'); setPayError('No pudimos generar el link de pago ahora. Podés coordinarlo por WhatsApp mientras tanto.'); } } function toggleScope(field, value) { const key = `${field}:${value}`; setScopeRemoved((cur) => { const next = { ...cur }; if (next[key]) delete next[key]; else next[key] = true; return next; }); } // Recalcula y SIEMPRE avanza. Antes se quedaba en 'scopedown' si el // presupuesto seguía corto, lo que dejaba al usuario tocando un botón que // no hacía nada visible. function continueFromScopeDown() { const newEstimate = engine.computeEstimate(resolvedProjectType, effectiveAnswers, pricing); setBudgetMatch(engine.classifyBudgetMatch(newEstimate, budgetBand)); setPhase('final'); } async function submitLead(e) { e.preventDefault(); if (!contact.name.trim() || !contact.contact.trim() || sending) return; setSending(true); const score = engine.computeScore( resolvedProjectType, effectiveAnswers, budgetBand && budgetBand.key, budgetMatch, { startWhen: contact.start_when, role: contact.role, stage: contact.stage } ); const qualification = engine.qualify(score); const service = pricing.services[resolvedProjectType]; const weeks = timelineWeeks(resolvedProjectType, Object.keys(effectiveAnswers).length); const payload = { name: contact.name.trim(), business: contact.business.trim(), contact: contact.contact.trim(), service: chosenReadySystem ? chosenReadySystem.name : (service ? service.label : resolvedProjectType), budget: budgetBand ? budgetBand.label : '', message: contact.comment.trim(), source: 'batajsystems.com · asesor de proyecto', project_type: resolvedProjectType, objective: Array.isArray(effectiveAnswers.objective) ? effectiveAnswers.objective.join(', ') : (effectiveAnswers.objective || ''), complexity: effectiveAnswers.complejidad || effectiveAnswers.nivel_visual || effectiveAnswers.criticidad || '', features_json: JSON.stringify(effectiveAnswers.features || effectiveAnswers.funcionalidades || []), integrations_json: JSON.stringify(effectiveAnswers.integraciones || []), content_status: effectiveAnswers.contenido || '', budget_choice: budgetBand ? budgetBand.key : '', estimated_min: chosenReadySystem ? chosenReadySystem.priceMin : (rawEstimate ? rawEstimate.min : null), estimated_max: chosenReadySystem ? chosenReadySystem.priceMax : (rawEstimate ? rawEstimate.max : null), coupon_code: couponPercent > 0 ? couponCode : '', timeline_weeks_min: weeks[0], timeline_weeks_max: weeks[1], start_when: contact.start_when, decision_role: contact.role, business_stage: contact.stage, lead_score: score, qualification: qualification.key, raw_answers_json: JSON.stringify(effectiveAnswers), path_kind: chosenReadySystem ? 'ready_system' : 'custom', ready_system_key: chosenReadySystem ? chosenReadySystem.key : '', subscription_tier: subscriptionTier || '', }; try { const res = await fetch('/api/lead.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await res.json().catch(() => null); if (data && data.id) setLeadId(data.id); } catch (err) { console.warn('[ProjectAdvisor] No se pudo registrar el lead:', err); } setSending(false); setPhase('done'); window.open(`https://wa.me/${WHATSAPP_NUMBER}?text=${encodeURIComponent(buildWhatsappMessage(resolvedProjectType, effectiveAnswers, estimate))}`, '_blank', 'noopener'); } /* ───────────────────────── render helpers ───────────────────────── */ function OptionCard({ label, hint, onClick, selected }) { return ( ); } function StepHeader({ title, subtext }) { return (

{title}

{subtext &&

{subtext}

}
); } function BackBar({ onBack }) { return ( ); } function ProgressBar() { if (phase !== 'tree' || !currentFlow) return null; const pct = Math.round(((stepIndex + 1) / currentFlow.length) * 100); return (
Paso {stepIndex + 1} de {currentFlow.length} {pct}%
); } function renderEntry() { const d = window.BATAJ_ADVISOR_ENTRY; return (

/ asesor de proyecto

{d.options.map((o) => ( pickEntry(o.value)} /> ))}
); } function renderTreeStep() { const step = currentStep; if (!step) return null; return (
{(stepIndex > 0 || !isDiscoveryFlow()) && ( { if (stepIndex === 0) { setProjectType(null); setPhase('entry'); } else setStepIndex(stepIndex - 1); }} /> )} {step.type === 'single' && (
{step.options.map((o) => ( answerSingle(step, o.value)} /> ))}
)} {step.type === 'multiple' && ( <>
{step.options.map((o) => { const sel = pendingMulti.includes(o.value); return ( ); })}
)} {step.type === 'text' && ( <>