Initial commit: Portal Brief Inteligente CDC
This commit is contained in:
+44
@@ -0,0 +1,44 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Production
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
CDC-Brief v1/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# Debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.*
|
||||||
|
ENV.env
|
||||||
|
! .env.example
|
||||||
|
|
||||||
|
# Zips and binaries
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
|
|
||||||
|
# Editor folders
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# Run and deploy your AI Studio app
|
||||||
|
|
||||||
|
This contains everything you need to run your app locally.
|
||||||
|
|
||||||
|
View your app in AI Studio: https://ai.studio/apps/d6393390-53fa-4d74-b914-e4fff219462b
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
**Prerequisites:** Node.js
|
||||||
|
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
`npm install`
|
||||||
|
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||||
|
3. Run the app:
|
||||||
|
`npm run dev`
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>My Google AI Studio App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "Nexus Portal",
|
||||||
|
"description": "A premium, modern, and fully responsive chat portal connected to a webhook, featuring real-time messaging, file attachments, and voice notes.",
|
||||||
|
"requestFramePermissions": [
|
||||||
|
"microphone"
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+5204
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "react-example",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"firebase": "^12.12.1",
|
||||||
|
"lucide-react": "^0.546.0",
|
||||||
|
"motion": "^12.23.24",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { LogOut, Sparkles, Zap } from 'lucide-react';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
isConnected: boolean;
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Header: React.FC<HeaderProps> = ({ isConnected, onLogout }) => {
|
||||||
|
return (
|
||||||
|
<header className="relative z-50 border-b border-white/10 bg-black/25 px-4 py-4 backdrop-blur-2xl sm:px-6 lg:px-8">
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl items-center justify-between gap-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-3 sm:gap-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[linear-gradient(135deg,#6366f1_0%,#2563eb_50%,#06b6d4_100%)] shadow-[0_18px_40px_rgba(79,70,229,0.38)]">
|
||||||
|
<Zap className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h1 className="truncate text-lg font-semibold tracking-tight text-white sm:text-xl">
|
||||||
|
Portal Brief CDC
|
||||||
|
</h1>
|
||||||
|
<span className="hidden rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.24em] text-zinc-400 sm:inline-flex">
|
||||||
|
Experiencia guiada
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2 sm:gap-3">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-zinc-300">
|
||||||
|
<span className="relative flex h-2.5 w-2.5">
|
||||||
|
{isConnected && (
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-70" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-2.5 w-2.5 rounded-full',
|
||||||
|
isConnected ? 'bg-emerald-400' : 'bg-red-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{isConnected ? 'Conectado al flujo' : 'Sin conexión'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden items-center gap-2 rounded-full border border-indigo-400/20 bg-indigo-500/10 px-2.5 py-1 text-xs text-indigo-200 sm:inline-flex">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
Flujo validado
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-zinc-200 transition hover:border-white/20 hover:bg-white/10 hover:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50"
|
||||||
|
aria-label="Cerrar sesión"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Cerrar sesión</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,607 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronRight,
|
||||||
|
File,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Link2,
|
||||||
|
Loader2,
|
||||||
|
Mic,
|
||||||
|
Paperclip,
|
||||||
|
Send,
|
||||||
|
Square,
|
||||||
|
WandSparkles,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Attachment, AttachmentType, AudioRecording } from '../types';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
|
interface InputAreaProps {
|
||||||
|
onSendMessage: (text: string, attachments: Attachment[], audio?: AudioRecording) => void;
|
||||||
|
isProcessing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GuidedStep = 'audio' | 'images' | 'documents' | 'links';
|
||||||
|
|
||||||
|
interface StepDefinition {
|
||||||
|
id: GuidedStep;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
hint: string;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_DEFINITIONS: StepDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'audio',
|
||||||
|
number: 1,
|
||||||
|
title: 'Nota de voz',
|
||||||
|
hint: 'Obligatorio. Graba una nota de voz explicando el brief.',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'images',
|
||||||
|
number: 2,
|
||||||
|
title: 'Imágenes de referencia',
|
||||||
|
hint: 'Obligatorio. Adjunta una o varias imágenes de referencia.',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'documents',
|
||||||
|
number: 3,
|
||||||
|
title: 'Documentos relacionados',
|
||||||
|
hint: 'Opcional. Adjunta PDF, Excel, PowerPoint, Word u otros archivos de apoyo.',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'links',
|
||||||
|
number: 4,
|
||||||
|
title: 'Enlaces de referencia online',
|
||||||
|
hint: 'Opcional. Pega enlaces, uno por línea.',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const InputArea: React.FC<InputAreaProps> = ({ onSendMessage, isProcessing }) => {
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
|
const [audioRecording, setAudioRecording] = useState<AudioRecording | null>(null);
|
||||||
|
const [currentStep, setCurrentStep] = useState<GuidedStep>('audio');
|
||||||
|
const [validationMessage, setValidationMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const audioChunksRef = useRef<Blob[]>([]);
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const imageAttachments = useMemo(
|
||||||
|
() => attachments.filter((att) => att.type === 'image'),
|
||||||
|
[attachments],
|
||||||
|
);
|
||||||
|
const documentAttachments = useMemo(
|
||||||
|
() => attachments.filter((att) => att.type !== 'image'),
|
||||||
|
[attachments],
|
||||||
|
);
|
||||||
|
|
||||||
|
const requiredCompleted = Boolean(audioRecording) && imageAttachments.length > 0;
|
||||||
|
const progressValue = ((STEP_DEFINITIONS.findIndex((step) => step.id === currentStep) + 1) / STEP_DEFINITIONS.length) * 100;
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
attachments.forEach((attachment) => URL.revokeObjectURL(attachment.url));
|
||||||
|
if (audioRecording?.url) {
|
||||||
|
URL.revokeObjectURL(audioRecording.url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [attachments, audioRecording]);
|
||||||
|
|
||||||
|
const getStepStatus = (step: GuidedStep) => {
|
||||||
|
switch (step) {
|
||||||
|
case 'audio':
|
||||||
|
return audioRecording ? 'done' : currentStep === 'audio' ? 'active' : 'pending';
|
||||||
|
case 'images':
|
||||||
|
return imageAttachments.length > 0 ? 'done' : currentStep === 'images' ? 'active' : 'pending';
|
||||||
|
case 'documents':
|
||||||
|
return documentAttachments.length > 0 ? 'done' : currentStep === 'documents' ? 'active' : 'pending';
|
||||||
|
case 'links':
|
||||||
|
return text.trim() ? 'done' : currentStep === 'links' ? 'active' : 'pending';
|
||||||
|
default:
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inferAttachmentType = (file: File): AttachmentType => {
|
||||||
|
if (file.type.startsWith('image/')) return 'image';
|
||||||
|
if (file.type.startsWith('video/')) return 'video';
|
||||||
|
if (file.type.startsWith('audio/')) return 'audio';
|
||||||
|
if (
|
||||||
|
file.type.includes('pdf') ||
|
||||||
|
file.type.includes('document') ||
|
||||||
|
file.type.includes('sheet') ||
|
||||||
|
file.type.includes('excel') ||
|
||||||
|
file.type.includes('powerpoint') ||
|
||||||
|
file.type.startsWith('text/')
|
||||||
|
) {
|
||||||
|
return 'document';
|
||||||
|
}
|
||||||
|
return 'other';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFiles = Array.from(e.target.files || []);
|
||||||
|
if (!selectedFiles.length) return;
|
||||||
|
|
||||||
|
let allowedFiles = selectedFiles;
|
||||||
|
|
||||||
|
if (currentStep === 'images') {
|
||||||
|
allowedFiles = selectedFiles.filter((file) => file.type.startsWith('image/'));
|
||||||
|
if (allowedFiles.length !== selectedFiles.length) {
|
||||||
|
setValidationMessage('En este paso solo se permiten imágenes. Los demás archivos no se agregaron.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === 'documents') {
|
||||||
|
allowedFiles = selectedFiles.filter((file) => !file.type.startsWith('image/') && !file.type.startsWith('audio/'));
|
||||||
|
if (allowedFiles.length !== selectedFiles.length) {
|
||||||
|
setValidationMessage('En este paso agrega documentos o archivos de apoyo. Las imágenes y audios se omiten aquí.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAttachments = allowedFiles.map((file) => ({
|
||||||
|
id: Math.random().toString(36).substring(7),
|
||||||
|
file,
|
||||||
|
type: inferAttachmentType(file),
|
||||||
|
url: URL.createObjectURL(file),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setAttachments((prev) => [...prev, ...newAttachments]);
|
||||||
|
if (allowedFiles.length > 0) {
|
||||||
|
setValidationMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAttachment = (id: string) => {
|
||||||
|
setAttachments((prev) => {
|
||||||
|
const filtered = prev.filter((attachment) => attachment.id !== id);
|
||||||
|
const removed = prev.find((attachment) => attachment.id === id);
|
||||||
|
if (removed) URL.revokeObjectURL(removed.url);
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRecording = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const mediaRecorder = new MediaRecorder(stream);
|
||||||
|
mediaRecorderRef.current = mediaRecorder;
|
||||||
|
audioChunksRef.current = [];
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = (e) => {
|
||||||
|
if (e.data.size > 0) audioChunksRef.current.push(e.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
||||||
|
const audioUrl = URL.createObjectURL(audioBlob);
|
||||||
|
setAudioRecording({
|
||||||
|
blob: audioBlob,
|
||||||
|
url: audioUrl,
|
||||||
|
duration: recordingDuration,
|
||||||
|
});
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.start();
|
||||||
|
setValidationMessage(null);
|
||||||
|
setIsRecording(true);
|
||||||
|
setRecordingDuration(0);
|
||||||
|
|
||||||
|
timerRef.current = window.setInterval(() => {
|
||||||
|
setRecordingDuration((prev) => prev + 1);
|
||||||
|
}, 1000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error accessing microphone:', error);
|
||||||
|
setValidationMessage('No se pudo acceder al micrófono. Revisa los permisos del navegador.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRecording = () => {
|
||||||
|
if (mediaRecorderRef.current && isRecording) {
|
||||||
|
mediaRecorderRef.current.stop();
|
||||||
|
setIsRecording(false);
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelRecording = () => {
|
||||||
|
if (audioRecording?.url) {
|
||||||
|
URL.revokeObjectURL(audioRecording.url);
|
||||||
|
}
|
||||||
|
setAudioRecording(null);
|
||||||
|
setRecordingDuration(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const advanceTo = (step: GuidedStep) => {
|
||||||
|
setCurrentStep(step);
|
||||||
|
setValidationMessage(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContinue = () => {
|
||||||
|
if (currentStep === 'audio') {
|
||||||
|
if (!audioRecording) {
|
||||||
|
setValidationMessage('Debes grabar una nota de voz para continuar.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
advanceTo('images');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === 'images') {
|
||||||
|
if (imageAttachments.length === 0) {
|
||||||
|
setValidationMessage('Debes adjuntar al menos una imagen de referencia para continuar.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
advanceTo('documents');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === 'documents') {
|
||||||
|
advanceTo('links');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSubmissionText = () => {
|
||||||
|
const linksText = text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const summaryLines = [
|
||||||
|
'Solicitud enviada desde el portal guiado del Brief CDC.',
|
||||||
|
`Nota de voz: ${audioRecording ? 'sí' : 'no'}`,
|
||||||
|
`Imágenes de referencia: ${imageAttachments.length}`,
|
||||||
|
`Documentos relacionados: ${documentAttachments.length}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (linksText) {
|
||||||
|
summaryLines.push('', 'Enlaces de referencia:', linksText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaryLines.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!requiredCompleted) {
|
||||||
|
setValidationMessage('Completa primero la nota de voz y al menos una imagen de referencia.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isProcessing) {
|
||||||
|
onSendMessage(buildSubmissionText(), attachments, audioRecording || undefined);
|
||||||
|
setText('');
|
||||||
|
setAttachments([]);
|
||||||
|
if (audioRecording?.url) {
|
||||||
|
URL.revokeObjectURL(audioRecording.url);
|
||||||
|
}
|
||||||
|
setAudioRecording(null);
|
||||||
|
setRecordingDuration(0);
|
||||||
|
setCurrentStep('audio');
|
||||||
|
setValidationMessage(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentStepConfig = STEP_DEFINITIONS.find((step) => step.id === currentStep)!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(8,12,24,0.96),rgba(10,12,20,0.86))] p-4 shadow-[0_28px_80px_rgba(0,0,0,0.38)] backdrop-blur-2xl sm:p-5 lg:p-6">
|
||||||
|
<div className="mb-5 overflow-hidden rounded-[28px] border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-indigo-400/20 bg-indigo-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-indigo-200">
|
||||||
|
<WandSparkles className="h-3.5 w-3.5" /> Flujo guiado
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-3 text-xl font-semibold tracking-tight text-white">Construye la solicitud paso por paso</h3>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">Completa primero los pasos obligatorios. Luego podrás adjuntar documentos y agregar referencias online.</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||||
|
<span className="font-semibold text-white">Progreso:</span> {requiredCompleted ? 'listo para enviar' : `${Math.round(progressValue)}% del flujo`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 h-2 overflow-hidden rounded-full bg-white/5">
|
||||||
|
<motion.div
|
||||||
|
className="h-full rounded-full bg-[linear-gradient(90deg,#6366f1_0%,#3b82f6_55%,#22d3ee_100%)]"
|
||||||
|
animate={{ width: `${progressValue}%` }}
|
||||||
|
transition={{ type: 'spring', stiffness: 80, damping: 18 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 xl:grid-cols-4">
|
||||||
|
{STEP_DEFINITIONS.map((step) => {
|
||||||
|
const status = getStepStatus(step.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={step.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => advanceTo(step.id)}
|
||||||
|
className={cn(
|
||||||
|
'group relative overflow-hidden rounded-[24px] border p-4 text-left transition-all',
|
||||||
|
status === 'done' && 'border-emerald-400/25 bg-emerald-500/10',
|
||||||
|
status === 'active' && 'border-indigo-400/30 bg-[linear-gradient(180deg,rgba(99,102,241,0.18),rgba(59,130,246,0.08))] shadow-[0_18px_40px_rgba(79,70,229,0.18)]',
|
||||||
|
status === 'pending' && 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/25'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.26em] text-zinc-400">
|
||||||
|
Paso {step.number}
|
||||||
|
</span>
|
||||||
|
{status === 'done' ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-emerald-300" />
|
||||||
|
) : status === 'active' ? (
|
||||||
|
<div className="h-3 w-3 rounded-full bg-indigo-300 shadow-[0_0_14px_rgba(129,140,248,0.7)]" />
|
||||||
|
) : (
|
||||||
|
<div className="h-3 w-3 rounded-full bg-zinc-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h4 className="text-sm font-semibold text-white">{step.title}</h4>
|
||||||
|
<p className="mt-2 text-xs leading-6 text-zinc-400">{step.hint}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-[1.45fr_0.85fr]">
|
||||||
|
<div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{(attachments.length > 0 || audioRecording) && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="mb-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
|
||||||
|
>
|
||||||
|
{audioRecording && (
|
||||||
|
<div className="relative rounded-[24px] border border-indigo-400/20 bg-[linear-gradient(180deg,rgba(99,102,241,0.2),rgba(99,102,241,0.08))] p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-black/20">
|
||||||
|
<Mic className="h-5 w-5 text-indigo-200" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white">Nota de voz lista</p>
|
||||||
|
<p className="text-xs font-mono text-indigo-200/80">0:{audioRecording.duration.toString().padStart(2, '0')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={cancelRecording}
|
||||||
|
className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full bg-black/30 text-white transition hover:bg-red-500/80"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{attachments.map((att) => (
|
||||||
|
<div key={att.id} className="relative overflow-hidden rounded-[24px] border border-white/10 bg-white/5 p-3.5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{att.type === 'image' ? (
|
||||||
|
<div className="h-16 w-16 overflow-hidden rounded-2xl border border-white/10 bg-black/20">
|
||||||
|
<img src={att.url} alt="preview" className="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-white/10 bg-black/20">
|
||||||
|
<File className="h-6 w-6 text-zinc-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-white">{att.file.name}</p>
|
||||||
|
<p className="mt-1 text-xs text-zinc-400">
|
||||||
|
{att.type === 'image' ? 'Imagen de referencia' : 'Documento / archivo de apoyo'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => removeAttachment(att.id)}
|
||||||
|
className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full bg-black/30 text-white transition hover:bg-red-500/80"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-black/20 p-4 sm:p-5">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-400/20 bg-indigo-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-indigo-200">
|
||||||
|
Paso {currentStepConfig.number}
|
||||||
|
</span>
|
||||||
|
<h3 className="text-lg font-semibold text-white">{currentStepConfig.title}</h3>
|
||||||
|
<span className="text-sm text-zinc-400">{currentStepConfig.hint}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentStep === 'audio' && (
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4">
|
||||||
|
<p className="mb-5 text-sm leading-7 text-zinc-300">
|
||||||
|
Graba una nota de voz explicando el brief. Incluye país, cliente, marca, tipo de requerimiento, objetivos, entregables, presupuesto y fechas clave.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={isRecording ? stopRecording : startRecording}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-all',
|
||||||
|
isRecording
|
||||||
|
? 'bg-red-500/20 text-red-200 hover:bg-red-500/30'
|
||||||
|
: 'bg-[linear-gradient(135deg,#6366f1_0%,#2563eb_100%)] text-white shadow-[0_18px_40px_rgba(79,70,229,0.25)] hover:brightness-110'
|
||||||
|
)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
{isRecording ? <Square className="h-4 w-4 fill-current" /> : <Mic className="h-4 w-4" />}
|
||||||
|
{isRecording ? `Detener grabación (${recordingDuration}s)` : 'Grabar nota de voz'}
|
||||||
|
</button>
|
||||||
|
{audioRecording && <span className="text-sm text-emerald-300">Nota de voz lista para enviar.</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 'images' && (
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4">
|
||||||
|
<p className="mb-5 text-sm leading-7 text-zinc-300">
|
||||||
|
Adjunta una o varias imágenes de referencia. Este paso es obligatorio para continuar.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#6366f1_0%,#2563eb_100%)] px-5 py-3.5 text-sm font-semibold text-white shadow-[0_18px_40px_rgba(79,70,229,0.25)] transition hover:brightness-110 disabled:opacity-50"
|
||||||
|
disabled={isProcessing || isRecording}
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
Agregar imágenes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 'documents' && (
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4">
|
||||||
|
<p className="mb-5 text-sm leading-7 text-zinc-300">
|
||||||
|
Adjunta documentos relacionados, referencias y archivos de apoyo. Puedes subir PDF, Excel, PowerPoint, Word y otros materiales.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-black/25 px-5 py-3.5 text-sm font-semibold text-zinc-100 transition hover:border-white/20 hover:bg-black/35 disabled:opacity-50"
|
||||||
|
disabled={isProcessing || isRecording}
|
||||||
|
>
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
Agregar documentos relacionados
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 'links' && (
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4">
|
||||||
|
<p className="mb-5 text-sm leading-7 text-zinc-300">
|
||||||
|
Pega enlaces de referencia online, uno por línea. Este paso es opcional.
|
||||||
|
</p>
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/25 px-4 py-3 transition-colors focus-within:border-indigo-400/30">
|
||||||
|
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-zinc-300">
|
||||||
|
<Link2 className="h-3.5 w-3.5 text-indigo-300" /> Enlaces de referencia
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder={'Pega aquí tus enlaces de referencia, uno por línea\nhttps://www.figma.com/...\nhttps://drive.google.com/...'}
|
||||||
|
className="h-40 max-h-40 w-full resize-none overflow-y-auto bg-transparent pr-2 text-[15px] leading-7 text-zinc-100 placeholder:text-zinc-500 outline-none scrollbar-thin"
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept={currentStep === 'images' ? 'image/*' : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{validationMessage && (
|
||||||
|
<div className="mt-4 flex items-start gap-3 rounded-2xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
|
||||||
|
<span>{validationMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="sticky bottom-0 mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-white/5 bg-[linear-gradient(180deg,rgba(8,12,24,0),rgba(8,12,24,0.96)_28%)] pt-4 pb-1">
|
||||||
|
<div className="text-sm text-zinc-400">
|
||||||
|
{requiredCompleted
|
||||||
|
? 'Los pasos obligatorios están completos. Ya puedes enviar la solicitud.'
|
||||||
|
: 'Completa primero la nota de voz y las imágenes de referencia.'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{currentStep !== 'links' ? (
|
||||||
|
<button
|
||||||
|
onClick={handleContinue}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm font-semibold text-zinc-100 transition hover:border-white/20 hover:bg-black/35 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Continuar
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={isProcessing || !requiredCompleted}
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#6366f1_0%,#2563eb_100%)] px-5 py-3 text-sm font-semibold text-white shadow-[0_18px_40px_rgba(79,70,229,0.2)] transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isProcessing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
|
Finalizar y enviar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="rounded-[28px] border border-white/10 bg-white/5 p-4 sm:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">Panel de control</p>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.22em] text-zinc-500">Obligatorios</p>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-zinc-300">Nota de voz</span>
|
||||||
|
<span className={cn('font-semibold', audioRecording ? 'text-emerald-300' : 'text-zinc-500')}>{audioRecording ? 'Lista' : 'Pendiente'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-zinc-300">Imágenes</span>
|
||||||
|
<span className={cn('font-semibold', imageAttachments.length > 0 ? 'text-emerald-300' : 'text-zinc-500')}>
|
||||||
|
{imageAttachments.length > 0 ? `${imageAttachments.length} cargada(s)` : 'Pendiente'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.22em] text-zinc-500">Opcionales</p>
|
||||||
|
<div className="mt-3 space-y-3 text-sm text-zinc-300">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Documentos</span>
|
||||||
|
<span className="font-semibold text-white">{documentAttachments.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Enlaces</span>
|
||||||
|
<span className="font-semibold text-white">{text.split('\n').map(line => line.trim()).filter(Boolean).length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-indigo-400/20 bg-[linear-gradient(180deg,rgba(99,102,241,0.16),rgba(99,102,241,0.04))] p-4">
|
||||||
|
<p className="text-sm font-semibold text-white">Cómo se procesará tu solicitud</p>
|
||||||
|
<ul className="mt-3 space-y-3 text-sm leading-6 text-zinc-300">
|
||||||
|
<li>• La voz y las imágenes alimentan la interpretación del brief.</li>
|
||||||
|
<li>• Los documentos de apoyo se guardan completos en la carpeta del proyecto.</li>
|
||||||
|
<li>• Los enlaces se agregan como referencias dentro del documento final.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { Chrome, ShieldCheck, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
interface LoginScreenProps {
|
||||||
|
onLogin: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoginScreen: React.FC<LoginScreenProps> = ({ onLogin, isLoading, error }) => {
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[#060816] px-4 text-zinc-100">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 18, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
className="relative w-full max-w-xl rounded-[32px] border border-white/10 bg-white/5 p-8 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-xl"
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
Acceso corporativo
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight text-white sm:text-4xl">
|
||||||
|
Portal Brief CDC
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-4 text-sm leading-7 text-zinc-300 sm:text-base">
|
||||||
|
Inicia sesión con tu cuenta corporativa de Google para acceder al portal.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ShieldCheck className="mt-0.5 h-5 w-5 text-indigo-300" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white">Acceso restringido</p>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-zinc-400">
|
||||||
|
Solo usuarios autorizados de la empresa podrán continuar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onLogin}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="mt-6 inline-flex w-full items-center justify-center gap-3 rounded-2xl bg-[linear-gradient(135deg,#6366f1_0%,#2563eb_50%,#06b6d4_100%)] px-5 py-4 text-sm font-semibold text-white shadow-[0_18px_40px_rgba(79,70,229,0.38)] transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Chrome className="h-4 w-4" />
|
||||||
|
{isLoading ? 'Abriendo Google...' : 'Continuar con Google'}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { AlertCircle, CheckCheck, FileText, Music, ShieldCheck } from 'lucide-react';
|
||||||
|
import { Message, Attachment } from '../types';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
|
||||||
|
interface MessageBubbleProps {
|
||||||
|
message: Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AttachmentPreview = ({ attachment }: { attachment: Attachment }) => {
|
||||||
|
if (attachment.type === 'image') {
|
||||||
|
return (
|
||||||
|
<div className="group relative max-w-[320px] overflow-hidden rounded-2xl border border-white/10 bg-black/20">
|
||||||
|
<img
|
||||||
|
src={attachment.url}
|
||||||
|
alt={attachment.file.name}
|
||||||
|
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-3 py-2 text-[11px] font-medium text-white/90">
|
||||||
|
<div className="truncate">{attachment.file.name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = attachment.type === 'audio' ? Music : FileText;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-[360px] items-center gap-3 rounded-2xl border border-white/10 bg-white/5 px-3 py-3">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-black/25">
|
||||||
|
<Icon className="h-4 w-4 text-indigo-200" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-white/90">{attachment.file.name}</p>
|
||||||
|
<p className="text-[11px] text-zinc-400">{(attachment.file.size / 1024).toFixed(1)} KB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
|
||||||
|
const isUser = message.sender === 'user';
|
||||||
|
const [isPlaying, setIsPlaying] = React.useState(false);
|
||||||
|
const audioRef = React.useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
} else {
|
||||||
|
audioRef.current.play();
|
||||||
|
}
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (message.audio) {
|
||||||
|
audioRef.current = new Audio(message.audio.url);
|
||||||
|
audioRef.current.onended = () => setIsPlaying(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [message.audio]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 14, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
transition={{ duration: 0.32, ease: [0.23, 1, 0.32, 1] }}
|
||||||
|
className={cn('mb-5 flex w-full', isUser ? 'justify-end' : 'justify-start')}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col gap-1',
|
||||||
|
isUser ? 'max-w-3xl items-end' : 'w-full max-w-none items-start'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!isUser && (
|
||||||
|
<div className="mb-1 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-zinc-400">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5 text-indigo-300" />
|
||||||
|
Respuesta del portal
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative overflow-hidden border shadow-[0_16px_40px_rgba(0,0,0,0.25)]',
|
||||||
|
isUser
|
||||||
|
? 'max-w-[85%] rounded-[26px] rounded-br-md border-indigo-400/20 bg-[linear-gradient(135deg,rgba(79,70,229,0.96),rgba(37,99,235,0.92))] px-4 py-4 text-white sm:px-5'
|
||||||
|
: 'w-full max-w-none rounded-[28px] rounded-bl-md border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.07),rgba(255,255,255,0.03))] px-5 py-5 text-zinc-100 backdrop-blur-xl sm:px-6 sm:py-6'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!isUser && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(129,140,248,0.14),transparent_26%)]" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{message.attachments && message.attachments.length > 0 && (
|
||||||
|
<div className="mb-4 flex flex-wrap gap-3">
|
||||||
|
{message.attachments.map((att) => (
|
||||||
|
<AttachmentPreview key={att.id} attachment={att} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.audio && (
|
||||||
|
<div className="mb-4 flex items-center gap-3 rounded-2xl bg-black/20 p-3 pr-4">
|
||||||
|
<button
|
||||||
|
onClick={togglePlay}
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/15 transition hover:bg-white/25"
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<div className="flex h-3 w-3 gap-1">
|
||||||
|
<div className="h-full w-1 rounded-sm bg-white" />
|
||||||
|
<div className="h-full w-1 rounded-sm bg-white" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ml-0.5 h-0 w-0 border-b-[6px] border-l-[9px] border-t-[6px] border-b-transparent border-l-white border-t-transparent" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/15">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full w-1/3 rounded-full bg-white/65',
|
||||||
|
isPlaying && 'animate-pulse'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-xs font-mono opacity-80">
|
||||||
|
0:{message.audio.duration.toString().padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.text && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'break-words text-[15px] leading-7 sm:text-base',
|
||||||
|
isUser ? 'whitespace-pre-wrap' : ''
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isUser ? (
|
||||||
|
message.text
|
||||||
|
) : (
|
||||||
|
<div className="prose prose-invert max-w-none prose-p:my-3 prose-p:leading-8 prose-headings:mb-3 prose-headings:mt-1 prose-headings:text-white prose-strong:text-white prose-li:my-1 prose-ul:my-3 prose-ol:my-3 prose-pre:overflow-x-auto prose-pre:rounded-2xl prose-pre:border prose-pre:border-white/10 prose-pre:bg-black/35 prose-pre:p-4 prose-pre:text-[13px] prose-code:text-indigo-200">
|
||||||
|
<ReactMarkdown>{message.text}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 px-1">
|
||||||
|
<span className="text-[11px] font-medium text-zinc-500">
|
||||||
|
{format(message.timestamp, 'HH:mm')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{isUser && (
|
||||||
|
<span className="text-zinc-500">
|
||||||
|
{message.status === 'sending' && (
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 animate-pulse text-zinc-400" />
|
||||||
|
)}
|
||||||
|
{message.status === 'sent' && (
|
||||||
|
<CheckCheck className="h-3.5 w-3.5 text-indigo-400" />
|
||||||
|
)}
|
||||||
|
{message.status === 'error' && (
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 text-red-500" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
import { X, Save, Link as LinkIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SettingsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
webhookUrl: string;
|
||||||
|
onSave: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, webhookUrl, onSave }) => {
|
||||||
|
const [url, setUrl] = useState(webhookUrl);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setUrl(webhookUrl);
|
||||||
|
}
|
||||||
|
}, [isOpen, webhookUrl]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave(url);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md bg-zinc-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-white/5">
|
||||||
|
<h2 className="text-xl font-semibold text-white">Configuración de conexión</h2>
|
||||||
|
<button onClick={onClose} className="p-2 rounded-full hover:bg-white/5 text-zinc-400 hover:text-white transition-colors">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-zinc-300 flex items-center gap-2">
|
||||||
|
<LinkIcon className="w-4 h-4 text-indigo-400" />
|
||||||
|
Webhook URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://tu-endpoint.com/webhook"
|
||||||
|
className="w-full bg-black/50 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-zinc-600 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-zinc-500 mt-2">
|
||||||
|
Endpoint donde se enviarán la nota de voz, imágenes, documentos y enlaces mediante una solicitud POST.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 border-t border-white/5 bg-black/20 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-5 py-2.5 rounded-xl text-sm font-medium text-zinc-300 hover:text-white hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="px-5 py-2.5 rounded-xl text-sm font-medium bg-indigo-600 text-white hover:bg-indigo-500 transition-colors flex items-center gap-2 shadow-lg shadow-indigo-500/20"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-height: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #060816;
|
||||||
|
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { initializeApp } from 'firebase/app';
|
||||||
|
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
|
||||||
|
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
|
||||||
|
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
|
||||||
|
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
|
||||||
|
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
|
||||||
|
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
|
||||||
|
appId: import.meta.env.VITE_FIREBASE_APP_ID,
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = initializeApp(firebaseConfig);
|
||||||
|
|
||||||
|
export const auth = getAuth(app);
|
||||||
|
|
||||||
|
export const googleProvider = new GoogleAuthProvider();
|
||||||
|
googleProvider.setCustomParameters({
|
||||||
|
prompt: 'select_account',
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import {StrictMode} from 'react';
|
||||||
|
import {createRoot} from 'react-dom/client';
|
||||||
|
import App from './App.tsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export type AttachmentType = 'image' | 'video' | 'audio' | 'document' | 'other';
|
||||||
|
|
||||||
|
export interface Attachment {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
type: AttachmentType;
|
||||||
|
url: string; // Object URL for preview
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioRecording {
|
||||||
|
blob: Blob;
|
||||||
|
url: string;
|
||||||
|
duration: number; // in seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
sender: 'user' | 'system';
|
||||||
|
text: string;
|
||||||
|
attachments?: Attachment[];
|
||||||
|
audio?: AudioRecording;
|
||||||
|
timestamp: Date;
|
||||||
|
status: 'sending' | 'sent' | 'error';
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowJs": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
import {defineConfig, loadEnv} from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig(({mode}) => {
|
||||||
|
const env = loadEnv(mode, '.', '');
|
||||||
|
return {
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
base: '/brief/',
|
||||||
|
define: {
|
||||||
|
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||||
|
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||||
|
hmr: process.env.DISABLE_HMR !== 'true',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user