Final version frontend: favicon, audio player, dist included

This commit is contained in:
Isaac Aracena
2026-05-23 08:45:25 -04:00
commit d7094c94cf
26 changed files with 9120 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Firebase Configuration
VITE_FIREBASE_API_KEY=your_api_key_here
VITE_FIREBASE_AUTH_DOMAIN=your_project_id.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=your_project_id
VITE_FIREBASE_STORAGE_BUCKET=your_project_id.appspot.com
VITE_FIREBASE_MESSAGING_SENDER_ID=your_sender_id
VITE_FIREBASE_APP_ID=your_app_id
# n8n Webhook Configuration
VITE_WEBHOOK_URL=https://your-n8n-instance.com/webhook/path
+44
View File
@@ -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?
+56
View File
@@ -0,0 +1,56 @@
# Portal Brief Inteligente CDC
Sistema generador de briefs inteligentes basado en IA para el análisis de activos multimedia y documentos.
## Descripción
Este proyecto es una interfaz frontend moderna y profesional diseñada para automatizar la creación de briefs. El sistema guía al usuario a través de un proceso estructurado que incluye la carga de notas de voz, imágenes de referencia, documentos técnicos y enlaces externos.
Utiliza un flujo de trabajo de **n8n** en el backend para procesar el audio (transcripción y análisis), analizar imágenes y consolidar toda la información en un brief estructurado en cuestión de segundos.
## Características Principales
- **Análisis de Audio**: Transcripción y extracción de puntos clave mediante IA.
- **Análisis de Imágenes**: Procesamiento visual para referencias de diseño.
- **Gestión de Documentos**: Soporte para PDF, Excel, PowerPoint y Word.
- **Flujo Guiado**: Interfaz por pasos para garantizar la calidad de la información.
- **Autenticación Corporativa**: Acceso restringido vía Google Auth para dominios autorizados.
## Requisitos
- Node.js (v18 o superior recomendado)
- npm o yarn
- Instancia de n8n con el workflow de procesamiento de briefs configurado
- Proyecto en Firebase para la autenticación
## Cómo instalar
1. Clonar el repositorio.
2. Ejecutar la instalación de dependencias:
```bash
npm install
```
## Cómo correr en local
1. Asegúrate de tener el archivo `.env` configurado (puedes basarte en `.env.example`).
2. Inicia el servidor de desarrollo:
```bash
npm run dev
```
3. Abre [http://localhost:3000](http://localhost:3000) en tu navegador.
## Cómo generar build
Para generar la versión de producción:
```bash
npm run build
```
Los archivos optimizados se generarán en la carpeta `dist/`.
## Variables de Entorno Requeridas
El proyecto requiere las siguientes variables en un archivo `.env`:
- `VITE_FIREBASE_API_KEY`: API Key de Firebase.
- `VITE_FIREBASE_AUTH_DOMAIN`: Dominio de autenticación.
- `VITE_FIREBASE_PROJECT_ID`: ID del proyecto Firebase.
- `VITE_FIREBASE_STORAGE_BUCKET`: Bucket de almacenamiento.
- `VITE_FIREBASE_MESSAGING_SENDER_ID`: ID del remitente de mensajería.
- `VITE_FIREBASE_APP_ID`: ID de la aplicación Firebase.
- `VITE_WEBHOOK_URL`: URL del webhook de n8n para procesar el brief.
## Nota de Seguridad
**IMPORTANTE:** Nunca subas el archivo `.env` o cualquier archivo con credenciales reales al repositorio. El archivo `.gitignore` está configurado para excluir archivos sensibles. Solo comparte `.env.example` con los nombres de las variables necesarias.
File diff suppressed because one or more lines are too long
+1613
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<defs>
<linearGradient id="g" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#6D7CFF"/>
<stop offset="1" stop-color="#2FE6FF"/>
</linearGradient>
</defs>
<rect x="6" y="6" width="52" height="52" rx="16" fill="url(#g)"/>
<path d="M34.6 15.5L20.8 34.5H30l-0.9 14 14.1-19.2h-9.1l0.5-13.8Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 454 B

+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<link rel="icon" type="image/svg+xml" href="/brief/favicon.svg" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CDC Brief GLM</title>
<script type="module" crossorigin src="/brief/assets/index-C_JoOsGT.js"></script>
<link rel="stylesheet" crossorigin href="/brief/assets/index-C16767E5.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CDC Brief GLM</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+7
View File
@@ -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"
]
}
+5204
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -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"
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<defs>
<linearGradient id="g" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#6D7CFF"/>
<stop offset="1" stop-color="#2FE6FF"/>
</linearGradient>
</defs>
<rect x="6" y="6" width="52" height="52" rx="16" fill="url(#g)"/>
<path d="M34.6 15.5L20.8 34.5H30l-0.9 14 14.1-19.2h-9.1l0.5-13.8Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 454 B

+352
View File
@@ -0,0 +1,352 @@
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';
const webhookUrl = import.meta.env.VITE_WEBHOOK_URL || '';
export default function App() {
const [messages, setMessages] = useState<Message[]>([]);
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', block: 'end' });
};
useEffect(() => {
scrollToBottom();
}, [messages, isProcessing]);
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 {
if (!webhookUrl) {
throw new Error('No hay webhook configurado en VITE_WEBHOOK_URL.');
}
const submittedAt = new Date().toISOString();
const submittedByName = user?.displayName || user?.email?.split('@')[0] || 'No especificado';
const submittedByEmail = user?.email || 'No especificado';
const formData = new FormData();
formData.append('text', text);
formData.append('timestamp', submittedAt);
formData.append('submittedAt', submittedAt);
formData.append('submittedByName', submittedByName);
formData.append('submittedByEmail', submittedByEmail);
formData.append('submittedByUid', user?.uid || '');
formData.append('submittedByPhotoUrl', user?.photoURL || '');
let generalFileIndex = 0;
let campaignFileIndex = 0;
attachments.forEach((att) => {
if (att.category === 'campaign') {
formData.append(`campaign_file_${campaignFileIndex}`, att.file);
formData.append(`campaign_file_${campaignFileIndex}_name`, att.file.name);
campaignFileIndex += 1;
return;
}
formData.append(`file_${generalFileIndex}`, att.file);
formData.append(`file_${generalFileIndex}_name`, att.file.name);
generalFileIndex += 1;
});
formData.append('generalFilesCount', String(generalFileIndex));
formData.append('campaignFilesCount', String(campaignFileIndex));
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 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, piezas gráficas 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 sí se analizan automáticamente.',
'Word, Excel, PowerPoint 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="min-h-[220px] rounded-[24px] border border-white/10 bg-white/[0.03] p-4 sm:p-5 lg:p-6">
{messages.length === 0 && !isProcessing && (
<div className="flex min-h-[150px] items-center justify-center rounded-[22px] border border-dashed border-white/10 bg-black/10 px-5 text-center text-sm leading-6 text-zinc-500">
Cuando envíes la solicitud, aquí aparecerá la respuesta del flujo y el enlace de Drive.
</div>
)}
<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>
);
}
+64
View File
@@ -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>
);
};
+912
View File
@@ -0,0 +1,912 @@
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,
Pause,
Play,
Send,
Square,
WandSparkles,
X,
} from 'lucide-react';
import { Attachment, AttachmentCategory, 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' | 'campaign' | 'links' | 'review';
type CampaignAnswer = 'yes' | 'no' | null;
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: 'campaign',
number: 4,
title: 'Campaña o elementos gráficos',
hint: 'Opcional. Si ya existen piezas gráficas o logos, adjúntalos aquí.',
required: false,
},
{
id: 'links',
number: 5,
title: 'Enlaces de referencia online',
hint: 'Opcional. Pega enlaces, uno por línea.',
required: false,
},
{
id: 'review',
number: 6,
title: 'Revisión y envío',
hint: 'Revisa la solicitud y envíala solo desde este paso final.',
required: true,
},
];
const formatDuration = (seconds: number) => {
const safeSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
const minutes = Math.floor(safeSeconds / 60);
const remainingSeconds = safeSeconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
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 [hasCampaignAssets, setHasCampaignAssets] = useState<CampaignAnswer>(null);
const [validationMessage, setValidationMessage] = useState<string | null>(null);
const [audioPreviewPlaying, setAudioPreviewPlaying] = useState(false);
const [audioPreviewCurrentTime, setAudioPreviewCurrentTime] = useState(0);
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 recordingDurationRef = useRef(0);
const attachmentsRef = useRef<Attachment[]>([]);
const audioRecordingRef = useRef<AudioRecording | null>(null);
const audioPreviewRef = useRef<HTMLAudioElement | null>(null);
const imageAttachments = useMemo(
() => attachments.filter((att) => att.type === 'image' && att.category !== 'campaign'),
[attachments],
);
const documentAttachments = useMemo(
() => attachments.filter((att) => att.category !== 'campaign' && att.type !== 'image'),
[attachments],
);
const campaignAttachments = useMemo(
() => attachments.filter((att) => att.category === 'campaign'),
[attachments],
);
const linksCount = useMemo(
() => text.split('\n').map((line) => line.trim()).filter(Boolean).length,
[text],
);
const requiredCompleted = Boolean(audioRecording) && imageAttachments.length > 0;
const campaignStepCompleted = hasCampaignAssets !== 'yes' || campaignAttachments.length > 0;
const canSend = requiredCompleted && campaignStepCompleted;
const progressValue = ((STEP_DEFINITIONS.findIndex((step) => step.id === currentStep) + 1) / STEP_DEFINITIONS.length) * 100;
useEffect(() => {
attachmentsRef.current = attachments;
}, [attachments]);
useEffect(() => {
audioRecordingRef.current = audioRecording;
}, [audioRecording]);
useEffect(() => {
setAudioPreviewPlaying(false);
setAudioPreviewCurrentTime(0);
if (audioPreviewRef.current) {
audioPreviewRef.current.pause();
audioPreviewRef.current.currentTime = 0;
}
}, [audioRecording?.url]);
const toggleAudioPreview = async () => {
if (!audioPreviewRef.current || !audioRecording) return;
if (audioPreviewPlaying) {
audioPreviewRef.current.pause();
setAudioPreviewPlaying(false);
setAudioPreviewCurrentTime(Math.floor(audioPreviewRef.current.currentTime || 0));
return;
}
try {
await audioPreviewRef.current.play();
setAudioPreviewPlaying(true);
} catch (error) {
console.error('No se pudo reproducir la nota de voz:', error);
setAudioPreviewPlaying(false);
setValidationMessage('No se pudo reproducir la nota de voz. Intenta grabarla nuevamente.');
}
};
useEffect(() => {
return () => {
attachmentsRef.current.forEach((attachment) => URL.revokeObjectURL(attachment.url));
if (audioRecordingRef.current?.url) {
URL.revokeObjectURL(audioRecordingRef.current.url);
}
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, []);
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 'campaign':
if (hasCampaignAssets === 'no' || (hasCampaignAssets === 'yes' && campaignAttachments.length > 0)) return 'done';
return currentStep === 'campaign' ? 'active' : 'pending';
case 'links':
return linksCount > 0 ? 'done' : currentStep === 'links' ? 'active' : 'pending';
case 'review':
return currentStep === 'review' ? 'active' : canSend ? 'done' : '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 getCurrentAttachmentCategory = (): AttachmentCategory => (
currentStep === 'campaign' ? 'campaign' : 'general'
);
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í.');
}
}
if (currentStep === 'campaign') {
allowedFiles = selectedFiles.filter((file) => !file.type.startsWith('audio/'));
if (allowedFiles.length !== selectedFiles.length) {
setValidationMessage('En este paso adjunta piezas, documentos o referencias gráficas. Los audios se omiten aquí.');
}
}
const category = getCurrentAttachmentCategory();
const newAttachments: Attachment[] = allowedFiles.map((file) => ({
id: Math.random().toString(36).substring(7),
file,
type: inferAttachmentType(file),
url: URL.createObjectURL(file),
category,
}));
setAttachments((prev) => [...prev, ...newAttachments]);
if (allowedFiles.length > 0) {
setValidationMessage(null);
}
if (fileInputRef.current) fileInputRef.current.value = '';
};
const removeAttachment = (id: string) => {
setAttachments((prev) => {
const removed = prev.find((attachment) => attachment.id === id);
if (removed) URL.revokeObjectURL(removed.url);
return prev.filter((attachment) => attachment.id !== id);
});
};
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: recordingDurationRef.current,
});
stream.getTracks().forEach((track) => track.stop());
};
mediaRecorder.start();
setValidationMessage(null);
setIsRecording(true);
recordingDurationRef.current = 0;
setRecordingDuration(0);
timerRef.current = window.setInterval(() => {
setRecordingDuration((prev) => {
const nextDuration = prev + 1;
recordingDurationRef.current = nextDuration;
return nextDuration;
});
}, 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 (audioPreviewRef.current) {
audioPreviewRef.current.pause();
audioPreviewRef.current.currentTime = 0;
}
setAudioPreviewPlaying(false);
setAudioPreviewCurrentTime(0);
if (audioRecording?.url) {
URL.revokeObjectURL(audioRecording.url);
}
setAudioRecording(null);
recordingDurationRef.current = 0;
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('campaign');
return;
}
if (currentStep === 'campaign') {
if (hasCampaignAssets === 'yes' && campaignAttachments.length === 0) {
setValidationMessage('Seleccionaste que sí existen elementos gráficos. Adjunta al menos un archivo para continuar.');
return;
}
advanceTo('links');
return;
}
if (currentStep === 'links') {
advanceTo('review');
}
};
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}`,
`Campaña o elementos gráficos existentes: ${hasCampaignAssets === 'yes' ? 'sí' : hasCampaignAssets === 'no' ? 'no' : 'no indicado'}`,
`Archivos de campaña / elementos gráficos: ${campaignAttachments.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 (!campaignStepCompleted) {
setValidationMessage('Seleccionaste que sí existen elementos gráficos. Adjunta al menos un archivo antes de enviar.');
return;
}
if (currentStep !== 'review') {
setValidationMessage('El envío solo está disponible en el paso final de revisión.');
advanceTo('review');
return;
}
if (!isProcessing) {
onSendMessage(buildSubmissionText(), attachments, audioRecording || undefined);
setText('');
setAttachments([]);
if (audioPreviewRef.current) {
audioPreviewRef.current.pause();
audioPreviewRef.current.currentTime = 0;
}
setAudioPreviewPlaying(false);
setAudioPreviewCurrentTime(0);
setAudioRecording(null);
recordingDurationRef.current = 0;
setRecordingDuration(0);
setHasCampaignAssets(null);
setCurrentStep('audio');
setValidationMessage(null);
}
};
const currentStepConfig = STEP_DEFINITIONS.find((step) => step.id === currentStep)!;
const audioPreviewDuration = audioRecording?.duration || 0;
const audioPreviewProgress = audioPreviewDuration
? Math.min(100, Math.max(0, (audioPreviewCurrentTime / audioPreviewDuration) * 100))
: 0;
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, piezas gráficas y 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> {canSend ? '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 sm:grid-cols-2 xl:grid-cols-6">
{STEP_DEFINITIONS.map((step) => {
const status = getStepStatus(step.id);
return (
<button
key={step.id}
type="button"
onClick={() => advanceTo(step.id)}
className={cn(
'group relative flex min-h-[178px] flex-col justify-between 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="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>
<div className="pt-5">
<h4 className="text-sm font-semibold leading-5 text-white">{step.title}</h4>
<p className="mt-2 text-xs leading-6 text-zinc-400">{step.hint}</p>
</div>
</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 overflow-hidden rounded-[24px] border border-indigo-400/25 bg-[linear-gradient(145deg,rgba(99,102,241,0.24),rgba(15,23,42,0.78))] p-4 shadow-[0_18px_45px_rgba(79,70,229,0.14)] sm:col-span-2 xl:col-span-3">
<div className="pointer-events-none absolute -right-10 -top-10 h-28 w-28 rounded-full bg-indigo-400/20 blur-2xl" />
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3 pr-10">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-black/25">
<Mic className="h-6 w-6 text-indigo-100" />
</div>
<div className="min-w-0">
<p className="text-base font-semibold leading-6 text-white">Nota de voz</p>
<p className="mt-0.5 text-sm leading-5 text-indigo-100/80">Lista para escuchar · Duración: {formatDuration(audioRecording.duration)}</p>
</div>
</div>
<div className="relative w-full rounded-2xl border border-white/10 bg-black/25 p-3 sm:max-w-[520px]">
<div className="flex items-center gap-3">
<button
type="button"
onClick={toggleAudioPreview}
className="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-white text-slate-950 shadow-[0_12px_28px_rgba(0,0,0,0.26)] transition hover:scale-105 hover:bg-zinc-200"
aria-label={audioPreviewPlaying ? 'Pausar nota de voz' : 'Reproducir nota de voz'}
>
{audioPreviewPlaying ? <Pause className="h-5 w-5 fill-current" /> : <Play className="ml-0.5 h-5 w-5 fill-current" />}
</button>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
<p className="text-sm font-semibold leading-5 text-white">
{audioPreviewPlaying ? 'Reproduciendo' : 'Reproducir'}
</p>
<p className="font-mono text-xs leading-5 text-indigo-100/80">
{formatDuration(audioPreviewCurrentTime)} / {formatDuration(audioPreviewDuration)}
</p>
</div>
<input
type="range"
min="0"
max={Math.max(1, audioPreviewDuration)}
step="1"
value={Math.min(audioPreviewCurrentTime, audioPreviewDuration || 0)}
onChange={(event) => {
const nextTime = Number(event.target.value);
setAudioPreviewCurrentTime(nextTime);
if (audioPreviewRef.current) {
audioPreviewRef.current.currentTime = nextTime;
}
}}
className="audio-preview-range mt-3 w-full cursor-pointer"
aria-label="Adelantar o atrasar la nota de voz"
/>
</div>
<audio
ref={audioPreviewRef}
src={audioRecording.url}
preload="metadata"
onTimeUpdate={(event) => setAudioPreviewCurrentTime(Math.floor(event.currentTarget.currentTime || 0))}
onEnded={() => {
setAudioPreviewPlaying(false);
setAudioPreviewCurrentTime(0);
if (audioPreviewRef.current) audioPreviewRef.current.currentTime = 0;
}}
onPause={(event) => {
setAudioPreviewPlaying(false);
setAudioPreviewCurrentTime(Math.floor(event.currentTarget.currentTime || 0));
}}
onPlay={() => setAudioPreviewPlaying(true)}
className="hidden"
/>
</div>
</div>
</div>
<button
onClick={cancelRecording}
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-black/35 text-white transition hover:bg-red-500/80"
aria-label="Eliminar nota de voz"
>
<X className="h-4 w-4" />
</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 pr-7">
<p className="truncate text-sm font-medium text-white">{att.file.name}</p>
<p className="mt-1 text-xs text-zinc-400">
{att.category === 'campaign'
? 'Campaña / elemento gráfico'
: 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"
aria-label="Eliminar archivo"
>
<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 === 'campaign' && (
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4">
<p className="mb-5 text-sm leading-7 text-zinc-300">
¿Ya existe campaña o elementos gráficos para esta solicitud? Si existen piezas, artes, logos, lineamientos o referencias aprobadas, adjúntalas aquí para evitar retrabajo.
</p>
<div className="mb-5 flex flex-wrap gap-3">
<button
type="button"
onClick={() => {
setHasCampaignAssets('yes');
setValidationMessage(null);
}}
className={cn(
'rounded-2xl border px-5 py-3 text-sm font-semibold transition',
hasCampaignAssets === 'yes'
? 'border-indigo-300/40 bg-indigo-500/20 text-white'
: 'border-white/10 bg-black/25 text-zinc-200 hover:border-white/20 hover:bg-black/35'
)}
disabled={isProcessing || isRecording}
>
, adjuntar elementos
</button>
<button
type="button"
onClick={() => {
setHasCampaignAssets('no');
setValidationMessage(null);
}}
className={cn(
'rounded-2xl border px-5 py-3 text-sm font-semibold transition',
hasCampaignAssets === 'no'
? 'border-emerald-300/40 bg-emerald-500/15 text-white'
: 'border-white/10 bg-black/25 text-zinc-200 hover:border-white/20 hover:bg-black/35'
)}
disabled={isProcessing || isRecording}
>
No, continuar sin piezas
</button>
</div>
{hasCampaignAssets === 'yes' && (
<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" />
Adjuntar campaña o elementos gráficos
</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>
)}
{currentStep === 'review' && (
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4">
<p className="mb-5 text-sm leading-7 text-zinc-300">
Revisa el resumen antes de enviar. Este es el único paso donde se puede finalizar la solicitud.
</p>
<div className="grid gap-3 sm:grid-cols-2">
<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-2 text-sm text-zinc-300">
<p>Nota de voz: <span className={audioRecording ? 'text-emerald-300' : 'text-amber-300'}>{audioRecording ? 'lista' : 'pendiente'}</span></p>
<p>Imágenes: <span className={imageAttachments.length ? 'text-emerald-300' : 'text-amber-300'}>{imageAttachments.length}</span></p>
</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">Soporte</p>
<div className="mt-3 space-y-2 text-sm text-zinc-300">
<p>Documentos: <span className="text-white">{documentAttachments.length}</span></p>
<p>Campaña / gráficos: <span className="text-white">{hasCampaignAssets === 'yes' ? `${campaignAttachments.length} archivo(s)` : hasCampaignAssets === 'no' ? 'no aplica' : 'no indicado'}</span></p>
<p>Enlaces: <span className="text-white">{linksCount}</span></p>
</div>
</div>
</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">
{canSend
? 'Todo está listo. El envío solo se realiza desde el paso final.'
: 'Completa primero la nota de voz y las imágenes. Si marcaste campaña, adjunta sus archivos.'}
</div>
<div className="flex flex-wrap items-center gap-2">
{currentStep !== 'review' ? (
<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}
{currentStep === 'review' && (
<button
onClick={handleSend}
disabled={isProcessing || !canSend}
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>Campaña / gráficos</span>
<span className="font-semibold text-white">{hasCampaignAssets === 'yes' ? campaignAttachments.length : hasCampaignAssets === 'no' ? 'No aplica' : 'No indicado'}</span>
</div>
<div className="flex items-center justify-between">
<span>Enlaces</span>
<span className="font-semibold text-white">{linksCount}</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, las imágenes y los PDFs 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>
);
};
+68
View File
@@ -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>
);
};
+363
View File
@@ -0,0 +1,363 @@
import React from 'react';
import { motion } from 'motion/react';
import { format } from 'date-fns';
import { AlertCircle, Check, CheckCheck, Copy, ExternalLink, FileText, Music, ShieldCheck, X } from 'lucide-react';
import { Message, Attachment } from '../types';
import { cn } from '../lib/utils';
import ReactMarkdown from 'react-markdown';
interface MessageBubbleProps {
message: Message;
}
const DRIVE_FOLDER_REGEX = /https:\/\/drive\.google\.com\/(?:drive\/u\/\d+\/folders|drive\/folders)\/[^\s)\]}"']+/i;
const getDriveFolderLink = (text: string) => {
const match = text.match(DRIVE_FOLDER_REGEX);
return match?.[0] || null;
};
const formatDuration = (seconds: number) => {
const safeSeconds = Number.isFinite(seconds) ? Math.max(0, Math.floor(seconds)) : 0;
const minutes = Math.floor(safeSeconds / 60);
const remainingSeconds = safeSeconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
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>
);
}
if (attachment.type === 'audio') {
return (
<div className="flex w-full max-w-[420px] flex-col gap-3 rounded-2xl border border-white/10 bg-white/5 px-3 py-3">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-black/25">
<Music 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>
<audio controls src={attachment.url} className="h-9 w-full rounded-xl" />
</div>
);
}
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">
<FileText 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>
);
};
const DriveLinkCard = ({ url, onClose }: { url: string; onClose: () => void }) => {
const [copied, setCopied] = React.useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch (error) {
console.error('No se pudo copiar el enlace de Drive:', error);
}
};
return (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.98 }}
className="mb-5 overflow-hidden rounded-[28px] border border-indigo-300/25 bg-[linear-gradient(135deg,rgba(99,102,241,0.22),rgba(34,211,238,0.12))] p-4 shadow-[0_22px_60px_rgba(79,70,229,0.22)] sm:p-5"
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-indigo-100/80">
Carpeta generada
</p>
<h4 className="mt-2 text-lg font-semibold text-white">Enlace de Drive listo</h4>
<p className="mt-1 text-sm leading-6 text-zinc-300">
Copia o abre la carpeta con el contenido del brief.
</p>
</div>
<button
type="button"
onClick={onClose}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-zinc-200 transition hover:bg-white/20 hover:text-white"
aria-label="Cerrar tarjeta del enlace"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-4 flex flex-col gap-3 rounded-2xl border border-white/10 bg-black/25 p-3 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1 rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-zinc-200">
<p className="truncate font-mono">{url}</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-2 rounded-xl bg-white px-4 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-zinc-200"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
{copied ? 'Copiado' : 'Copiar'}
</button>
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/10 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/15"
>
<ExternalLink className="h-4 w-4" />
Abrir
</a>
</div>
</div>
</motion.div>
);
};
export const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
const isUser = message.sender === 'user';
const [isPlaying, setIsPlaying] = React.useState(false);
const [audioCurrentTime, setAudioCurrentTime] = React.useState(0);
const [audioDuration, setAudioDuration] = React.useState(message.audio?.duration || 0);
const [showDriveCard, setShowDriveCard] = React.useState(true);
const audioRef = React.useRef<HTMLAudioElement | null>(null);
const driveFolderLink = !isUser ? getDriveFolderLink(message.text) : null;
const effectiveAudioDuration = Math.max(0, audioDuration || message.audio?.duration || 0);
const audioProgress = effectiveAudioDuration
? Math.min(100, Math.max(0, (audioCurrentTime / effectiveAudioDuration) * 100))
: 0;
const togglePlay = async () => {
if (!audioRef.current) return;
try {
if (isPlaying) {
audioRef.current.pause();
} else {
await audioRef.current.play();
}
} catch (error) {
console.error('No se pudo reproducir la nota de voz:', error);
setIsPlaying(false);
}
};
const handleAudioSeek = (event: React.ChangeEvent<HTMLInputElement>) => {
const nextTime = Number(event.target.value);
setAudioCurrentTime(nextTime);
if (audioRef.current) {
audioRef.current.currentTime = nextTime;
}
};
React.useEffect(() => {
setShowDriveCard(true);
}, [message.id, message.text]);
React.useEffect(() => {
setIsPlaying(false);
setAudioCurrentTime(0);
setAudioDuration(message.audio?.duration || 0);
if (message.audio) {
const audio = new Audio(message.audio.url);
audio.preload = 'metadata';
const updateCurrentTime = () => {
setAudioCurrentTime(audio.currentTime || 0);
};
audio.onloadedmetadata = () => {
const loadedDuration = Number.isFinite(audio.duration) ? audio.duration : 0;
setAudioDuration(loadedDuration || message.audio?.duration || 0);
};
audio.ontimeupdate = updateCurrentTime;
audio.onseeking = updateCurrentTime;
audio.onseeked = updateCurrentTime;
audio.onplay = () => setIsPlaying(true);
audio.onpause = () => {
setIsPlaying(false);
updateCurrentTime();
};
audio.onended = () => {
setIsPlaying(false);
audio.currentTime = 0;
setAudioCurrentTime(0);
};
audioRef.current = audio;
}
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
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">
{driveFolderLink && showDriveCard && (
<DriveLinkCard url={driveFolderLink} onClose={() => setShowDriveCard(false)} />
)}
{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 rounded-2xl bg-black/20 p-3 pr-4">
<div className="flex items-center gap-3">
<button
type="button"
onClick={togglePlay}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-white/15 transition hover:bg-white/25"
aria-label={isPlaying ? 'Pausar audio' : 'Reproducir audio'}
>
{isPlaying ? (
<div className="flex h-3.5 w-3.5 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-[7px] border-l-[10px] border-t-[7px] border-b-transparent border-l-white border-t-transparent" />
)}
</button>
<div className="min-w-0 flex-1">
<div className="mb-1.5 flex items-center justify-between gap-3">
<span className="text-xs font-semibold text-white/80">
{isPlaying ? 'Reproduciendo' : 'Nota de voz'}
</span>
<span className="shrink-0 text-xs font-mono opacity-80">
{formatDuration(audioCurrentTime)} / {formatDuration(effectiveAudioDuration)}
</span>
</div>
<div className="relative h-5">
<div className="absolute left-0 right-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-white/15" />
<div
className="absolute left-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-white/70"
style={{ width: `${audioProgress}%` }}
/>
<input
type="range"
min="0"
max={Math.max(1, effectiveAudioDuration)}
step="0.01"
value={Math.min(audioCurrentTime, effectiveAudioDuration || 0)}
onChange={handleAudioSeek}
className="message-audio-range absolute inset-0 w-full cursor-pointer"
aria-label="Adelantar o atrasar la nota de voz"
/>
</div>
</div>
</div>
</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>
);
};
+89
View File
@@ -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>
);
};
+138
View File
@@ -0,0 +1,138 @@
@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);
}
}
@layer components {
.audio-preview-range {
height: 20px;
appearance: none;
-webkit-appearance: none;
background: transparent;
}
.audio-preview-range::-webkit-slider-runnable-track {
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
}
.audio-preview-range::-webkit-slider-thumb {
width: 16px;
height: 16px;
margin-top: -5px;
border: 3px solid #ffffff;
border-radius: 999px;
appearance: none;
-webkit-appearance: none;
background: #67e8f9;
box-shadow: 0 0 0 5px rgba(103, 232, 249, 0.16), 0 8px 18px rgba(0, 0, 0, 0.32);
}
.audio-preview-range::-moz-range-track {
height: 6px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
}
.audio-preview-range::-moz-range-thumb {
width: 16px;
height: 16px;
border: 3px solid #ffffff;
border-radius: 999px;
background: #67e8f9;
box-shadow: 0 0 0 5px rgba(103, 232, 249, 0.16), 0 8px 18px rgba(0, 0, 0, 0.32);
}
}
@layer components {
.message-audio-range {
height: 20px;
appearance: none;
-webkit-appearance: none;
background: transparent;
}
.message-audio-range::-webkit-slider-runnable-track {
height: 6px;
border-radius: 999px;
background: transparent;
}
.message-audio-range::-webkit-slider-thumb {
width: 16px;
height: 16px;
margin-top: -5px;
border: 3px solid #ffffff;
border-radius: 999px;
appearance: none;
-webkit-appearance: none;
background: #67e8f9;
box-shadow: 0 0 0 5px rgba(103, 232, 249, 0.16), 0 8px 18px rgba(0, 0, 0, 0.32);
}
.message-audio-range::-moz-range-track {
height: 6px;
border: 0;
border-radius: 999px;
background: transparent;
}
.message-audio-range::-moz-range-thumb {
width: 16px;
height: 16px;
border: 3px solid #ffffff;
border-radius: 999px;
background: #67e8f9;
box-shadow: 0 0 0 5px rgba(103, 232, 249, 0.16), 0 8px 18px rgba(0, 0, 0, 0.32);
}
}
+20
View File
@@ -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',
});
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+10
View File
@@ -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>,
);
+26
View File
@@ -0,0 +1,26 @@
export type AttachmentType = 'image' | 'video' | 'audio' | 'document' | 'other';
export type AttachmentCategory = 'general' | 'campaign';
export interface Attachment {
id: string;
file: File;
type: AttachmentType;
url: string; // Object URL for preview
category?: AttachmentCategory;
}
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';
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+26
View File
@@ -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
}
}
+25
View File
@@ -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',
},
};
});