Initial commit: Portal Brief Inteligente CDC

This commit is contained in:
Isaac Aracena
2026-05-06 17:55:54 -04:00
commit f1284fc405
20 changed files with 6868 additions and 0 deletions
+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?
+20
View File
@@ -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
View File
@@ -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>
+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"
}
}
+365
View File
@@ -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>
);
}
+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>
);
};
+607
View File
@@ -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>
);
};
+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>
);
};
+187
View File
@@ -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>
);
};
+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>
);
};
+52
View File
@@ -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);
}
}
+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>,
);
+24
View File
@@ -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';
}
+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',
},
};
});