Initial commit: Portal Brief Inteligente CDC
This commit is contained in:
+365
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user