Compare commits
2 Commits
main
..
6f0fd4848a
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f0fd4848a | |||
| f1284fc405 |
@@ -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
|
|
||||||
+1
-1
@@ -7,7 +7,7 @@ node_modules/
|
|||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
# Production
|
# Production
|
||||||
# dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
CDC-Brief v1/
|
CDC-Brief v1/
|
||||||
|
|
||||||
|
|||||||
@@ -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*
|
|
||||||
Vendored
-1587
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
@@ -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 |
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
Vendored
-20
@@ -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
@@ -1,15 +1,9 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>CDC Brief | GLM</title>
|
<title>My Google AI Studio App</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -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 |
+153
-67
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'motion/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 { onAuthStateChanged, signInWithPopup, signOut, User } from 'firebase/auth';
|
||||||
|
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
@@ -11,10 +11,50 @@ import { LoginScreen } from './components/LoginScreen';
|
|||||||
import { auth, googleProvider } from './lib/firebase';
|
import { auth, googleProvider } from './lib/firebase';
|
||||||
import { Message, Attachment, AudioRecording } from './types';
|
import { Message, Attachment, AudioRecording } from './types';
|
||||||
|
|
||||||
const webhookUrl = import.meta.env.VITE_WEBHOOK_URL || '';
|
|
||||||
|
|
||||||
export default function App() {
|
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 [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [isConnected, setIsConnected] = useState(true);
|
const [isConnected, setIsConnected] = useState(true);
|
||||||
|
|
||||||
@@ -26,13 +66,19 @@ export default function App() {
|
|||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages, isProcessing]);
|
}, [messages, isProcessing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined' && webhookUrl) {
|
||||||
|
window.localStorage.setItem('cdc-brief-webhook-url', webhookUrl);
|
||||||
|
}
|
||||||
|
}, [webhookUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
|
const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
|
||||||
setUser(currentUser);
|
setUser(currentUser);
|
||||||
@@ -67,6 +113,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await signOut(auth);
|
await signOut(auth);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
@@ -75,9 +122,7 @@ export default function App() {
|
|||||||
const handleSendMessage = async (
|
const handleSendMessage = async (
|
||||||
text: string,
|
text: string,
|
||||||
attachments: Attachment[],
|
attachments: Attachment[],
|
||||||
audio?: AudioRecording,
|
audio?: AudioRecording
|
||||||
deliveryDate?: string,
|
|
||||||
deliveryDateStatus: 'defined' | 'pending' = 'defined'
|
|
||||||
) => {
|
) => {
|
||||||
const userMessageId = Math.random().toString(36).substring(7);
|
const userMessageId = Math.random().toString(36).substring(7);
|
||||||
|
|
||||||
@@ -95,51 +140,14 @@ export default function App() {
|
|||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
|
||||||
try {
|
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();
|
const formData = new FormData();
|
||||||
formData.append('text', text);
|
formData.append('text', text);
|
||||||
formData.append('timestamp', submittedAt);
|
formData.append('timestamp', new Date().toISOString());
|
||||||
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('deliveryDate', deliveryDateValue);
|
attachments.forEach((att, index) => {
|
||||||
formData.append('fechaEntrega', deliveryDateValue);
|
formData.append(`file_${index}`, att.file);
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
formData.append('generalFilesCount', String(generalFileIndex));
|
|
||||||
formData.append('campaignFilesCount', String(campaignFileIndex));
|
|
||||||
|
|
||||||
if (audio) {
|
if (audio) {
|
||||||
formData.append('audio', audio.blob, 'voice_note.webm');
|
formData.append('audio', audio.blob, 'voice_note.webm');
|
||||||
}
|
}
|
||||||
@@ -196,7 +204,7 @@ export default function App() {
|
|||||||
sender: 'system',
|
sender: 'system',
|
||||||
text: `**Error de conexión:** No se pudo conectar con el webhook configurado.
|
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(),
|
timestamp: new Date(),
|
||||||
status: 'error'
|
status: 'error'
|
||||||
};
|
};
|
||||||
@@ -209,7 +217,7 @@ Verifica que la URL esté correcta en el entorno y que el endpoint esté activo
|
|||||||
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
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...
|
Cargando acceso...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -226,31 +234,107 @@ Verifica que la URL esté correcta en el entorno y que el endpoint esté activo
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="relative flex min-h-screen flex-col overflow-x-hidden bg-[#060816] text-zinc-100 font-sans selection:bg-indigo-500/30">
|
||||||
{/* Subtle mesh background */}
|
|
||||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
<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 left-[-10%] top-[-15%] h-[34rem] w-[34rem] rounded-full bg-indigo-600/18 blur-3xl" />
|
||||||
<div className="absolute right-[-8%] top-[15%] h-[28rem] w-[28rem] rounded-full bg-[#D6E8F4]/60 blur-[90px]" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<Header isConnected={isConnected} onLogout={handleLogout} />
|
<Header isConnected={isConnected} onLogout={handleLogout} />
|
||||||
|
|
||||||
<main className="relative flex-1">
|
<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
|
<InputArea
|
||||||
onSendMessage={handleSendMessage}
|
onSendMessage={handleSendMessage}
|
||||||
isProcessing={isProcessing}
|
isProcessing={isProcessing}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Response Section — only show when there's content */}
|
<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">
|
||||||
{(messages.length > 0 || isProcessing) && (
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<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">
|
<div>
|
||||||
<p className="mb-4 text-xs font-bold uppercase tracking-[0.22em] text-[#6B8FA3]">Respuesta del portal</p>
|
<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}>
|
<AnimatePresence initial={false}>
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<MessageBubble key={message.id} message={message} />
|
<MessageBubble key={message.id} message={message} />
|
||||||
@@ -262,16 +346,18 @@ Verifica que la URL esté correcta en el entorno y que el endpoint esté activo
|
|||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.9 }}
|
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]" />
|
<Loader2 className="h-4 w-4 animate-spin text-indigo-400" />
|
||||||
<span className="text-sm font-medium text-[#4A4A4A]">Procesando la solicitud...</span>
|
<span className="text-sm font-medium text-zinc-300">
|
||||||
|
Procesando la solicitud...
|
||||||
|
</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={messagesEndRef} className="h-1" />
|
<div ref={messagesEndRef} className="h-2" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+34
-27
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { LogOut } from 'lucide-react';
|
import { LogOut, Sparkles, Zap } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
@@ -7,48 +7,55 @@ interface HeaderProps {
|
|||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logoSrc = `${import.meta.env.BASE_URL}glm-logo.png`;
|
|
||||||
|
|
||||||
export const Header: React.FC<HeaderProps> = ({ isConnected, onLogout }) => {
|
export const Header: React.FC<HeaderProps> = ({ isConnected, onLogout }) => {
|
||||||
return (
|
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)]">
|
<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 px-5 py-3 sm: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">
|
||||||
{/* Left: Logo + Title */}
|
<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)]">
|
||||||
<div className="flex items-center gap-4">
|
<Zap className="h-5 w-5 text-white" />
|
||||||
<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" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<h1 className="text-base font-bold tracking-tight text-[#4F758B] sm:text-lg">
|
<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
|
Portal Brief CDC
|
||||||
</h1>
|
</h1>
|
||||||
<div className="mt-0.5 flex items-center gap-2">
|
<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">
|
||||||
<span className={cn(
|
Experiencia guiada
|
||||||
'relative flex h-2 w-2 shrink-0'
|
</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 && (
|
{isConnected && (
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#6CC24A] opacity-60" />
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-70" />
|
||||||
)}
|
)}
|
||||||
<span className={cn(
|
<span
|
||||||
'relative inline-flex h-2 w-2 rounded-full',
|
className={cn(
|
||||||
isConnected ? 'bg-[#6CC24A]' : 'bg-red-400'
|
'relative inline-flex h-2.5 w-2.5 rounded-full',
|
||||||
)} />
|
isConnected ? 'bg-emerald-400' : 'bg-red-400'
|
||||||
</span>
|
)}
|
||||||
<span className="text-[11px] font-medium text-slate-400">
|
/>
|
||||||
{isConnected ? 'Conectado' : 'Sin conexión'} · GomezLee Marketing
|
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Logout */}
|
|
||||||
<button
|
<button
|
||||||
onClick={onLogout}
|
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"
|
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>
|
<span className="hidden sm:inline">Cerrar sesión</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+298
-426
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Chrome, ShieldCheck, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
interface LoginScreenProps {
|
interface LoginScreenProps {
|
||||||
onLogin: () => void;
|
onLogin: () => void;
|
||||||
@@ -8,101 +8,60 @@ interface LoginScreenProps {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logoSrc = `${import.meta.env.BASE_URL}glm-logo.png`;
|
|
||||||
|
|
||||||
export const LoginScreen: React.FC<LoginScreenProps> = ({ onLogin, isLoading, error }) => {
|
export const LoginScreen: React.FC<LoginScreenProps> = ({ onLogin, isLoading, error }) => {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[#F4F7F5] px-4 py-12 select-none">
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[#060816] px-4 text-zinc-100">
|
||||||
{/* Premium Tech Grid Background */}
|
|
||||||
<div className="absolute inset-0 premium-grid-bg opacity-75 pointer-events-none" />
|
|
||||||
|
|
||||||
{/* Dynamic Animated Mesh Gradients */}
|
|
||||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||||
<motion.div
|
<div className="absolute left-[-10%] top-[-15%] h-[34rem] w-[34rem] rounded-full bg-indigo-600/18 blur-3xl" />
|
||||||
animate={{ x: [0, 45, -25, 0], y: [0, -60, 40, 0], scale: [1, 1.15, 0.95, 1] }}
|
<div className="absolute right-[-8%] top-[8%] h-[30rem] w-[30rem] rounded-full bg-cyan-400/12 blur-3xl" />
|
||||||
transition={{ duration: 22, repeat: Infinity, ease: 'easeInOut' }}
|
<div className="absolute bottom-[-18%] left-[20%] h-[28rem] w-[28rem] rounded-full bg-fuchsia-500/10 blur-3xl" />
|
||||||
className="absolute left-[-8%] top-[-12%] h-[36rem] w-[36rem] rounded-full bg-[#EEF6E8]/70 blur-[90px]"
|
<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]" />
|
||||||
/>
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Login Card */}
|
|
||||||
<motion.div
|
<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 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 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"
|
||||||
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]"
|
|
||||||
>
|
>
|
||||||
{/* Top gradient bar */}
|
<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">
|
||||||
<div className="absolute top-0 inset-x-0 h-[4px] bg-gradient-to-r from-[#6CC24A] via-[#5B7F95] to-[#4F758B]" />
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
Acceso corporativo
|
||||||
{/* 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>
|
</div>
|
||||||
|
|
||||||
{/* Title — full gradient, no black */}
|
<h1 className="text-3xl font-semibold tracking-tight text-white sm:text-4xl">
|
||||||
<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
|
Portal Brief CDC
|
||||||
</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Subtle tagline */}
|
<p className="mt-4 text-sm leading-7 text-zinc-300 sm:text-base">
|
||||||
<p className="mt-2.5 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">
|
Inicia sesión con tu cuenta corporativa de Google para acceder al portal.
|
||||||
GomezLee Marketing
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Error */}
|
<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 && (
|
{error && (
|
||||||
<motion.div
|
<div className="mt-4 rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||||
initial={{ opacity: 0, y: -10 }}
|
{error}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</div>
|
||||||
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
|
<button
|
||||||
onClick={onLogin}
|
onClick={onLogin}
|
||||||
disabled={isLoading}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
<Chrome className="h-4 w-4" />
|
||||||
<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'}
|
{isLoading ? 'Abriendo Google...' : 'Continuar con Google'}
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { format } from 'date-fns';
|
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 { Message, Attachment } from '../types';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
@@ -10,46 +10,17 @@ interface MessageBubbleProps {
|
|||||||
message: Message;
|
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 AttachmentPreview = ({ attachment }: { attachment: Attachment }) => {
|
||||||
const fileSizeKb = `${(attachment.file.size / 1024).toFixed(1)} KB`;
|
|
||||||
const label = getAttachmentLabel(attachment);
|
|
||||||
|
|
||||||
if (attachment.type === 'image') {
|
if (attachment.type === 'image') {
|
||||||
return (
|
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="group relative max-w-[320px] overflow-hidden rounded-2xl border border-white/10 bg-black/20">
|
||||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-[#D0D0D0]/65 bg-[#F9FBFC]">
|
|
||||||
<img
|
<img
|
||||||
src={attachment.url}
|
src={attachment.url}
|
||||||
alt={attachment.file.name}
|
alt={attachment.file.name}
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]"
|
||||||
/>
|
/>
|
||||||
</div>
|
<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="min-w-0 flex-1">
|
<div className="truncate">{attachment.file.name}</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -58,167 +29,43 @@ const AttachmentPreview = ({ attachment }: { attachment: Attachment }) => {
|
|||||||
const Icon = attachment.type === 'audio' ? Music : FileText;
|
const Icon = attachment.type === 'audio' ? Music : FileText;
|
||||||
|
|
||||||
return (
|
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 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-14 w-14 shrink-0 items-center justify-center rounded-xl border border-[#D0D0D0]/60 bg-[#F7FAF8]">
|
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-black/25">
|
||||||
<Icon className="h-5 w-5 text-[#4F758B]" />
|
<Icon className="h-4 w-4 text-indigo-200" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-semibold text-[#2E4052]">{attachment.file.name}</p>
|
<p className="truncate text-sm font-medium text-white/90">{attachment.file.name}</p>
|
||||||
<p className="truncate text-xs font-medium text-[#6B8FA3]">{label}</p>
|
<p className="text-[11px] text-zinc-400">{(attachment.file.size / 1024).toFixed(1)} KB</p>
|
||||||
<p className="mt-0.5 text-[11px] text-[#8EA1B3]">{fileSizeKb}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</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 }) => {
|
export const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
|
||||||
const isUser = message.sender === 'user';
|
const isUser = message.sender === 'user';
|
||||||
const [isPlaying, setIsPlaying] = React.useState(false);
|
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 audioRef = React.useRef<HTMLAudioElement | null>(null);
|
||||||
const driveFolderLink = !isUser ? getDriveFolderLink(message.text) : null;
|
|
||||||
|
|
||||||
const effectiveAudioDuration = Math.max(0, audioDuration || message.audio?.duration || 0);
|
const togglePlay = () => {
|
||||||
const audioProgress = effectiveAudioDuration
|
if (audioRef.current) {
|
||||||
? Math.min(100, Math.max(0, (audioCurrentTime / effectiveAudioDuration) * 100))
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const togglePlay = async () => {
|
|
||||||
if (!audioRef.current) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
audioRef.current.pause();
|
audioRef.current.pause();
|
||||||
} else {
|
} else {
|
||||||
await audioRef.current.play();
|
audioRef.current.play();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
setIsPlaying(!isPlaying);
|
||||||
console.error('No se pudo reproducir la nota de voz:', error);
|
|
||||||
setIsPlaying(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAudioSeek = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const nextTime = Number(event.target.value);
|
|
||||||
setAudioCurrentTime(nextTime);
|
|
||||||
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = nextTime;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setShowDriveCard(true);
|
|
||||||
}, [message.id, message.text]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setIsPlaying(false);
|
|
||||||
setAudioCurrentTime(0);
|
|
||||||
setAudioDuration(message.audio?.duration || 0);
|
|
||||||
|
|
||||||
if (message.audio) {
|
if (message.audio) {
|
||||||
const audio = new Audio(message.audio.url);
|
audioRef.current = new Audio(message.audio.url);
|
||||||
audio.preload = 'metadata';
|
audioRef.current.onended = () => setIsPlaying(false);
|
||||||
|
|
||||||
const updateCurrentTime = () => {
|
|
||||||
setAudioCurrentTime(audio.currentTime || 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
audio.onloadedmetadata = () => {
|
|
||||||
const loadedDuration = Number.isFinite(audio.duration) ? audio.duration : 0;
|
|
||||||
setAudioDuration(loadedDuration || message.audio?.duration || 0);
|
|
||||||
};
|
|
||||||
audio.ontimeupdate = updateCurrentTime;
|
|
||||||
audio.onseeking = updateCurrentTime;
|
|
||||||
audio.onseeked = updateCurrentTime;
|
|
||||||
audio.onplay = () => setIsPlaying(true);
|
|
||||||
audio.onpause = () => {
|
|
||||||
setIsPlaying(false);
|
|
||||||
updateCurrentTime();
|
|
||||||
};
|
|
||||||
audio.onended = () => {
|
|
||||||
setIsPlaying(false);
|
|
||||||
audio.currentTime = 0;
|
|
||||||
setAudioCurrentTime(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
audioRef.current = audio;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current.pause();
|
audioRef.current.pause();
|
||||||
audioRef.current.src = '';
|
|
||||||
audioRef.current = null;
|
audioRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -257,10 +104,6 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{driveFolderLink && showDriveCard && (
|
|
||||||
<DriveLinkCard url={driveFolderLink} onClose={() => setShowDriveCard(false)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{message.attachments && message.attachments.length > 0 && (
|
{message.attachments && message.attachments.length > 0 && (
|
||||||
<div className="mb-4 flex flex-wrap gap-3">
|
<div className="mb-4 flex flex-wrap gap-3">
|
||||||
{message.attachments.map((att) => (
|
{message.attachments.map((att) => (
|
||||||
@@ -270,50 +113,33 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{message.audio && (
|
{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="mb-4 flex items-center gap-3 rounded-2xl bg-black/20 p-3 pr-4">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={togglePlay}
|
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]"
|
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/15 transition hover:bg-white/25"
|
||||||
aria-label={isPlaying ? 'Pausar audio' : 'Reproducir audio'}
|
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
<Pause className="h-5 w-5" fill="currentColor" />
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<Play className="ml-0.5 h-5 w-5" fill="currentColor" />
|
<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>
|
</button>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/15">
|
||||||
<div className="mb-1.5 flex items-center justify-between gap-3">
|
<div
|
||||||
<span className="text-xs font-semibold text-[#4F758B]">
|
className={cn(
|
||||||
{isPlaying ? 'Reproduciendo' : 'Nota de voz'}
|
'h-full w-1/3 rounded-full bg-white/65',
|
||||||
</span>
|
isPlaying && 'animate-pulse'
|
||||||
<span className="shrink-0 text-xs font-mono text-[#6B8FA3]">
|
)}
|
||||||
{formatDuration(audioCurrentTime)} / {formatDuration(effectiveAudioDuration)}
|
/>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative h-5">
|
<span className="text-xs font-mono opacity-80">
|
||||||
<div className="absolute left-0 right-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-[#6CC24A]/20" />
|
0:{message.audio.duration.toString().padStart(2, '0')}
|
||||||
<div
|
</span>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -32,25 +32,25 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, w
|
|||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
onClick={onClose}
|
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
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
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">
|
<div className="flex items-center justify-between p-6 border-b border-white/5">
|
||||||
<h2 className="text-xl font-semibold text-[#4F758B]">Configuración de conexión</h2>
|
<h2 className="text-xl font-semibold text-white">Configuración de conexión</h2>
|
||||||
<button onClick={onClose} className="rounded-full p-2 text-[#6B8FA3] transition-colors hover:bg-[#EEF6E8] hover:text-[#4F758B]">
|
<button onClick={onClose} className="p-2 rounded-full hover:bg-white/5 text-zinc-400 hover:text-white transition-colors">
|
||||||
<X className="h-5 w-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6 p-6">
|
<div className="p-6 space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-[#4F758B]">
|
<label className="text-sm font-medium text-zinc-300 flex items-center gap-2">
|
||||||
<LinkIcon className="h-4 w-4 text-[#6CC24A]" />
|
<LinkIcon className="w-4 h-4 text-indigo-400" />
|
||||||
Webhook URL
|
Webhook URL
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -58,26 +58,26 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, w
|
|||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
placeholder="https://tu-endpoint.com/webhook"
|
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.
|
Endpoint donde se enviarán la nota de voz, imágenes, documentos y enlaces mediante una solicitud POST.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
onClick={onClose}
|
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
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
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
|
Guardar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-312
@@ -2,22 +2,12 @@
|
|||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: dark;
|
||||||
--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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
background: var(--glm-page);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body,
|
body,
|
||||||
@@ -27,156 +17,11 @@ body,
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--glm-page);
|
background: #060816;
|
||||||
font-family: Arial, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
overflow-x: hidden;
|
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 {
|
@layer utilities {
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -197,163 +42,11 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
background: rgba(79, 117, 139, 0.22);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
.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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
export type AttachmentType = 'image' | 'video' | 'audio' | 'document' | 'other';
|
export type AttachmentType = 'image' | 'video' | 'audio' | 'document' | 'other';
|
||||||
export type AttachmentCategory = 'general' | 'campaign';
|
|
||||||
|
|
||||||
export interface Attachment {
|
export interface Attachment {
|
||||||
id: string;
|
id: string;
|
||||||
file: File;
|
file: File;
|
||||||
type: AttachmentType;
|
type: AttachmentType;
|
||||||
url: string; // Object URL for preview
|
url: string; // Object URL for preview
|
||||||
category?: AttachmentCategory;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AudioRecording {
|
export interface AudioRecording {
|
||||||
|
|||||||
Reference in New Issue
Block a user