Initial commit: Portal Brief Inteligente CDC

This commit is contained in:
Isaac Aracena
2026-05-06 17:55:54 -04:00
commit f1284fc405
20 changed files with 6868 additions and 0 deletions
+365
View File
@@ -0,0 +1,365 @@
import React, { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { FileText, Loader2, ShieldCheck, Sparkles, Workflow } from 'lucide-react';
import { onAuthStateChanged, signInWithPopup, signOut, User } from 'firebase/auth';
import { Header } from './components/Header';
import { InputArea } from './components/InputArea';
import { MessageBubble } from './components/MessageBubble';
import { LoginScreen } from './components/LoginScreen';
import { auth, googleProvider } from './lib/firebase';
import { Message, Attachment, AudioRecording } from './types';
export default function App() {
const welcomeText = `### Portal Brief CDC
Este portal funciona por pasos y no como un chat libre.
**Orden del proceso:**
1. **Nota de voz** _(obligatorio)_
2. **Imágenes de referencia** _(obligatorio)_
3. **Documentos relacionados y archivos de apoyo** _(opcional)_
4. **Enlaces de referencia online** _(opcional)_
**Qué hace el sistema con cada entrada:**
- **Nota de voz:** se analiza
- **Imágenes:** se analizan
- **PDF, Excel, PowerPoint, Word y otros archivos:** se guardan en la carpeta del proyecto
- **Enlaces online:** se listan dentro del brief
Cuando completes los pasos obligatorios, podrás enviar la solicitud.`;
const [messages, setMessages] = useState<Message[]>([
{
id: 'welcome',
sender: 'system',
text: welcomeText,
timestamp: new Date(),
status: 'sent'
}
]);
const getInitialWebhookUrl = () => {
if (typeof window === 'undefined') {
return import.meta.env.VITE_WEBHOOK_URL || '';
}
return (
window.localStorage.getItem('cdc-brief-webhook-url') ||
import.meta.env.VITE_WEBHOOK_URL ||
''
);
};
const [webhookUrl] = useState(getInitialWebhookUrl);
const [isProcessing, setIsProcessing] = useState(false);
const [isConnected, setIsConnected] = useState(true);
const [user, setUser] = useState<User | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const [loginLoading, setLoginLoading] = useState(false);
const [authError, setAuthError] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages, isProcessing]);
useEffect(() => {
if (typeof window !== 'undefined' && webhookUrl) {
window.localStorage.setItem('cdc-brief-webhook-url', webhookUrl);
}
}, [webhookUrl]);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
setUser(currentUser);
setAuthLoading(false);
});
return () => unsubscribe();
}, []);
const handleGoogleLogin = async () => {
try {
setLoginLoading(true);
setAuthError('');
const result = await signInWithPopup(auth, googleProvider);
const email = result.user.email || '';
const allowedDomain = '@gomezleemarketing.com';
if (!email.toLowerCase().endsWith(allowedDomain.toLowerCase())) {
await signOut(auth);
setAuthError('Solo se permite acceso con cuentas corporativas de la empresa.');
return;
}
setUser(result.user);
} catch (error) {
console.error('Google login error:', error);
setAuthError('No se pudo iniciar sesión con Google.');
} finally {
setLoginLoading(false);
}
};
const handleLogout = async () => {
await signOut(auth);
setUser(null);
};
const handleSendMessage = async (
text: string,
attachments: Attachment[],
audio?: AudioRecording
) => {
const userMessageId = Math.random().toString(36).substring(7);
const newMessage: Message = {
id: userMessageId,
sender: 'user',
text,
attachments,
audio,
timestamp: new Date(),
status: 'sending'
};
setMessages((prev) => [...prev, newMessage]);
setIsProcessing(true);
try {
const formData = new FormData();
formData.append('text', text);
formData.append('timestamp', new Date().toISOString());
attachments.forEach((att, index) => {
formData.append(`file_${index}`, att.file);
});
if (audio) {
formData.append('audio', audio.blob, 'voice_note.webm');
}
const response = await fetch(webhookUrl, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
let responseData: unknown;
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
responseData = await response.json();
} else {
responseData = await response.text();
}
setMessages((prev) =>
prev.map((msg) =>
msg.id === userMessageId ? { ...msg, status: 'sent' } : msg
)
);
const systemMessage: Message = {
id: Math.random().toString(36).substring(7),
sender: 'system',
text:
typeof responseData === 'object'
? JSON.stringify(responseData, null, 2)
: (responseData as string) || 'Solicitud recibida correctamente.',
timestamp: new Date(),
status: 'sent'
};
setMessages((prev) => [...prev, systemMessage]);
setIsConnected(true);
} catch (error) {
console.error('Webhook error:', error);
setIsConnected(false);
setMessages((prev) =>
prev.map((msg) =>
msg.id === userMessageId ? { ...msg, status: 'error' } : msg
)
);
const errorMessage: Message = {
id: Math.random().toString(36).substring(7),
sender: 'system',
text: `**Error de conexión:** No se pudo conectar con el webhook configurado.
Verifica que la URL productiva esté correcta en el entorno y que el endpoint esté activo y acepte solicitudes POST con FormData.`,
timestamp: new Date(),
status: 'error'
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsProcessing(false);
}
};
if (authLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#060816] text-zinc-200">
Cargando acceso...
</div>
);
}
if (!user) {
return (
<LoginScreen
onLogin={handleGoogleLogin}
isLoading={loginLoading}
error={authError}
/>
);
}
return (
<div className="relative flex min-h-screen flex-col overflow-x-hidden bg-[#060816] text-zinc-100 font-sans selection:bg-indigo-500/30">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute left-[-10%] top-[-15%] h-[34rem] w-[34rem] rounded-full bg-indigo-600/18 blur-3xl" />
<div className="absolute right-[-8%] top-[8%] h-[30rem] w-[30rem] rounded-full bg-cyan-400/12 blur-3xl" />
<div className="absolute bottom-[-18%] left-[20%] h-[28rem] w-[28rem] rounded-full bg-fuchsia-500/10 blur-3xl" />
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:42px_42px] opacity-[0.06]" />
</div>
<Header isConnected={isConnected} onLogout={handleLogout} />
<main className="relative flex-1">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6 pb-8 sm:px-6 lg:px-8">
<section className="overflow-hidden rounded-[32px] border border-white/10 bg-white/5 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-xl">
<div className="grid gap-0 lg:grid-cols-[1.5fr_1fr]">
<div className="relative p-6 sm:p-7 lg:p-8">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(99,102,241,0.22),transparent_38%),radial-gradient(circle_at_bottom_right,rgba(34,211,238,0.14),transparent_36%)]" />
<div className="relative">
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-indigo-400/25 bg-indigo-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.22em] text-indigo-200">
<Sparkles className="h-3.5 w-3.5" /> Portal inteligente de briefs
</div>
<h2 className="max-w-3xl text-3xl font-semibold tracking-tight text-white sm:text-4xl">
Carga el brief en un flujo claro, visual y mucho más profesional.
</h2>
<p className="mt-4 max-w-2xl text-sm leading-7 text-zinc-300 sm:text-base">
La interfaz guía al usuario paso por paso, reduce errores de carga y organiza audio, imágenes, archivos y referencias para construir un brief listo para revisión interna.
</p>
<div className="mt-6 flex flex-wrap gap-3">
{[
{ icon: ShieldCheck, label: 'Validaciones obligatorias' },
{ icon: Workflow, label: 'Proceso guiado por pasos' },
{ icon: FileText, label: 'Entrega estructurada al flujo' },
].map(({ icon: Icon, label }) => (
<div
key={label}
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-200"
>
<Icon className="h-4 w-4 text-indigo-300" />
<span>{label}</span>
</div>
))}
</div>
</div>
</div>
<div className="border-t border-white/10 bg-black/20 p-6 sm:p-7 lg:border-l lg:border-t-0 lg:p-8">
<div className="rounded-[28px] border border-white/10 bg-black/25 p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">
Resumen del sistema
</p>
<div className="mt-4 space-y-4">
{[
'La nota de voz y las imágenes sí se analizan automáticamente.',
'PDF, Excel, PowerPoint, Word y otros adjuntos se guardan en la carpeta del proyecto.',
'Los enlaces online se registran para alimentar el brief final.',
'El usuario no necesita usar el portal como chat libre: todo queda guiado.',
].map((item) => (
<div key={item} className="flex items-start gap-3">
<div className="mt-1 h-2.5 w-2.5 rounded-full bg-indigo-400 shadow-[0_0_18px_rgba(129,140,248,0.7)]" />
<p className="text-sm leading-6 text-zinc-300">{item}</p>
</div>
))}
</div>
</div>
</div>
</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-black/20 p-4 shadow-[0_24px_60px_rgba(0,0,0,0.28)] backdrop-blur-xl sm:p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">
Carga guiada
</p>
<h3 className="mt-1 text-lg font-semibold text-white sm:text-xl">
Completa el brief y envía la solicitud
</h3>
</div>
</div>
<InputArea
onSendMessage={handleSendMessage}
isProcessing={isProcessing}
/>
</section>
<section className="rounded-[30px] border border-white/10 bg-black/20 p-4 shadow-[0_24px_60px_rgba(0,0,0,0.28)] backdrop-blur-xl sm:p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">
Respuesta del portal
</p>
<h3 className="mt-1 text-lg font-semibold text-white sm:text-xl">
Resultado de la solicitud
</h3>
</div>
</div>
<div className="max-h-[65vh] min-h-[360px] overflow-y-auto rounded-[24px] border border-white/10 bg-white/[0.03] p-4 sm:p-5 lg:p-6 scrollbar-thin">
<AnimatePresence initial={false}>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
</AnimatePresence>
{isProcessing && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9 }}
className="mb-4 flex w-fit items-center gap-3 rounded-2xl border border-white/10 bg-white/5 px-4 py-3"
>
<Loader2 className="h-4 w-4 animate-spin text-indigo-400" />
<span className="text-sm font-medium text-zinc-300">
Procesando la solicitud...
</span>
</motion.div>
)}
<div ref={messagesEndRef} className="h-2" />
</div>
</section>
</div>
</main>
</div>
);
}