368 lines
13 KiB
TypeScript
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>
|
|
);
|
|
} |