Files
cdcbrief-glm/src/App.tsx
T

368 lines
13 KiB
TypeScript

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** _(opcional)_
4. **Campaña o elementos gráficos existentes** _(opcional)_
5. **Enlaces de referencia online** _(opcional)_
6. **Revisión y envío**
**Qué hace el sistema con cada entrada:**
- **Nota de voz:** se analiza
- **Imágenes:** se analizan
- **Documentos y Campaña:** se guardan en la carpeta del proyecto
- **Enlaces online:** se listan dentro del brief
Cuando completes los pasos obligatorios, podrás enviar la solicitud en el paso final.`;
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) => {
const key = att.category === 'campaign' ? `campaign_file_${index}` : `file_${index}`;
formData.append(key, 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, las imágenes y los PDFs se analizan automáticamente.',
'Excel, PowerPoint, Word y otros adjuntos se guardan como soporte 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>
);
}