199 lines
8.2 KiB
TypeScript
199 lines
8.2 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { Calculator, TrendingUp, ShieldCheck, Sparkles } from 'lucide-react';
|
|
|
|
export default function BudgetSimulator() {
|
|
const [spend, setSpend] = useState<number>(1000);
|
|
const [channel, setChannel] = useState<string>('meta');
|
|
const [simulation, setSimulation] = useState<any>(null);
|
|
const [loading, setLoading] = useState<boolean>(false);
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
fetchSimulation(spend, channel);
|
|
}, 200);
|
|
return () => clearTimeout(timer);
|
|
}, [spend, channel]);
|
|
|
|
async function fetchSimulation(amount: number, ch: string) {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch('/api/simulate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ spend: amount, channel: ch })
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setSimulation(data);
|
|
}
|
|
} catch (e) {
|
|
console.error('Error simulating budget:', e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
const p10 = simulation?.conservative_p10 || {};
|
|
const p50 = simulation?.expected_p50 || {};
|
|
const p90 = simulation?.optimistic_p90 || {};
|
|
const baseline = simulation?.baseline || {};
|
|
|
|
return (
|
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-6 space-y-6">
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-gray-800 pb-4">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<Calculator className="w-5 h-5 text-brand-500" />
|
|
<h3 className="text-lg font-bold text-white">Simulador Determinista "¿Qué pasa si...?"</h3>
|
|
</div>
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
Proyección matemática basada en el histórico de CPL, tasa de cierre y ticket medio en Supabase.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{['meta', 'google', 'tiktok', 'blended'].map((ch) => (
|
|
<button
|
|
key={ch}
|
|
onClick={() => setChannel(ch)}
|
|
className={`px-3 py-1 rounded-lg text-xs font-semibold uppercase transition-all ${
|
|
channel === ch
|
|
? 'bg-brand-600 text-white shadow-lg shadow-brand-500/20'
|
|
: 'bg-gray-800 text-gray-400 hover:text-white'
|
|
}`}
|
|
>
|
|
{ch}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Control del presupuesto */}
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-center">
|
|
<label className="text-xs font-medium text-gray-300">
|
|
Inversión Adicional Proyectada
|
|
</label>
|
|
<span className="text-lg font-extrabold text-brand-400">
|
|
{spend.toLocaleString('es-ES')} €
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="200"
|
|
max="10000"
|
|
step="100"
|
|
value={spend}
|
|
onChange={(e) => setSpend(Number(e.target.value))}
|
|
className="w-full accent-brand-500 bg-gray-800 rounded-lg cursor-pointer h-2"
|
|
/>
|
|
<div className="flex justify-between text-[10px] text-gray-400">
|
|
<span>200 €</span>
|
|
<span>2.500 €</span>
|
|
<span>5.000 €</span>
|
|
<span>10.000 €</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Baseline Info */}
|
|
<div className="flex flex-wrap items-center gap-3 bg-gray-900/60 p-3 rounded-lg border border-gray-800/80 text-xs text-gray-300">
|
|
<span className="text-gray-400">Parámetros Base Reales:</span>
|
|
<span className="bg-gray-800 px-2 py-0.5 rounded text-gray-200">
|
|
CPL Histórico: <strong>{Number(baseline.historical_cpl || 0).toFixed(2)} €</strong>
|
|
</span>
|
|
<span className="bg-gray-800 px-2 py-0.5 rounded text-gray-200">
|
|
Tasa Cierre: <strong>{baseline.conversion_rate_pct || 0}%</strong>
|
|
</span>
|
|
<span className="bg-gray-800 px-2 py-0.5 rounded text-gray-200">
|
|
Ticket Medio: <strong>{Number(baseline.avg_ticket || 0).toLocaleString('es-ES')} €</strong>
|
|
</span>
|
|
</div>
|
|
|
|
{/* Las 3 Bandas de Confianza */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
|
{/* Conservador p10 */}
|
|
<div className="bg-[#0B0F19] border border-gray-800/80 rounded-xl p-4 flex flex-col justify-between">
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-xs font-semibold text-gray-400">Conservador (p10)</span>
|
|
<ShieldCheck className="w-4 h-4 text-amber-400" />
|
|
</div>
|
|
<p className="text-2xl font-extrabold text-white">
|
|
+{Number(p10.revenue || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
|
|
</p>
|
|
<p className="text-xs text-emerald-400 font-semibold mt-1">
|
|
ROAS {p10.roas || 0}x
|
|
</p>
|
|
</div>
|
|
<div className="mt-4 pt-3 border-t border-gray-800/60 text-xs text-gray-400 space-y-1">
|
|
<div className="flex justify-between">
|
|
<span>Leads estimados:</span>
|
|
<strong className="text-gray-200">{p10.leads || 0}</strong>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>Ventas cerradas:</span>
|
|
<strong className="text-gray-200">{p10.deals || 0}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Esperado p50 (Destacado) */}
|
|
<div className="bg-gradient-to-b from-brand-950/40 to-[#0B0F19] border-2 border-brand-500/60 rounded-xl p-4 flex flex-col justify-between relative shadow-lg shadow-brand-900/20">
|
|
<div className="absolute -top-2.5 right-3 bg-brand-600 text-white text-[9px] uppercase tracking-wider font-bold px-2 py-0.5 rounded-full">
|
|
Más Probable
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-xs font-semibold text-brand-300">Esperado (p50)</span>
|
|
<Sparkles className="w-4 h-4 text-brand-400" />
|
|
</div>
|
|
<p className="text-2xl font-extrabold text-white">
|
|
+{Number(p50.revenue || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
|
|
</p>
|
|
<p className="text-xs text-brand-400 font-semibold mt-1">
|
|
ROAS {p50.roas || 0}x
|
|
</p>
|
|
</div>
|
|
<div className="mt-4 pt-3 border-t border-brand-800/40 text-xs text-gray-300 space-y-1">
|
|
<div className="flex justify-between">
|
|
<span>Leads estimados:</span>
|
|
<strong className="text-white">{p50.leads || 0}</strong>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>Ventas cerradas:</span>
|
|
<strong className="text-white">{p50.deals || 0}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Optimista p90 */}
|
|
<div className="bg-[#0B0F19] border border-gray-800/80 rounded-xl p-4 flex flex-col justify-between">
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-xs font-semibold text-gray-400">Optimista (p90)</span>
|
|
<TrendingUp className="w-4 h-4 text-emerald-400" />
|
|
</div>
|
|
<p className="text-2xl font-extrabold text-white">
|
|
+{Number(p90.revenue || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
|
|
</p>
|
|
<p className="text-xs text-emerald-400 font-semibold mt-1">
|
|
ROAS {p90.roas || 0}x
|
|
</p>
|
|
</div>
|
|
<div className="mt-4 pt-3 border-t border-gray-800/60 text-xs text-gray-400 space-y-1">
|
|
<div className="flex justify-between">
|
|
<span>Leads estimados:</span>
|
|
<strong className="text-gray-200">{p90.leads || 0}</strong>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>Ventas cerradas:</span>
|
|
<strong className="text-gray-200">{p90.deals || 0}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|