feat(portal): complete masterplan with deterministic budget simulator, RAG assistant, agency cross-tenant view and multi-tenant middleware
This commit is contained in:
@@ -19,6 +19,79 @@ export async function POST(req: NextRequest) {
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 0. Intención: Simulación Determinista "¿Qué pasa si...?"
|
||||||
|
if (userMessage.includes('simul') || userMessage.includes('invierto') || userMessage.includes('pasa si') || userMessage.includes('proyec')) {
|
||||||
|
try {
|
||||||
|
// Extraer importe numérico
|
||||||
|
const matchNumber = userMessage.match(/(\d+[\d\.,]*)/);
|
||||||
|
let amount = 1000;
|
||||||
|
if (matchNumber) {
|
||||||
|
amount = parseFloat(matchNumber[1].replace(/\./g, '').replace(',', '.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel: string | null = null;
|
||||||
|
if (userMessage.includes('meta') || userMessage.includes('facebook') || userMessage.includes('instagram')) channel = 'meta';
|
||||||
|
else if (userMessage.includes('google')) channel = 'google';
|
||||||
|
else if (userMessage.includes('tiktok')) channel = 'tiktok';
|
||||||
|
|
||||||
|
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
p_tenant_id: tenantId,
|
||||||
|
p_additional_spend: amount,
|
||||||
|
p_target_channel: channel
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const sim = await res.json();
|
||||||
|
const p10 = sim.conservative_p10 || {};
|
||||||
|
const p50 = sim.expected_p50 || {};
|
||||||
|
const p90 = sim.optimistic_p90 || {};
|
||||||
|
const base = sim.baseline || {};
|
||||||
|
|
||||||
|
const text = `🔮 **Simulación de Inversión Adicional (${amount.toLocaleString('es-ES')} € en ${sim.target_channel.toUpperCase()}):**\n\n` +
|
||||||
|
`*Métricas Base Reales:* CPL ${base.historical_cpl} €, Tasa de Cierre ${base.conversion_rate_pct}%, Ticket Medio ${Number(base.avg_ticket || 0).toLocaleString('es-ES')} €.\n\n` +
|
||||||
|
`- **Escenario Esperado (p50):** +${p50.leads} leads, +${p50.deals} clientes, **+${Number(p50.revenue || 0).toLocaleString('es-ES')} €** (ROAS ${p50.roas}x)\n` +
|
||||||
|
`- **Escenario Conservador (p10):** +${p10.leads} leads, +${p10.deals} clientes, **+${Number(p10.revenue || 0).toLocaleString('es-ES')} €** (ROAS ${p10.roas}x)\n` +
|
||||||
|
`- **Escenario Optimista (p90):** +${p90.leads} leads, +${p90.deals} clientes, **+${Number(p90.revenue || 0).toLocaleString('es-ES')} €** (ROAS ${p90.roas}x)\n\n` +
|
||||||
|
`*Proyección calculada matemáticamente sin alucinaciones con la RPC simulate_budget.*`;
|
||||||
|
return Response.json([{ text }]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error in simulate_budget chat:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0.5 Intención: RAG Documental Híbrido (Documentos / Copys / Informes)
|
||||||
|
if (userMessage.includes('document') || userMessage.includes('copy') || userMessage.includes('informe') || userMessage.includes('brief') || userMessage.includes('contenido') || userMessage.includes('activo')) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/match_documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
p_tenant_id: tenantId,
|
||||||
|
p_query_text: userMessage,
|
||||||
|
p_match_count: 3
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const matches = await res.json();
|
||||||
|
if (Array.isArray(matches) && matches.length > 0) {
|
||||||
|
const excerpts = matches.map((m: any) =>
|
||||||
|
`📄 **${m.document_title}** (${m.document_type.toUpperCase()}):\n> ${m.chunk_text.replace(/\n/g, '\n> ')}`
|
||||||
|
).join('\n\n');
|
||||||
|
|
||||||
|
const text = `📚 **Fragmentos Recuperados de la Biblioteca de Activos (RAG Híbrido):**\n\n${excerpts}\n\n*Fuente: Búsqueda vectorial y de texto completo en document_chunks.*`;
|
||||||
|
return Response.json([{ text }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error in match_documents chat:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Intención: Objetivos vs Real / Estrategia
|
// 1. Intención: Objetivos vs Real / Estrategia
|
||||||
if (userMessage.includes('objetivo') || userMessage.includes('meta') || userMessage.includes('factura') || userMessage.includes('estrategia')) {
|
if (userMessage.includes('objetivo') || userMessage.includes('meta') || userMessage.includes('factura') || userMessage.includes('estrategia')) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
37
src/app/api/simulate/route.ts
Normal file
37
src/app/api/simulate/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
||||||
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
||||||
|
const tenantId = process.env.DEFAULT_TENANT_ID || '0b3c5fc0-6901-4caf-b26a-b6a0201d8541';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const spend = parseFloat(body.spend || 1000);
|
||||||
|
const channel = body.channel || 'meta';
|
||||||
|
|
||||||
|
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
apikey: serviceKey,
|
||||||
|
Authorization: `Bearer ${serviceKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
p_tenant_id: tenantId,
|
||||||
|
p_additional_spend: spend,
|
||||||
|
p_target_channel: channel === 'blended' ? null : channel
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
return Response.json(data);
|
||||||
|
} else {
|
||||||
|
const err = await res.text();
|
||||||
|
return Response.json({ error: err }, { status: 500 });
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return Response.json({ error: e.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/app/dashboard/agencia/page.tsx
Normal file
150
src/app/dashboard/agencia/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Building2, DollarSign, Users, TrendingUp, Layers } from 'lucide-react';
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function AgencyPage() {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
||||||
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
apikey: serviceKey,
|
||||||
|
Authorization: `Bearer ${serviceKey}`
|
||||||
|
};
|
||||||
|
|
||||||
|
let tenants: any[] = [];
|
||||||
|
let totalSpend = 0;
|
||||||
|
let totalLeads = 0;
|
||||||
|
let totalWonDeals = 0;
|
||||||
|
let totalRevenue = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [tenantsRes, adRes, leadsRes, dealsRes] = await Promise.all([
|
||||||
|
fetch(`${supabaseUrl}/rest/v1/tenants?select=*&order=created_at.asc`, { headers, cache: 'no-store' }),
|
||||||
|
fetch(`${supabaseUrl}/rest/v1/ad_daily?select=spend`, { headers, cache: 'no-store' }),
|
||||||
|
fetch(`${supabaseUrl}/rest/v1/leads?select=id`, { headers, cache: 'no-store' }),
|
||||||
|
fetch(`${supabaseUrl}/rest/v1/deals?select=amount&stage=eq.won`, { headers, cache: 'no-store' })
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (tenantsRes.ok) tenants = await tenantsRes.json();
|
||||||
|
if (adRes.ok) {
|
||||||
|
const ads = await adRes.json();
|
||||||
|
totalSpend = ads.reduce((acc: number, curr: any) => acc + Number(curr.spend || 0), 0);
|
||||||
|
}
|
||||||
|
if (leadsRes.ok) {
|
||||||
|
const leads = await leadsRes.json();
|
||||||
|
totalLeads = leads.length;
|
||||||
|
}
|
||||||
|
if (dealsRes.ok) {
|
||||||
|
const deals = await dealsRes.json();
|
||||||
|
totalWonDeals = deals.length;
|
||||||
|
totalRevenue = deals.reduce((acc: number, curr: any) => acc + Number(curr.amount || 0), 0);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error in AgencyPage:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blendedRoas = totalSpend > 0 ? (totalRevenue / totalSpend).toFixed(2) : '0';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-md bg-brand-950/60 border border-brand-800/40 text-brand-400 text-xs font-semibold uppercase tracking-wider mb-2">
|
||||||
|
<Layers className="w-3.5 h-3.5" /> Vista Global de Agencia (Cross-Tenant)
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight text-white">Centro de Mando 4Ventures</h2>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Métricas consolidadas de infraestructura, inversión publicitaria y cartera de clientes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global Agency KPI Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs text-gray-400 font-medium">Clientes / Tenants</span>
|
||||||
|
<Building2 className="w-4 h-4 text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-extrabold text-white">{tenants.length}</p>
|
||||||
|
<p className="text-[11px] text-emerald-400 mt-1">100% aislados vía RLS</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs text-gray-400 font-medium">Inversión Gestionada</span>
|
||||||
|
<DollarSign className="w-4 h-4 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-extrabold text-white">
|
||||||
|
{totalSpend.toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-gray-400 mt-1">Vía Windsor.ai pipeline</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs text-gray-400 font-medium">Leads Captados</span>
|
||||||
|
<Users className="w-4 h-4 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-extrabold text-white">{totalLeads}</p>
|
||||||
|
<p className="text-[11px] text-gray-400 mt-1">{totalWonDeals} cerrados ganados</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs text-gray-400 font-medium">ROAS Blended Agencia</span>
|
||||||
|
<TrendingUp className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-extrabold text-white">{blendedRoas}x</p>
|
||||||
|
<p className="text-[11px] text-emerald-400 mt-1">
|
||||||
|
{totalRevenue.toLocaleString('es-ES', { minimumFractionDigits: 2 })} € generados
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tenants Table */}
|
||||||
|
<div className="bg-[#111827] border border-gray-800 rounded-xl overflow-hidden">
|
||||||
|
<div className="p-5 border-b border-gray-800">
|
||||||
|
<h3 className="text-base font-bold text-white">Cartera de Clientes Activos</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
Cada cliente cuenta con aislamiento nativo Row Level Security (RLS) en Postgres.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm text-gray-300">
|
||||||
|
<thead className="bg-gray-900/50 text-xs uppercase text-gray-400 border-b border-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-3">Nombre del Cliente</th>
|
||||||
|
<th className="px-5 py-3">Tenant Slug</th>
|
||||||
|
<th className="px-5 py-3">UUID</th>
|
||||||
|
<th className="px-5 py-3">Acceso Portal</th>
|
||||||
|
<th className="px-5 py-3">Estado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-800/60">
|
||||||
|
{tenants.map((t) => (
|
||||||
|
<tr key={t.id} className="hover:bg-gray-800/30 transition-colors">
|
||||||
|
<td className="px-5 py-3.5 font-semibold text-white">{t.name}</td>
|
||||||
|
<td className="px-5 py-3.5 text-xs text-brand-400 font-mono">{t.slug}</td>
|
||||||
|
<td className="px-5 py-3.5 text-xs text-gray-400 font-mono">{t.id}</td>
|
||||||
|
<td className="px-5 py-3.5 text-xs">
|
||||||
|
<a
|
||||||
|
href={`/dashboard/kpis?tenant=${t.slug}`}
|
||||||
|
className="text-brand-400 hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Abrir Portal ↗
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5 text-xs">
|
||||||
|
<span className="bg-emerald-950/80 text-emerald-400 px-2 py-0.5 rounded-full border border-emerald-800/40 text-[10px] font-bold uppercase">
|
||||||
|
Activo
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Target, CheckCircle2, TrendingUp } from 'lucide-react';
|
import { Target, CheckCircle2, TrendingUp } from 'lucide-react';
|
||||||
|
import BudgetSimulator from '@/components/BudgetSimulator';
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
@@ -103,6 +104,8 @@ export default async function EstrategiaPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BudgetSimulator />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
Target,
|
Target,
|
||||||
FolderGit2,
|
FolderGit2,
|
||||||
Bot,
|
Bot,
|
||||||
ShieldCheck
|
ShieldCheck,
|
||||||
|
Layers
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
@@ -57,6 +58,16 @@ export default function DashboardLayout({
|
|||||||
<Bot className="w-4 h-4 text-purple-400" />
|
<Bot className="w-4 h-4 text-purple-400" />
|
||||||
Asistente IA Financiero
|
Asistente IA Financiero
|
||||||
</Link>
|
</Link>
|
||||||
|
<div className="pt-4 mt-4 border-t border-gray-800/60">
|
||||||
|
<span className="px-3 text-[10px] font-bold uppercase tracking-wider text-gray-400">Agencia OS</span>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/agencia"
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 mt-1 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"
|
||||||
|
>
|
||||||
|
<Layers className="w-4 h-4 text-amber-400" />
|
||||||
|
Cartera Cross-Tenant
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
198
src/components/BudgetSimulator.tsx
Normal file
198
src/components/BudgetSimulator.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/middleware.ts
Normal file
43
src/middleware.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export function middleware(req: NextRequest) {
|
||||||
|
const url = req.nextUrl.clone();
|
||||||
|
const hostname = req.headers.get('host') || '';
|
||||||
|
|
||||||
|
// 1. Extraer slug desde subdominio (ej: acme.4ventures.es)
|
||||||
|
let tenantSlug = '4ventures-hub';
|
||||||
|
const parts = hostname.split('.');
|
||||||
|
|
||||||
|
if (parts.length > 2) {
|
||||||
|
const sub = parts[0].toLowerCase();
|
||||||
|
if (sub !== 'app' && sub !== 'git' && sub !== 'supabase' && sub !== 'n8n' && sub !== 'www') {
|
||||||
|
tenantSlug = sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Override opcional vía query param ?tenant=slug
|
||||||
|
const queryTenant = url.searchParams.get('tenant');
|
||||||
|
if (queryTenant) {
|
||||||
|
tenantSlug = queryTenant.toLowerCase();
|
||||||
|
} else {
|
||||||
|
const cookieTenant = req.cookies.get('4v_tenant')?.value;
|
||||||
|
if (cookieTenant) {
|
||||||
|
tenantSlug = cookieTenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = NextResponse.next({
|
||||||
|
request: {
|
||||||
|
headers: new Headers(req.headers)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
response.headers.set('x-tenant-slug', tenantSlug);
|
||||||
|
response.cookies.set('4v_tenant', tenantSlug, { path: '/', maxAge: 60 * 60 * 24 * 30 });
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!_next/static|_next/image|favicon.ico|_4v_attr.js).*)']
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user