Compare commits

..

2 Commits

Author SHA1 Message Date
Isaac_Aracena 6f0fd4848a Eliminar README.md 2026-05-06 21:56:27 +00:00
Isaac Aracena f1284fc405 Initial commit: Portal Brief Inteligente CDC 2026-05-06 17:55:54 -04:00
19 changed files with 630 additions and 3243 deletions
-10
View File
@@ -1,10 +0,0 @@
# 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
+2 -2
View File
@@ -7,7 +7,7 @@ node_modules/
coverage/
# Production
# dist/
dist/
build/
CDC-Brief v1/
@@ -28,7 +28,7 @@ yarn-error.log*
.env.production.local
.env.*
ENV.env
!.env.example
! .env.example
# Zips and binaries
*.zip
-428
View File
@@ -1,428 +0,0 @@
# CDC Brief GLM
Portal web para la captura guiada y procesamiento inteligente de briefs del área CDC de GomezLee Marketing.
---
## Descripción
CDC Brief GLM es una aplicación frontend desarrollada para estandarizar la recepción de solicitudes creativas mediante un flujo guiado por pasos. La plataforma permite al usuario enviar una nota de voz, imágenes de referencia, documentos de apoyo, materiales de campaña, enlaces externos y fecha límite de entrega.
La información enviada desde el portal se procesa mediante un webhook de n8n, el cual recibe los archivos y datos del brief para generar una carpeta de trabajo en Google Drive y consolidar la información necesaria para continuar el flujo operativo del equipo CDC.
---
## Objetivo del proyecto
Centralizar y ordenar la solicitud inicial de briefs creativos, reduciendo vacíos de información y facilitando que el equipo CDC reciba insumos completos, trazables y organizados desde el primer contacto.
El portal busca:
* Guiar al usuario en la captura de información mínima requerida.
* Recibir audio, imágenes, documentos, archivos de campaña y enlaces externos.
* Enviar todos los datos al flujo automatizado de n8n.
* Mostrar una respuesta clara del portal con la carpeta generada y el resumen del procesamiento.
* Mantener una experiencia visual alineada con la identidad de GomezLee Marketing.
---
## Alcance funcional
La aplicación incluye los siguientes módulos y pasos:
| Paso | Nombre | Descripción |
| ---- | ---------------------- | -------------------------------------------------------------------------------------- |
| 1 | Nota de voz | Permite grabar o adjuntar una nota de voz explicando el brief. |
| 2 | Imágenes de referencia | Permite adjuntar imágenes visuales de apoyo. |
| 3 | Documentos | Permite adjuntar documentos opcionales como PDF, Excel, PowerPoint o Word. |
| 4 | Campaña / gráficos | Permite adjuntar archivos relacionados con campañas o elementos gráficos existentes. |
| 5 | Referencias online | Permite agregar enlaces externos como referencia. |
| 6 | Revisión y envío | Permite revisar la información, definir fecha límite de entrega y enviar la solicitud. |
---
## Características principales
* Interfaz guiada por pasos.
* Autenticación corporativa con Firebase.
* Restricción de acceso según configuración del proyecto.
* Carga de archivos multimedia y documentos.
* Grabación y reproducción de nota de voz.
* Campo obligatorio de fecha límite de entrega.
* Envío de datos y archivos mediante FormData al webhook de n8n.
* Visualización de respuesta del portal.
* Copia y apertura directa del enlace de carpeta generada.
* Diseño visual alineado al branding GLM.
* Favicon corporativo GLM.
* Carpeta `dist/` incluida para despliegue estático cuando aplique.
---
## Stack técnico
| Componente | Tecnología |
| ---------------------- | -------------------------------------------------------------- |
| Frontend | React |
| Lenguaje | TypeScript |
| Build tool | Vite |
| Estilos | CSS |
| Autenticación | Firebase Auth |
| Automatización backend | n8n Webhook |
---
## Estructura del repositorio
```text
cdcbrief-glm/
├── dist/ # Build de producción generado por Vite
├── public/ # Assets públicos
├── src/ # Código fuente de la aplicación
│ ├── components/ # Componentes visuales y funcionales
│ ├── services/ # Servicios de integración
│ ├── App.tsx # Componente principal
│ └── main.tsx # Punto de entrada
├── .env.example # Ejemplo de variables de entorno requeridas
├── .gitignore # Exclusiones del repositorio
├── index.html # HTML base de Vite
├── metadata.json # Metadata del proyecto
├── package.json # Dependencias y scripts
├── package-lock.json # Lockfile de npm
├── tsconfig.json # Configuración TypeScript
├── vite.config.ts # Configuración de Vite
└── README.md # Documentación del proyecto
```
---
## Requisitos
Antes de instalar o correr el proyecto, validar que el ambiente tenga:
* Node.js 18 o superior.
* npm instalado.
* Acceso al repositorio en Gitea.
* Archivo `.env` configurado localmente.
* Proyecto Firebase configurado.
* Webhook de n8n activo y disponible.
---
## Variables de entorno
El proyecto requiere un archivo `.env` en la raíz del repositorio.
Tomar como base el archivo `.env.example`.
```env
VITE_FIREBASE_API_KEY=
VITE_FIREBASE_AUTH_DOMAIN=
VITE_FIREBASE_PROJECT_ID=
VITE_FIREBASE_STORAGE_BUCKET=
VITE_FIREBASE_MESSAGING_SENDER_ID=
VITE_FIREBASE_APP_ID=
VITE_WEBHOOK_URL=
```
### Descripción de variables
| Variable | Descripción |
| ----------------------------------- | ----------------------------------------------------- |
| `VITE_FIREBASE_API_KEY` | API Key del proyecto Firebase. |
| `VITE_FIREBASE_AUTH_DOMAIN` | Dominio de autenticación de Firebase. |
| `VITE_FIREBASE_PROJECT_ID` | ID del proyecto Firebase. |
| `VITE_FIREBASE_STORAGE_BUCKET` | Bucket asociado al proyecto Firebase. |
| `VITE_FIREBASE_MESSAGING_SENDER_ID` | Sender ID del proyecto Firebase. |
| `VITE_FIREBASE_APP_ID` | App ID del proyecto Firebase. |
| `VITE_WEBHOOK_URL` | URL del webhook de n8n que recibe y procesa el brief. |
---
## Instalación local
Clonar el repositorio:
```bash
git clone https://git.digitalcompass.agency/Isaac_Aracena/cdcbrief-glm.git
```
Entrar a la carpeta del proyecto:
```bash
cd cdcbrief-glm
```
Instalar dependencias:
```bash
npm install
```
Crear el archivo `.env` local usando `.env.example` como referencia:
```bash
cp .env.example .env
```
Completar las variables reales en `.env`.
---
## Correr en desarrollo
Ejecutar el servidor local:
```bash
npm run dev
```
Abrir el navegador en:
```text
http://localhost:3000
```
Si el puerto cambia por configuración local, usar la URL indicada por Vite en la terminal.
---
## Generar build de producción
Para generar la carpeta `dist/`:
```bash
npm run build
```
El resultado se genera en:
```text
dist/
```
La carpeta `dist/` contiene los archivos estáticos listos para servir desde un servidor web.
---
## Validar build local con XAMPP
Para validar el build con XAMPP:
1. Ejecutar:
```bash
npm run build
```
2. Copiar el contenido de `dist/` dentro de la carpeta correspondiente de Apache/XAMPP.
3. Abrir la URL local configurada.
4. Validar:
* Carga de pantalla de login.
* Acceso con cuenta autorizada.
* Flujo completo de los seis pasos.
* Grabación y reproducción de audio.
* Carga de imágenes.
* Carga de documentos.
* Carga de archivos de campaña.
* Agregado de enlaces.
* Fecha límite de entrega.
* Envío al webhook.
* Respuesta del portal.
* Enlace de carpeta generada.
* Botones de copiar y abrir.
---
## Scripts disponibles
| Comando | Descripción |
| ----------------- | --------------------------------------------------------------------------- |
| `npm install` | Instala las dependencias del proyecto. |
| `npm run dev` | Inicia el servidor local de desarrollo. |
| `npm run build` | Genera el build de producción en `dist/`. |
| `npm run lint` | Ejecuta validaciones de lint, si están configuradas en el proyecto. |
| `npm run preview` | Sirve localmente el build generado, si está disponible en la configuración. |
---
## Flujo de procesamiento
```text
Usuario autorizado
|
v
Portal CDC Brief GLM
|
v
Captura guiada de información
|
v
Archivos + metadata + fecha límite
|
v
Webhook n8n
|
v
Procesamiento del brief
|
v
Carpeta generada en Google Drive
|
v
Respuesta visual en el portal
```
---
## Datos enviados al webhook
La aplicación envía la información usando `FormData`.
Entre los datos principales se incluyen:
* Nota de voz.
* Imágenes de referencia.
* Documentos de apoyo.
* Archivos de campaña o elementos gráficos.
* Enlaces de referencia.
* Fecha límite de entrega.
* Metadata del usuario autenticado.
* Resumen del brief.
* Campos compatibles para uso en n8n, correo, Google Drive, Google Docs o Google Sheets.
La fecha límite de entrega se envía en los campos:
```text
deliveryDate
fechaEntrega
```
---
## Seguridad
No subir credenciales reales al repositorio.
Archivos que no deben versionarse:
```text
.env
.env.local
.env.production
.env.development
```
El archivo `.env.example` sí puede mantenerse en el repositorio porque solo contiene los nombres de las variables requeridas, sin valores sensibles.
---
## Reglas de desarrollo
* No modificar la pantalla de login salvo que exista una solicitud explícita aprobada.
* Mantener el branding visual de GomezLee Marketing.
* No romper el flujo funcional existente.
* Validar `npm run build` antes de subir cambios.
* Si se modifica la experiencia visual, validar el flujo completo en navegador.
* Si se modifica el envío al webhook, validar con n8n antes de desplegar.
* Mantener actualizado el `dist/` cuando el repositorio lo requiera para despliegue estático.
* No subir `node_modules`.
* No subir `.env` con credenciales reales.
---
## Checklist antes de hacer commit
Antes de subir cambios a Gitea, validar:
* `npm install` ejecutado correctamente.
* `npm run build` completado sin errores.
* La carpeta `dist/` fue generada correctamente.
* El portal carga en navegador.
* Login funciona.
* Flujo de pasos funciona.
* Audio muestra iconos correctos de play y pausa.
* Adjuntos se visualizan correctamente.
* Botón de copiar y abrir funcionan.
* Fecha límite de entrega se muestra y se envía.
* Respuesta del portal se ve correctamente.
* `.env` no aparece en `git status`.
* `node_modules` no aparece en `git status`.
---
## Comandos recomendados para actualizar Gitea
Agregar cambios:
```bash
git add -A
git add -f dist
```
Revisar estado:
```bash
git status
```
Crear commit:
```bash
git commit -m "feat: actualizar CDC Brief con branding GLM y ajustes UX finales"
```
Subir cambios:
```bash
git push origin main
```
---
## Estado actual
Versión frontend actualizada con:
* Branding visual GomezLee Marketing.
* UX/UI principal suavizada y menos cargada.
* Favicon GLM.
* Botones principales ajustados al estilo GLM.
* Reproductor de audio con iconos visibles de play y pausa.
* Botón final con contraste corregido.
* Respuesta del portal mejorada para adjuntos, documentos y audio.
* Carpeta `dist/` generada y validada localmente.
---
## Mantenimiento
Responsable técnico:
```text
GLM IT
```
Repositorio:
```text
https://git.digitalcompass.agency/Isaac_Aracena/cdcbrief-glm
```
Proyecto:
```text
CDC Brief GLM
```
Área:
```text
Creative Design Center / GomezLee Marketing
```
---
*GLM IT — Automatización e IA · GomezLee Marketing · 2026*
-1587
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="GLM"><rect width="64" height="64" rx="14" fill="#FFFFFF"/><rect x="7" y="20" width="50" height="24" rx="4" fill="#4F758B"/><path d="M29 20h13v17c0 3-2.5 5.5-5.5 5.5H29V20Z" fill="#6CC24A"/><path d="M28 20h4v24h-4z" fill="#6CC24A"/><path d="M24 28h4v16h-4z" fill="#6CC24A"/></svg>

Before

Width:  |  Height:  |  Size: 363 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

-20
View File
@@ -1,20 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="icon" type="image/png" href="glm-logo.png?v=5" />
<link rel="shortcut icon" type="image/png" href="glm-logo.png?v=5" />
<link rel="apple-touch-icon" href="glm-logo.png?v=5" />
<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-D0uN9x5z.js"></script>
<link rel="stylesheet" crossorigin href="/brief/assets/index-q-lSOtsg.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+1 -7
View File
@@ -1,15 +1,9 @@
<!doctype html>
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="icon" type="image/png" href="glm-logo.png?v=5" />
<link rel="shortcut icon" type="image/png" href="glm-logo.png?v=5" />
<link rel="apple-touch-icon" href="glm-logo.png?v=5" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CDC Brief | GLM</title>
<title>My Google AI Studio App</title>
</head>
<body>
<div id="root"></div>
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="GLM"><rect width="64" height="64" rx="14" fill="#FFFFFF"/><rect x="7" y="20" width="50" height="24" rx="4" fill="#4F758B"/><path d="M29 20h13v17c0 3-2.5 5.5-5.5 5.5H29V20Z" fill="#6CC24A"/><path d="M28 20h4v24h-4z" fill="#6CC24A"/><path d="M24 28h4v16h-4z" fill="#6CC24A"/></svg>

Before

Width:  |  Height:  |  Size: 363 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

+159 -73
View File
@@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { Loader2 } from 'lucide-react';
import { FileText, Loader2, ShieldCheck, Sparkles, Workflow } from 'lucide-react';
import { onAuthStateChanged, signInWithPopup, signOut, User } from 'firebase/auth';
import { Header } from './components/Header';
@@ -11,10 +11,50 @@ 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 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);
@@ -26,13 +66,19 @@ export default function App() {
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
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);
@@ -67,17 +113,16 @@ export default function App() {
}
};
const handleLogout = async () => {
await signOut(auth);
setUser(null);
};
await signOut(auth);
setUser(null);
};
const handleSendMessage = async (
text: string,
attachments: Attachment[],
audio?: AudioRecording,
deliveryDate?: string,
deliveryDateStatus: 'defined' | 'pending' = 'defined'
audio?: AudioRecording
) => {
const userMessageId = Math.random().toString(36).substring(7);
@@ -95,51 +140,14 @@ export default function App() {
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 || '');
const deliveryDateValue = deliveryDateStatus === 'pending' ? '' : deliveryDate || '';
const deliveryDateLabel = deliveryDateStatus === 'pending' ? 'Por confirmar' : deliveryDateValue;
formData.append('timestamp', new Date().toISOString());
formData.append('deliveryDate', deliveryDateValue);
formData.append('fechaEntrega', deliveryDateValue);
formData.append('deliveryDateStatus', deliveryDateStatus);
formData.append('fechaEntregaEstado', deliveryDateStatus === 'pending' ? 'por_confirmar' : 'definida');
formData.append('deliveryDateLabel', deliveryDateLabel);
formData.append('fechaEntregaTexto', deliveryDateLabel);
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;
attachments.forEach((att, index) => {
formData.append(`file_${index}`, att.file);
});
formData.append('generalFilesCount', String(generalFileIndex));
formData.append('campaignFilesCount', String(campaignFileIndex));
if (audio) {
formData.append('audio', audio.blob, 'voice_note.webm');
}
@@ -196,7 +204,7 @@ export default function App() {
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.`,
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'
};
@@ -209,7 +217,7 @@ Verifica que la URL esté correcta en el entorno y que el endpoint esté activo
if (authLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#F7FAF8] text-[#4F758B]">
<div className="flex min-h-screen items-center justify-center bg-[#060816] text-zinc-200">
Cargando acceso...
</div>
);
@@ -226,31 +234,107 @@ Verifica que la URL esté correcta en el entorno y que el endpoint esté activo
}
return (
<div className="glm-app relative flex min-h-screen flex-col overflow-x-hidden bg-[#F4F7F5] font-['Plus_Jakarta_Sans',sans-serif] text-[#4A4A4A] selection:bg-[#6CC24A]/20">
{/* Subtle mesh background */}
<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-[-10%] h-[32rem] w-[32rem] rounded-full bg-[#EEF6E8]/60 blur-[90px]" />
<div className="absolute right-[-8%] top-[15%] h-[28rem] w-[28rem] rounded-full bg-[#D6E8F4]/60 blur-[90px]" />
<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} />
<Header isConnected={isConnected} onLogout={handleLogout} />
<main className="relative flex-1">
<div className="mx-auto w-full max-w-6xl px-4 py-6 pb-10 sm:px-6 lg:px-8 flex flex-col gap-5">
<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>
{/* Brief Input Section */}
<section className="overflow-hidden rounded-[28px] border border-white/60 bg-white/80 shadow-[0_8px_40px_rgba(79,117,139,0.10)] backdrop-blur-xl">
<InputArea
onSendMessage={handleSendMessage}
isProcessing={isProcessing}
/>
</section>
{/* Response Section — only show when there's content */}
{(messages.length > 0 || isProcessing) && (
<section className="overflow-hidden rounded-[28px] border border-white/60 bg-white/80 p-5 shadow-[0_8px_40px_rgba(79,117,139,0.10)] backdrop-blur-xl sm:p-6">
<p className="mb-4 text-xs font-bold uppercase tracking-[0.22em] text-[#6B8FA3]">Respuesta del portal</p>
<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} />
@@ -262,18 +346,20 @@ Verifica que la URL esté correcta en el entorno y que el endpoint esté activo
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9 }}
className="flex w-fit items-center gap-3 rounded-2xl border border-[#D0D0D0]/60 bg-white px-4 py-3 shadow-sm"
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-[#6CC24A]" />
<span className="text-sm font-medium text-[#4A4A4A]">Procesando la solicitud...</span>
<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-1" />
</section>
)}
<div ref={messagesEndRef} className="h-2" />
</div>
</section>
</div>
</main>
</div>
);
}
}
+39 -32
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { LogOut } from 'lucide-react';
import { LogOut, Sparkles, Zap } from 'lucide-react';
import { cn } from '../lib/utils';
interface HeaderProps {
@@ -7,51 +7,58 @@ interface HeaderProps {
onLogout: () => void;
}
const logoSrc = `${import.meta.env.BASE_URL}glm-logo.png`;
export const Header: React.FC<HeaderProps> = ({ isConnected, onLogout }) => {
return (
<header className="sticky top-0 z-50 border-b border-[#D0D0D0]/60 bg-white/90 backdrop-blur-2xl shadow-[0_4px_24px_rgba(79,117,139,0.07)]">
<div className="mx-auto flex w-full max-w-7xl items-center justify-between gap-4 px-5 py-3 sm:px-8">
{/* Left: Logo + Title */}
<div className="flex items-center gap-4">
<div className="flex h-14 w-14 items-center justify-center overflow-hidden rounded-[18px] border border-slate-200/80 bg-white p-1.5 shadow-[0_4px_14px_rgba(79,117,139,0.10)]">
<img src={logoSrc} alt="GLM" className="h-full w-full object-contain" />
<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>
<h1 className="text-base font-bold tracking-tight text-[#4F758B] sm:text-lg">
Portal Brief CDC
</h1>
<div className="mt-0.5 flex items-center gap-2">
<span className={cn(
'relative flex h-2 w-2 shrink-0'
)}>
{isConnected && (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#6CC24A] opacity-60" />
)}
<span className={cn(
'relative inline-flex h-2 w-2 rounded-full',
isConnected ? 'bg-[#6CC24A]' : 'bg-red-400'
)} />
</span>
<span className="text-[11px] font-medium text-slate-400">
{isConnected ? 'Conectado' : 'Sin conexión'} · GomezLee Marketing
<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>
{/* Right: Logout */}
<button
onClick={onLogout}
className="group inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-[#4F758B] shadow-sm transition-all hover:border-rose-200 hover:bg-rose-50 hover:text-rose-600 focus:outline-none focus:ring-2 focus:ring-[#6CC24A]/30"
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 transition-transform group-hover:translate-x-0.5" />
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline">Cerrar sesión</span>
</button>
</div>
</header>
);
};
};
File diff suppressed because it is too large Load Diff
+47 -88
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { motion } from 'motion/react';
import { Loader2 } from 'lucide-react';
import { Chrome, ShieldCheck, Sparkles } from 'lucide-react';
interface LoginScreenProps {
onLogin: () => void;
@@ -8,102 +8,61 @@ interface LoginScreenProps {
error?: string;
}
const logoSrc = `${import.meta.env.BASE_URL}glm-logo.png`;
export const LoginScreen: React.FC<LoginScreenProps> = ({ onLogin, isLoading, error }) => {
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[#F4F7F5] px-4 py-12 select-none">
{/* Premium Tech Grid Background */}
<div className="absolute inset-0 premium-grid-bg opacity-75 pointer-events-none" />
{/* Dynamic Animated Mesh Gradients */}
<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">
<motion.div
animate={{ x: [0, 45, -25, 0], y: [0, -60, 40, 0], scale: [1, 1.15, 0.95, 1] }}
transition={{ duration: 22, repeat: Infinity, ease: 'easeInOut' }}
className="absolute left-[-8%] top-[-12%] h-[36rem] w-[36rem] rounded-full bg-[#EEF6E8]/70 blur-[90px]"
/>
<motion.div
animate={{ x: [0, -35, 45, 0], y: [0, 50, -40, 0], scale: [1, 0.95, 1.1, 1] }}
transition={{ duration: 26, repeat: Infinity, ease: 'easeInOut' }}
className="absolute right-[-6%] top-[8%] h-[32rem] w-[32rem] rounded-full bg-[#D6E8F4]/70 blur-[100px]"
/>
<motion.div
animate={{ x: [0, 30, -30, 0], y: [0, 40, -40, 0], scale: [1, 1.05, 0.95, 1] }}
transition={{ duration: 18, repeat: Infinity, ease: 'easeInOut' }}
className="absolute bottom-[-15%] left-[20%] h-[30rem] w-[30rem] rounded-full bg-white/70 blur-[90px]"
/>
<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>
{/* Login Card */}
<motion.div
initial={{ opacity: 0, y: 32, scale: 0.97 }}
initial={{ opacity: 0, y: 18, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }}
className="relative w-full max-w-[400px] overflow-hidden rounded-[32px] border border-white/50 bg-white/75 px-10 py-12 shadow-[0_32px_96px_rgba(79,117,139,0.14)] backdrop-blur-2xl font-['Plus_Jakarta_Sans',sans-serif]"
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"
>
{/* Top gradient bar */}
<div className="absolute top-0 inset-x-0 h-[4px] bg-gradient-to-r from-[#6CC24A] via-[#5B7F95] to-[#4F758B]" />
{/* Centered content */}
<div className="flex flex-col items-center text-center">
{/* Logo */}
<div className="flex h-24 w-24 items-center justify-center overflow-hidden rounded-[24px] border border-slate-200/60 bg-white p-3 shadow-[0_8px_28px_rgba(79,117,139,0.12)]">
<img src={logoSrc} alt="GLM Logo" className="h-full w-full object-contain" />
</div>
{/* Title — full gradient, no black */}
<h1 className="mt-8 text-[2rem] font-bold tracking-tight leading-tight">
<span className="bg-gradient-to-r from-[#4F758B] via-[#5B7F95] to-[#6B8FA3] bg-clip-text text-transparent">
Portal Brief CDC
</span>
</h1>
{/* Subtle tagline */}
<p className="mt-2.5 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">
GomezLee Marketing
</p>
{/* Error */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-6 w-full rounded-xl border border-rose-100 bg-rose-50/50 px-4 py-3 text-xs leading-relaxed text-rose-600 flex items-start gap-2.5"
>
<div className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-rose-500 animate-pulse" />
<span>{error}</span>
</motion.div>
)}
{/* Google Login Button */}
<button
onClick={onLogin}
disabled={isLoading}
className="shimmer-btn group mt-10 relative flex w-full items-center justify-center gap-3 rounded-2xl bg-gradient-to-r from-[#4F758B] to-[#5B7F95] px-6 py-4 text-sm font-semibold text-white shadow-[0_12px_28px_rgba(79,117,139,0.2)] transition-all duration-300 hover:from-[#456A7F] hover:to-[#4F758B] hover:shadow-[0_16px_36px_rgba(79,117,139,0.3)] active:scale-[0.99] disabled:pointer-events-none disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<svg
className="h-5 w-5 shrink-0 transition-transform duration-300 group-hover:scale-110"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z" fill="#EA4335" />
</svg>
)}
<span className="tracking-wide">
{isLoading ? 'Abriendo Google...' : 'Continuar con Google'}
</span>
</button>
<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>
);
};
};
+46 -220
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { motion } from 'motion/react';
import { format } from 'date-fns';
import { AlertCircle, Check, CheckCheck, Copy, ExternalLink, FileText, Music, Pause, Play, ShieldCheck, X } from 'lucide-react';
import { AlertCircle, CheckCheck, FileText, Music, ShieldCheck } from 'lucide-react';
import { Message, Attachment } from '../types';
import { cn } from '../lib/utils';
import ReactMarkdown from 'react-markdown';
@@ -10,46 +10,17 @@ 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 getAttachmentLabel = (attachment: Attachment) => {
if (attachment.type === 'image') return 'Imagen de referencia';
if (attachment.type === 'audio') return 'Nota de voz';
if (attachment.category === 'campaign') return 'Campaña / gráfico';
if (attachment.type === 'document') return 'Documento';
return 'Archivo adjunto';
};
const AttachmentPreview = ({ attachment }: { attachment: Attachment }) => {
const fileSizeKb = `${(attachment.file.size / 1024).toFixed(1)} KB`;
const label = getAttachmentLabel(attachment);
if (attachment.type === 'image') {
return (
<div className="glm-message-attachment flex w-full max-w-[320px] items-center gap-3 rounded-2xl border border-[#D0D0D0]/70 bg-white/95 px-3 py-3 text-[#4A4A4A] shadow-[0_8px_22px_rgba(79,117,139,0.10)]">
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-[#D0D0D0]/65 bg-[#F9FBFC]">
<img
src={attachment.url}
alt={attachment.file.name}
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-[#2E4052]">{attachment.file.name}</p>
<p className="truncate text-xs font-medium text-[#6B8FA3]">{label}</p>
<p className="mt-0.5 text-[11px] text-[#8EA1B3]">{fileSizeKb}</p>
<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>
);
@@ -58,167 +29,43 @@ const AttachmentPreview = ({ attachment }: { attachment: Attachment }) => {
const Icon = attachment.type === 'audio' ? Music : FileText;
return (
<div className="glm-message-attachment flex w-full max-w-[320px] items-center gap-3 rounded-2xl border border-[#D0D0D0]/70 bg-white/95 px-3 py-3 text-[#4A4A4A] shadow-[0_8px_22px_rgba(79,117,139,0.10)]">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl border border-[#D0D0D0]/60 bg-[#F7FAF8]">
<Icon className="h-5 w-5 text-[#4F758B]" />
<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-semibold text-[#2E4052]">{attachment.file.name}</p>
<p className="truncate text-xs font-medium text-[#6B8FA3]">{label}</p>
<p className="mt-0.5 text-[11px] text-[#8EA1B3]">{fileSizeKb}</p>
<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 border border-white/10 bg-white px-4 py-2.5 text-sm font-semibold text-[#4F758B] transition hover:bg-[#F8FBF6]"
>
{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 {
const togglePlay = () => {
if (audioRef.current) {
if (isPlaying) {
audioRef.current.pause();
} else {
await audioRef.current.play();
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;
setIsPlaying(!isPlaying);
}
};
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;
audioRef.current = new Audio(message.audio.url);
audioRef.current.onended = () => setIsPlaying(false);
}
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
audioRef.current = null;
}
};
@@ -257,10 +104,6 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
)}
<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) => (
@@ -270,50 +113,33 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
)}
{message.audio && (
<div className="glm-message-audio mb-4 rounded-2xl border border-[#D0D0D0]/70 bg-white/95 p-3 pr-4 shadow-[0_8px_22px_rgba(79,117,139,0.10)]">
<div className="flex items-center gap-3">
<button
type="button"
onClick={togglePlay}
className="glm-message-audio-toggle flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#4F758B] text-white shadow-[0_8px_18px_rgba(79,117,139,0.24)] transition hover:bg-[#5B7F95]"
aria-label={isPlaying ? 'Pausar audio' : 'Reproducir audio'}
>
{isPlaying ? (
<Pause className="h-5 w-5" fill="currentColor" />
) : (
<Play className="ml-0.5 h-5 w-5" fill="currentColor" />
<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'
)}
</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-[#4F758B]">
{isPlaying ? 'Reproduciendo' : 'Nota de voz'}
</span>
<span className="shrink-0 text-xs font-mono text-[#6B8FA3]">
{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-[#6CC24A]/20" />
<div
className="absolute left-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-[#6CC24A]/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>
<span className="text-xs font-mono opacity-80">
0:{message.audio.duration.toString().padStart(2, '0')}
</span>
</div>
)}
@@ -358,4 +184,4 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
</div>
</motion.div>
);
};
};
+15 -15
View File
@@ -32,25 +32,25 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, w
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 z-50 bg-[#2C3E45]/35 backdrop-blur-sm"
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 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-3xl border border-[#D0D0D0]/70 bg-white shadow-2xl"
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 border-b border-[#D0D0D0]/70 p-6">
<h2 className="text-xl font-semibold text-[#4F758B]">Configuración de conexión</h2>
<button onClick={onClose} className="rounded-full p-2 text-[#6B8FA3] transition-colors hover:bg-[#EEF6E8] hover:text-[#4F758B]">
<X className="h-5 w-5" />
<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="space-y-6 p-6">
<div className="p-6 space-y-6">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#4F758B]">
<LinkIcon className="h-4 w-4 text-[#6CC24A]" />
<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
@@ -58,26 +58,26 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, w
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://tu-endpoint.com/webhook"
className="w-full rounded-xl border border-[#D0D0D0]/80 bg-[#F9FBFC] px-4 py-3 font-mono text-sm text-[#4A4A4A] placeholder:text-[#9AA8B0] transition-all focus:border-[#6CC24A] focus:outline-none focus:ring-2 focus:ring-[#6CC24A]/25"
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="mt-2 text-xs text-[#6B8FA3]">
<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="flex justify-end gap-3 border-t border-[#D0D0D0]/70 bg-[#F9FBFC] p-6">
<div className="p-6 border-t border-white/5 bg-black/20 flex justify-end gap-3">
<button
onClick={onClose}
className="rounded-xl px-5 py-2.5 text-sm font-medium text-[#6B8FA3] transition-colors hover:bg-white hover:text-[#4F758B]"
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="flex items-center gap-2 rounded-xl bg-[#4F758B] px-5 py-2.5 text-sm font-medium text-white shadow-lg shadow-[#4F758B]/20 transition-colors hover:bg-[#456A7F]"
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="h-4 w-4" />
<Save className="w-4 h-4" />
Guardar
</button>
</div>
+5 -312
View File
@@ -2,22 +2,12 @@
@plugin "@tailwindcss/typography";
:root {
color-scheme: light;
--glm-blue: #4F758B;
--glm-blue-mid: #5B7F95;
--glm-steel: #6B8FA3;
--glm-green: #6CC24A;
--glm-green-soft: #EEF6E8;
--glm-blue-soft: #D6E8F4;
--glm-text: #4A4A4A;
--glm-border: #D0D0D0;
--glm-page: #F7FAF8;
color-scheme: dark;
}
html {
min-height: 100%;
overflow-y: scroll;
background: var(--glm-page);
}
body,
@@ -27,156 +17,11 @@ body,
body {
margin: 0;
background: var(--glm-page);
font-family: Arial, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #060816;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
overflow-x: hidden;
}
button,
input,
textarea,
select {
font-family: Arial, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
/* GLM light skin: keeps all behavior intact and only remaps the previous dark UI colors. */
.glm-app .border-white\/10,
.glm-app .border-white\/5,
.glm-app .border-indigo-400\/20,
.glm-app .border-indigo-400\/25,
.glm-app .border-indigo-400\/30,
.glm-app .border-indigo-300\/25 {
border-color: rgba(208, 208, 208, 0.72) !important;
}
.glm-app .bg-black\/10,
.glm-app .bg-black\/20,
.glm-app .bg-black\/25,
.glm-app .bg-white\/5,
.glm-app .bg-white\/10,
.glm-app .bg-white\/\[0\.03\],
.glm-app .bg-white\/\[0\.04\] {
background-color: #ffffff !important;
}
.glm-app .bg-black\/30,
.glm-app .bg-black\/35,
.glm-app .bg-black\/40,
.glm-app .bg-black\/50 {
background-color: #F9FBFC !important;
}
.glm-app .text-zinc-100,
.glm-app .text-zinc-200,
.glm-app .text-zinc-300 {
color: var(--glm-text) !important;
}
.glm-app .text-zinc-400,
.glm-app .text-zinc-500,
.glm-app .text-zinc-600 {
color: var(--glm-steel) !important;
}
.glm-app .text-white,
.glm-app .text-white\/80,
.glm-app .text-white\/90,
.glm-app .text-indigo-100,
.glm-app .text-indigo-100\/80,
.glm-app .text-indigo-200 {
color: var(--glm-blue) !important;
}
.glm-app .text-indigo-300,
.glm-app .text-indigo-400,
.glm-app .text-emerald-300,
.glm-app .text-emerald-400 {
color: var(--glm-green) !important;
}
.glm-app .text-amber-300 {
color: #FF8C00 !important;
}
.glm-app [class*="bg-[linear-gradient(135deg,#6366f1"],
.glm-app [class*="bg-[linear-gradient(135deg,rgba(79,70,229"],
.glm-app [class*="bg-[linear-gradient(135deg,#6366f1_0%,#2563eb_50%,#06b6d4"],
.glm-app [class*="bg-[linear-gradient(135deg,#6366f1_0%,#2563eb_100%"] {
background: var(--glm-blue) !important;
color: #ffffff !important;
box-shadow: 0 16px 34px rgba(79, 117, 139, 0.2) !important;
}
.glm-app [class*="bg-[linear-gradient(135deg,#6366f1"] *,
.glm-app [class*="bg-[linear-gradient(135deg,rgba(79,70,229"] *,
.glm-app [class*="bg-[linear-gradient(135deg,#6366f1_0%,#2563eb_50%,#06b6d4"] *,
.glm-app [class*="bg-[linear-gradient(135deg,#6366f1_0%,#2563eb_100%"] * {
color: inherit !important;
}
.glm-app [class*="bg-[linear-gradient(90deg,#6366f1"] {
background: linear-gradient(90deg, var(--glm-green) 0%, var(--glm-blue) 100%) !important;
}
.glm-app [class*="bg-[linear-gradient(180deg,rgba(99,102,241"],
.glm-app [class*="bg-[linear-gradient(145deg,rgba(99,102,241"] {
background: #F8FBF6 !important;
border-color: rgba(108, 194, 74, 0.38) !important;
box-shadow: 0 14px 34px rgba(108, 194, 74, 0.12) !important;
}
.glm-app [class*="bg-indigo-500/10"],
.glm-app .bg-indigo-500\/10,
.glm-app .bg-emerald-500\/10,
.glm-app .bg-emerald-500\/15 {
background-color: var(--glm-green-soft) !important;
}
.glm-app .bg-indigo-300,
.glm-app .bg-indigo-400,
.glm-app .bg-emerald-400 {
background-color: var(--glm-green) !important;
}
.glm-app .bg-zinc-600 {
background-color: var(--glm-border) !important;
}
.glm-app [class*="min-h-[178px]"] {
min-height: 132px !important;
}
.glm-app [class*="shadow-[0_28px_80px"],
.glm-app [class*="shadow-[0_24px_60px"],
.glm-app [class*="shadow-[0_30px_80px"],
.glm-app [class*="shadow-[0_16px_40px"] {
box-shadow: 0 18px 50px rgba(79, 117, 139, 0.11) !important;
}
.glm-app .prose-invert {
--tw-prose-body: var(--glm-text);
--tw-prose-headings: var(--glm-blue);
--tw-prose-lead: var(--glm-text);
--tw-prose-links: var(--glm-blue);
--tw-prose-bold: var(--glm-blue);
--tw-prose-counters: var(--glm-steel);
--tw-prose-bullets: var(--glm-green);
--tw-prose-hr: var(--glm-border);
--tw-prose-quotes: var(--glm-blue);
--tw-prose-quote-borders: var(--glm-green);
--tw-prose-captions: var(--glm-steel);
--tw-prose-code: var(--glm-blue);
--tw-prose-pre-code: var(--glm-text);
--tw-prose-pre-bg: #F9FBFC;
--tw-prose-th-borders: var(--glm-border);
--tw-prose-td-borders: var(--glm-border);
}
.glm-app .prose pre {
border: 1px solid rgba(208, 208, 208, 0.7) !important;
background: #F9FBFC !important;
}
@layer utilities {
.scrollbar-hide::-webkit-scrollbar {
display: none;
@@ -197,163 +42,11 @@ select {
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: rgba(79, 117, 139, 0.22);
background: rgba(255, 255, 255, 0.12);
border-radius: 999px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: rgba(79, 117, 139, 0.36);
background: rgba(255, 255, 255, 0.22);
}
}
@layer components {
.audio-preview-range,
.message-audio-range {
height: 20px;
appearance: none;
-webkit-appearance: none;
background: transparent;
}
.audio-preview-range::-webkit-slider-runnable-track,
.message-audio-range::-webkit-slider-runnable-track {
height: 6px;
border-radius: 999px;
background: rgba(108, 194, 74, 0.18);
}
.audio-preview-range::-webkit-slider-thumb,
.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: var(--glm-green);
box-shadow: 0 0 0 5px rgba(108, 194, 74, 0.16), 0 8px 18px rgba(79, 117, 139, 0.18);
}
.audio-preview-range::-moz-range-track,
.message-audio-range::-moz-range-track {
height: 6px;
border: 0;
border-radius: 999px;
background: rgba(108, 194, 74, 0.18);
}
.audio-preview-range::-moz-range-thumb,
.message-audio-range::-moz-range-thumb {
width: 16px;
height: 16px;
border: 3px solid #ffffff;
border-radius: 999px;
background: var(--glm-green);
box-shadow: 0 0 0 5px rgba(108, 194, 74, 0.16), 0 8px 18px rgba(79, 117, 139, 0.18);
}
}
.glm-app [class*="bg-[linear-gradient(135deg,rgba(99,102,241"] {
background: var(--glm-green-soft) !important;
border-color: rgba(108, 194, 74, 0.36) !important;
box-shadow: 0 16px 38px rgba(108, 194, 74, 0.12) !important;
}
/* Redesign Premium Utilities */
.premium-grid-bg {
background-image:
linear-gradient(to right, rgba(108, 194, 74, 0.06) 1px, transparent 1px),
linear-gradient(to bottom, rgba(108, 194, 74, 0.06) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%);
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.shimmer-btn {
position: relative;
overflow: hidden;
}
.shimmer-btn::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.22) 30%,
rgba(255, 255, 255, 0.35) 50%,
rgba(255, 255, 255, 0.22) 70%,
rgba(255, 255, 255, 0) 100%
);
animation: shimmer 3s infinite ease-in-out;
content: '';
}
/* Keep the audio preview play/pause glyph visible after the GLM light-skin color remap. */
.glm-app .glm-audio-preview-toggle svg {
color: #ffffff !important;
stroke: #ffffff !important;
}
.glm-app .glm-audio-preview-toggle svg.fill-current {
fill: #ffffff !important;
}
/* Keep the final review submit button readable against the GLM blue background. */
.glm-app .glm-submit-final,
.glm-app .glm-submit-final * {
color: #ffffff !important;
stroke: #ffffff !important;
}
.glm-app .glm-submit-final svg.fill-current {
fill: #ffffff !important;
}
/* Response portal attachment polish: keep uploaded files compact and readable inside the GLM message bubble. */
.glm-app .glm-message-attachment,
.glm-app .glm-message-attachment * {
color: #4A4A4A !important;
}
.glm-app .glm-message-attachment p:first-of-type {
color: #2E4052 !important;
}
.glm-app .glm-message-attachment p:not(:first-of-type) {
color: #6B8FA3 !important;
}
.glm-app .glm-message-attachment svg {
color: #4F758B !important;
stroke: #4F758B !important;
}
.glm-app .glm-message-audio span:first-of-type {
color: #4F758B !important;
}
.glm-app .glm-message-audio span:last-of-type {
color: #6B8FA3 !important;
}
.glm-app .glm-message-audio-toggle,
.glm-app .glm-message-audio-toggle * {
color: #ffffff !important;
fill: #ffffff !important;
stroke: #ffffff !important;
}
-2
View File
@@ -1,12 +1,10 @@
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 {