From 7c2582b209b65f82046182a4fb4b377539a902f5 Mon Sep 17 00:00:00 2001 From: bryancito1090 Date: Tue, 8 Sep 2026 11:46:41 -0500 Subject: [PATCH] feat: add n8n integration for AI chatbot with Cloudflare tunnel support - Create docker-compose configuration for n8n and Cloudflare tunnel. - Implement portfolio chat workflow for AI chatbot interactions. - Develop chat store to manage chat state and interactions. - Introduce chat configuration token for webhook URL. - Define chat message models and ports for communication. - Create n8n chat adapter for sending messages to the AI model. - Add necessary DTOs for chat requests and responses. - Design chat bot component with responsive UI and accessibility features. - Implement styles for chat bot interface and interactions. --- automation/n8n/SETUP-GUIDE.md | 89 ++++++ automation/n8n/bryan-knowledge-base.md | 86 ++++++ automation/n8n/docker-compose.yml | 35 +++ automation/n8n/portfolio-chat-workflow.json | 256 ++++++++++++++++++ src/frontend/src/app/app.config.ts | 5 +- .../src/app/application/stores/chat.store.ts | 154 +++++++++++ .../src/app/core/tokens/chat-config.token.ts | 7 + .../src/app/domain/models/chat.model.ts | 20 ++ .../src/app/domain/ports/chat.port.ts | 6 + .../adapters/n8n-chat.adapter.ts | 40 +++ .../app/infrastructure/dtos/n8n-chat.dto.ts | 11 + .../features/chat-bot/chat-bot.component.css | 255 +++++++++++++++++ .../features/chat-bot/chat-bot.component.html | 167 ++++++++++++ .../features/chat-bot/chat-bot.component.ts | 79 ++++++ .../features/hero-3d/hero-3d.component.ts | 132 +++++---- .../features/landing/landing.component.html | 3 + .../features/landing/landing.component.ts | 26 +- 17 files changed, 1302 insertions(+), 69 deletions(-) create mode 100644 automation/n8n/SETUP-GUIDE.md create mode 100644 automation/n8n/bryan-knowledge-base.md create mode 100644 automation/n8n/docker-compose.yml create mode 100644 automation/n8n/portfolio-chat-workflow.json create mode 100644 src/frontend/src/app/application/stores/chat.store.ts create mode 100644 src/frontend/src/app/core/tokens/chat-config.token.ts create mode 100644 src/frontend/src/app/domain/models/chat.model.ts create mode 100644 src/frontend/src/app/domain/ports/chat.port.ts create mode 100644 src/frontend/src/app/infrastructure/adapters/n8n-chat.adapter.ts create mode 100644 src/frontend/src/app/infrastructure/dtos/n8n-chat.dto.ts create mode 100644 src/frontend/src/app/presentation/features/chat-bot/chat-bot.component.css create mode 100644 src/frontend/src/app/presentation/features/chat-bot/chat-bot.component.html create mode 100644 src/frontend/src/app/presentation/features/chat-bot/chat-bot.component.ts diff --git a/automation/n8n/SETUP-GUIDE.md b/automation/n8n/SETUP-GUIDE.md new file mode 100644 index 0000000..8a5cbb5 --- /dev/null +++ b/automation/n8n/SETUP-GUIDE.md @@ -0,0 +1,89 @@ +# Guía de Despliegue y Puesta en Producción: n8n + Gemini Gratuito + Cloudflare + +Esta guía contiene los pasos exactos para tener tu agente conversacional funcionando en producción 100% gratis. + +--- + +## Paso 1: Obtener la API Key Gratuita de Google Gemini +1. Ingresa en [Google AI Studio](https://aistudio.google.com/). +2. Inicia sesión con tu cuenta de Google. +3. Haz clic en **"Get API key"** -> **"Create API key in new project"**. +4. Copia tu API key generada. + - *Nota*: La capa gratuita de Gemini 2.0 Flash / 1.5 Flash incluye **15 RPM (peticiones por minuto)** y **1,500 peticiones diarias gratis**, con 1,000,000 de tokens de contexto, lo cual cubre de sobra el tráfico de tu portafolio. + +--- + +## Paso 2: Despliegue de n8n en Oracle Cloud Always Free (o Localmente) + +### Opción A: En tu VPS Gratuito de Oracle Cloud (Recomendado 24/7) +1. Inicia sesión en tu consola de Oracle Cloud (Always Free). +2. Crea una instancia Compute gratuita (ej. Ubuntu 24.04 ARM Ampere o AMD x86 micro). +3. Instala Docker y Docker Compose: + ```bash + sudo apt update && sudo apt install -y docker.io docker-compose + sudo usermod -aG docker $USER + ``` +4. Clona o copia la carpeta `automation/n8n/` en tu servidor. +5. Inicia el contenedor: + ```bash + cd automation/n8n + docker compose up -d + ``` +6. n8n estará corriendo en el puerto interno `5678`. + +### Opción B: Probar primero en tu máquina local +1. En tu terminal (Arch Linux): + ```bash + cd /home/bryan/Projects/PORTFOLIO/automation/n8n + docker compose up -d + ``` +2. Abre tu navegador en `http://localhost:5678`. + +--- + +## Paso 3: Conectar Cloudflare Tunnel (SSL Gratuito y Sin Puertos Abiertos) +Dado que tu dominio y frontend ya están en Cloudflare: +1. Ve a [Cloudflare Zero Trust Dashboard](https://one.dash.cloudflare.com/) -> **Networks** -> **Tunnels**. +2. Haz clic en **"Create a Tunnel"** (nombre: `portfolio-n8n`). +3. Selecciona **Docker** y copia el token provisto (`eyJh...`). +4. En el archivo `automation/n8n/.env` (o directamente en tu servidor): + ```env + CLOUDFLARE_TUNNEL_TOKEN=eyJh...tu_token_aqui... + N8N_PUBLIC_WEBHOOK_URL=https://chat-api.tudominio.com/ + ``` +5. En la pestaña **Public Hostname** del túnel en Cloudflare: + - **Subdomain**: `chat-api` (o `n8n`) + - **Domain**: Tu dominio gestionado en Cloudflare (ej. `bryanbano.com`) + - **Service**: `HTTP` -> `n8n:5678` (o `localhost:5678` si ejecutas cloudflared como servicio). +6. ¡Listo! Tu n8n tendrá URL pública segura `https://chat-api.tudominio.com` con HTTPS y protección anti-DDoS sin tocar puertos de router. + +--- + +## Paso 4: Importar el Flujo de Trabajo en n8n +1. Abre tu panel de n8n (`http://localhost:5678` o tu subdominio de Cloudflare). +2. Crea tu cuenta de administrador de n8n en el primer inicio. +3. En el menú de la izquierda, haz clic en **"Workflows"** -> botón de **"..."** (arriba a la derecha) -> **"Import from File"**. +4. Selecciona el archivo `portfolio-chat-workflow.json`. +5. En el nodo **"Google Gemini Chat Model"**: + - Haz clic en **Credentials** -> **Create New**. + - Pega tu API Key obtenida en el Paso 1. +6. Haz clic en **Save** y enciende el interruptor **"Active"** (arriba a la derecha). +7. La URL de tu webhook será: + `https://chat-api.tudominio.com/webhook/portfolio-chat` (o `http://localhost:5678/webhook/portfolio-chat` en local). + +--- + +## Paso 5: Probar el Webhook desde la Terminal +Puedes verificar que n8n responde correctamente antes de probar el frontend ejecutando: +```bash +curl -X POST http://localhost:5678/webhook/portfolio-chat \ + -H "Content-Type: application/json" \ + -d '{"message": "¿Cuál es tu stack principal Bryan?", "sessionId": "test-123"}' +``` +Respuesta esperada: +```json +{ + "reply": "Hola. Mi stack principal es .NET 8/9 con Clean Architecture y CQRS en backend, y Angular con Signals en frontend...", + "sessionId": "test-123" +} +``` diff --git a/automation/n8n/bryan-knowledge-base.md b/automation/n8n/bryan-knowledge-base.md new file mode 100644 index 0000000..3ad13b0 --- /dev/null +++ b/automation/n8n/bryan-knowledge-base.md @@ -0,0 +1,86 @@ +# BASE DE CONOCIMIENTO: BRYAN BAÑO (FULLSTACK SOFTWARE ENGINEER) + +## 1. Identidad y Perfil Profesional +- **Nombre**: Bryan Baño +- **Ubicación**: Quito, Ecuador (Zona horaria: America/Guayaquil / UTC-5) +- **Título**: Fullstack Software Engineer | Especialista en .NET Clean Architecture & Angular Signals +- **Formación Académica**: Egresado de Ingeniería en Computación de la Escuela Politécnica Nacional (EPN), Quito, Ecuador. +- **Disponibilidad**: Abierto a nuevos retos profesionales, proyectos de consultoría, desarrollo de software a medida y posiciones Fullstack / Backend / Frontend de alto impacto. + +--- + +## 2. Filosofía de Ingeniería y Arquitectura +- **Clean Architecture (Arquitectura Limpia / Hexagonal)**: + - Desacoplamiento estricto de capas: Dominio puro (independiente de frameworks), Aplicación (Casos de uso / CQRS), Infraestructura (Adaptadores externos, BD, APIs) y Presentación. + - Inversión de Dependencias (DIP) y puertos & adaptadores. +- **Filosofía Ponytail (Simplicidad & Rendimiento)**: + - Rechazo a la sobreingeniería: priorizar la librería estándar, APIs nativas del navegador (Canvas 2D, Web Audio API, IntersectionObserver) y patrones limpios antes de instalar librerías pesadas de terceros. + - Código mantenible, testeable y optimizado para 60 FPS en web. +- **Estándares & Resiliencia**: + - Manejo de errores uniforme con RFC 7807 (ProblemDetails). + - Tipado estricto extremo, inmutabilidad y DTOs bien definidos. + +--- + +## 3. Stack Tecnológico Principal +- **Backend**: + - .NET 8 / 9 (C#) + - MediatR & Patrón CQRS (Command Query Responsibility Segregation) + - Entity Framework Core (EF Core) con optimización de queries y migraciones + - FluentValidation para validación declarativa de casos de uso + - RESTful APIs seguras, autenticación JWT y autorización basada en roles (RBAC) +- **Frontend**: + - Angular (versiones modernas 18, 19, 20) + - Angular Signals reactivos nativos (`signal`, `computed`, `effect`) + - Estrategia de detección de cambios `ChangeDetectionStrategy.OnPush` + - Sintaxis moderna de control de flujo (`@if`, `@for`, `@switch`) + - HTML5 Canvas 2D acelerado, CSS moderno, diseño Cyber-Zen / Dark Mode +- **Bases de Datos & Almacenamiento**: + - MySQL / MariaDB (indexación, optimización de queries relacionales) + - PostgreSQL + - Redis (caching y sesiones) +- **DevOps, Cloud & Entorno**: + - Docker & Docker Compose para contenerización consistente + - Cloudflare (Pages, Workers, Tunnels, DNS, WAF) + - AWS (EC2, S3, RDS) + - Sistema operativo de desarrollo diario: Arch Linux con Fish Shell + - Automatización con n8n, GitHub Actions y herramientas de IA + +--- + +## 4. Certificaciones y Seguridad +- **EC-Council**: NDE (Network Defense Essentials). +- **Cisco Networking Academy**: Redes y Endpoint Security. +- Buenas prácticas de seguridad en la nube, hardening de contenedores y protección de APIs. + +--- + +## 5. Proyectos Destacados del Portafolio +1. **Sistema de Auditoría & Acreditación CACES**: + - Plataforma robusta desarrollada para gestión y validación de evidencias académicas para acreditación universitaria. + - Arquitectura limpia en .NET, control de acceso granular y trazabilidad documental. +2. **Simulador de Respuesta a Incidentes Cloud (Interactive Security Simulator)**: + - Módulo interactivo dentro del portafolio que simula ataques en tiempo real y muestra la mitigación mediante arquitecturas serverless y seguridad en la nube. +3. **Portafolio Profesional Interactivo (Cyber-Zen & Retro Computing)**: + - Aplicación web desarrollada con Angular 20, Clean Architecture, Signals, OnPush. + - Incluye experiencia 3D en scroll (laptop desarmable con Three.js), terminal interactiva Linux, mini-sistema operativo retro (ZenOS) con minijuegos en Canvas 2D y probador de API en vivo. +4. **Agente Conversacional con n8n & Gemini**: + - Chatbot inteligente integrado que responde dudas técnicas y filtra oportunidades laborales en tiempo real. + +--- + +## 6. Canales de Contacto Oficiales +- **Email**: Disponible mediante el formulario de contacto del portafolio. +- **LinkedIn**: Perfil profesional de Bryan Baño. +- **GitHub**: Repositorios de código y proyectos de código abierto. +- **Ubicación**: Quito, Ecuador (remoto a nivel global). + +--- + +## 7. Instrucciones y Directrices para el Asistente de IA +- **Rol**: Eres el asistente virtual oficial de Bryan Baño en su portafolio web. +- **Tono**: Profesional, empático, técnico, conciso y elegante. +- **Idioma**: Responde siempre en el mismo idioma en el que te escribe el usuario (Español o Inglés). +- **Extensión**: Respuestas breves y precisas (máximo 2-3 párrafos cortos o viñetas claras). No abrumes con textos interminables. +- **Exactitud**: Limítate a los datos provistos en esta base de conocimiento. Si te preguntan algo que no conoces de Bryan, sugiere amablemente que le escriban directamente a través de la sección de Contacto del portafolio. +- **Llamado a la acción (CTA)**: Si el usuario muestra interés en contratarlo o colaborar, anímalo a dejar un mensaje en el formulario de contacto o conectar por LinkedIn. diff --git a/automation/n8n/docker-compose.yml b/automation/n8n/docker-compose.yml new file mode 100644 index 0000000..fa97cd0 --- /dev/null +++ b/automation/n8n/docker-compose.yml @@ -0,0 +1,35 @@ +version: '3.8' + +services: + n8n: + image: docker.n8n.io/n8nio/n8n:latest + container_name: portfolio_n8n + restart: always + ports: + - "5678:5678" + environment: + - N8N_HOST=0.0.0.0 + - N8N_PORT=5678 + - N8N_PROTOCOL=http + - WEBHOOK_URL=${N8N_PUBLIC_WEBHOOK_URL:-http://localhost:5678/} + - GENERIC_TIMEZONE=America/Guayaquil + - N8N_DEFAULT_BINARY_DATA_MODE=filesystem + - EXECUTIONS_DATA_PRUNE=true + - EXECUTIONS_DATA_MAX_AGE=168 + volumes: + - n8n_data:/home/node/.n8n + + # Cloudflare Tunnel: Expone n8n a internet de forma segura, con HTTPS y sin abrir puertos en el router/firewall + # Para activarlo: obtén el token de tu túnel en el panel de Cloudflare Zero Trust y colócalo en el archivo .env + cloudflared: + image: cloudflare/cloudflared:latest + container_name: portfolio_tunnel + restart: always + command: tunnel run + environment: + - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN:-} + depends_on: + - n8n + +volumes: + n8n_data: diff --git a/automation/n8n/portfolio-chat-workflow.json b/automation/n8n/portfolio-chat-workflow.json new file mode 100644 index 0000000..c985471 --- /dev/null +++ b/automation/n8n/portfolio-chat-workflow.json @@ -0,0 +1,256 @@ +{ + "name": "Portfolio Bryan - AI Chatbot", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "portfolio-chat", + "responseMode": "responseNode", + "options": { + "allowedOrigins": "*" + } + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 220, + 340 + ], + "id": "webhook-trigger", + "name": "Webhook - Incoming Chat" + }, + { + "parameters": { + "jsCode": "// Sanitización y validación de entrada para proteger cuota de API y prevenir abusos\nconst body = $input.item.json.body || {};\nconst rawMessage = typeof body.message === 'string' ? body.message.trim() : '';\nconst sessionId = typeof body.sessionId === 'string' && body.sessionId ? body.sessionId.slice(0, 64) : 'anon-' + Date.now();\n\nif (!rawMessage) {\n return [{\n json: {\n isValid: false,\n errorMessage: 'El mensaje no puede estar vacío.',\n sessionId\n }\n }];\n}\n\n// Limitar a máximo 400 caracteres para cuidar tokens gratuitos\nconst cleanMessage = rawMessage.slice(0, 400).replace(/<[^>]*>?/gm, '');\n\nreturn [{\n json: {\n isValid: true,\n cleanMessage,\n sessionId\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 460, + 340 + ], + "id": "code-sanitizer", + "name": "Sanitize & Validate" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "cond-is-valid", + "leftValue": "={{ $json.isValid }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equals" + } + } + ], + "combinator": "and" + } + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 680, + 340 + ], + "id": "if-valid-input", + "name": "Is Input Valid?" + }, + { + "parameters": { + "promptType": "define", + "text": "={{ $('Sanitize & Validate').item.json.cleanMessage }}", + "options": { + "systemMessage": "=Eres el asistente virtual oficial de Bryan Baño en su portafolio web personal.\n\n### CONTEXTO DE BRYAN BAÑO:\n- Ingeniero en Computación (EPN, Quito - Ecuador).\n- Especialidad: .NET 8/9 Clean Architecture (CQRS MediatR, EF Core MySQL) y Angular Signals (OnPush, Canvas 2D).\n- Filosofía de Software: Ponytail (Simplicidad extrema, YAGNI, cero sobreingeniería, Web Platform nativa, 60 FPS).\n- Proyectos Destacados: Auditoría CACES (.NET), Incident Simulator Cloud Security (Serverless), Portafolio Interactivo Cyber-Zen.\n- Certificaciones: EC-Council Network Defense Essentials (NDE), Cisco Networking Academy.\n- Ubicación: Quito, Ecuador. Disponible para proyectos y contrataciones remotas globales.\n\n### REGLAS DE RESPUESTA:\n1. Responde siempre en el MISMO idioma del usuario (Español o Inglés).\n2. Sé técnico, conciso, profesional y cálido (máximo 2 a 3 párrafos o viñetas breves).\n3. No inventes experiencia que no esté en este contexto.\n4. Si el visitante desea contactar a Bryan o contratarlo, indícale amablemente que use el formulario de contacto del portafolio o su perfil de LinkedIn." + } + }, + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 1.7, + "position": [ + 920, + 240 + ], + "id": "ai-agent-bryan", + "name": "AI Agent - Bryan Assistant" + }, + { + "parameters": { + "modelName": "models/gemini-2.0-flash", + "options": { + "temperature": 0.4 + } + }, + "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini", + "typeVersion": 1, + "position": [ + 880, + 480 + ], + "id": "gemini-model", + "name": "Google Gemini Chat Model", + "credentials": { + "googleGeminiApi": { + "id": "gemini-free-cred", + "name": "Google Gemini Free API" + } + } + }, + { + "parameters": { + "sessionKey": "={{ $('Sanitize & Validate').item.json.sessionId }}", + "contextWindowLength": 8 + }, + "type": "@n8n/n8n-nodes-langchain.memoryBufferWindow", + "typeVersion": 1.3, + "position": [ + 1040, + 480 + ], + "id": "window-memory", + "name": "Window Buffer Memory" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={\n \"reply\": {{ JSON.stringify($json.output) }},\n \"sessionId\": {{ JSON.stringify($('Sanitize & Validate').item.json.sessionId) }}\n}", + "options": { + "responseCode": 200, + "responseHeaders": { + "entries": [ + { + "name": "Access-Control-Allow-Origin", + "value": "*" + }, + { + "name": "Access-Control-Allow-Methods", + "value": "POST, OPTIONS" + }, + { + "name": "Access-Control-Allow-Headers", + "value": "Content-Type" + } + ] + } + } + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 1220, + 240 + ], + "id": "respond-success", + "name": "Respond - Success" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={\n \"reply\": {{ JSON.stringify($json.errorMessage || 'Mensaje no válido.') }},\n \"sessionId\": {{ JSON.stringify($json.sessionId) }}\n}", + "options": { + "responseCode": 400, + "responseHeaders": { + "entries": [ + { + "name": "Access-Control-Allow-Origin", + "value": "*" + } + ] + } + } + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 1220, + 420 + ], + "id": "respond-error", + "name": "Respond - Error" + } + ], + "connections": { + "Webhook - Incoming Chat": { + "main": [ + [ + { + "node": "Sanitize & Validate", + "type": "main", + "index": 0 + } + ] + ] + }, + "Sanitize & Validate": { + "main": [ + [ + { + "node": "Is Input Valid?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Is Input Valid?": { + "main": [ + [ + { + "node": "AI Agent - Bryan Assistant", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Respond - Error", + "type": "main", + "index": 0 + } + ] + ] + }, + "AI Agent - Bryan Assistant": { + "main": [ + [ + { + "node": "Respond - Success", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Gemini Chat Model": { + "ai_languageModel": [ + [ + { + "node": "AI Agent - Bryan Assistant", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "Window Buffer Memory": { + "ai_memory": [ + [ + { + "node": "AI Agent - Bryan Assistant", + "type": "ai_memory", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/src/frontend/src/app/app.config.ts b/src/frontend/src/app/app.config.ts index 7cf75c1..1c8ce0a 100644 --- a/src/frontend/src/app/app.config.ts +++ b/src/frontend/src/app/app.config.ts @@ -4,12 +4,15 @@ import { provideHttpClient } from '@angular/common/http'; import { routes } from './app.routes'; import { RetroScorePort } from './domain/ports/retro-score.port'; import { LocalStorageRetroScoreAdapter } from './infrastructure/adapters/local-storage-retro-score.adapter'; +import { ChatPort } from './domain/ports/chat.port'; +import { N8nChatAdapter } from './infrastructure/adapters/n8n-chat.adapter'; export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes, withComponentInputBinding()), provideHttpClient(), - { provide: RetroScorePort, useClass: LocalStorageRetroScoreAdapter } + { provide: RetroScorePort, useClass: LocalStorageRetroScoreAdapter }, + { provide: ChatPort, useClass: N8nChatAdapter } ] }; diff --git a/src/frontend/src/app/application/stores/chat.store.ts b/src/frontend/src/app/application/stores/chat.store.ts new file mode 100644 index 0000000..3b64872 --- /dev/null +++ b/src/frontend/src/app/application/stores/chat.store.ts @@ -0,0 +1,154 @@ +import { Injectable, inject, signal, computed } from '@angular/core'; +import { ChatPort } from '../../domain/ports/chat.port'; +import { ChatMessage } from '../../domain/models/chat.model'; +import { RetroAudioService } from '../services/retro-audio.service'; +import { I18nService } from '../services/i18n.service'; + +const SESSION_STORAGE_KEY = 'bryan_portfolio_chat_session'; + +@Injectable({ providedIn: 'root' }) +export class ChatStore { + private readonly chatPort = inject(ChatPort); + private readonly audio = inject(RetroAudioService); + private readonly i18n = inject(I18nService); + + private readonly _isOpen = signal(false); + private readonly _isTyping = signal(false); + private readonly _unreadCount = signal(0); + private readonly _errorMessage = signal(null); + private readonly _history = signal([]); + private readonly _sessionId = signal(this.initSessionId()); + + readonly isOpen = this._isOpen.asReadonly(); + readonly isTyping = this._isTyping.asReadonly(); + readonly unreadCount = this._unreadCount.asReadonly(); + readonly errorMessage = this._errorMessage.asReadonly(); + readonly hasUnread = computed(() => this._unreadCount() > 0); + + // Mensajes computados de forma reactiva y pura, sin efectos que causen NG0600 + readonly messages = computed(() => { + const isEs = this.i18n.currentLang() === 'es'; + const greeting: ChatMessage = { + id: 'greeting', + role: 'assistant', + content: isEs + ? 'Hola, soy el asistente virtual de Bryan Baño. Puedo responder dudas sobre su perfil, Clean Architecture en .NET / Angular o sus proyectos. ¿En qué te puedo orientar?' + : "Hello, I am Bryan Baño's virtual assistant. I can answer questions about his skills, Clean Architecture in .NET / Angular, or his projects. How can I assist you?", + timestamp: 'Zen AI', + status: 'sent' + }; + return [greeting, ...this._history()]; + }); + + readonly quickPrompts = computed(() => { + const isEs = this.i18n.currentLang() === 'es'; + return isEs + ? [ + '¿Cuál es tu stack principal?', + '¿Qué proyectos has realizado?', + '¿Cómo puedo contactarte?' + ] + : [ + 'What is your main tech stack?', + 'What projects have you built?', + 'How can I get in touch?' + ]; + }); + + private initSessionId(): string { + if (typeof window === 'undefined') return `session-${Date.now()}`; + try { + const saved = sessionStorage.getItem(SESSION_STORAGE_KEY); + if (saved) return saved; + const newId = + typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : `session-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + sessionStorage.setItem(SESSION_STORAGE_KEY, newId); + return newId; + } catch { + return `session-${Date.now()}`; + } + } + + toggleChat(): void { + const nextState = !this._isOpen(); + this._isOpen.set(nextState); + if (nextState) { + this._unreadCount.set(0); + this.audio.playClick(); + } + } + + openChat(): void { + this._isOpen.set(true); + this._unreadCount.set(0); + this.audio.playClick(); + } + + closeChat(): void { + this._isOpen.set(false); + } + + clearHistory(): void { + this._errorMessage.set(null); + this._history.set([]); + this.audio.playClick(); + } + + sendMessage(rawText: string): void { + const text = rawText.trim(); + if (!text || this._isTyping()) return; + + this._errorMessage.set(null); + + const timeStr = typeof Intl !== 'undefined' + ? new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : ''; + + const userMessage: ChatMessage = { + id: `usr-${Date.now()}`, + role: 'user', + content: text, + timestamp: timeStr, + status: 'sent' + }; + + this._history.update(prev => [...prev, userMessage]); + this._isTyping.set(true); + this.audio.playClick(); + + this.chatPort.sendMessage({ message: text, sessionId: this._sessionId() }).subscribe({ + next: res => { + const assistantMessage: ChatMessage = { + id: `bot-${Date.now()}`, + role: 'assistant', + content: res.reply, + timestamp: timeStr, + status: 'sent' + }; + + this._history.update(prev => [...prev, assistantMessage]); + this._isTyping.set(false); + + if (!this._isOpen()) { + this._unreadCount.update(c => c + 1); + } + this.audio.playHighScore(); + }, + error: err => { + this._isTyping.set(false); + this._errorMessage.set(err.message ?? 'No se pudo recibir respuesta del asistente.'); + + const errorAssistantMsg: ChatMessage = { + id: `err-${Date.now()}`, + role: 'assistant', + content: err.message ?? 'Lo siento, ocurrió un error de conexión con el agente.', + timestamp: timeStr, + status: 'error' + }; + this._history.update(prev => [...prev, errorAssistantMsg]); + } + }); + } +} diff --git a/src/frontend/src/app/core/tokens/chat-config.token.ts b/src/frontend/src/app/core/tokens/chat-config.token.ts new file mode 100644 index 0000000..41188cf --- /dev/null +++ b/src/frontend/src/app/core/tokens/chat-config.token.ts @@ -0,0 +1,7 @@ +import { InjectionToken } from '@angular/core'; + +export const CHAT_WEBHOOK_URL = new InjectionToken('CHAT_WEBHOOK_URL', { + providedIn: 'root', + // Por defecto apunta a un endpoint relativo o variable local; se puede sobreescribir en app.config.ts + factory: () => 'https://chat-api.bryanbano.com/webhook/portfolio-chat' +}); diff --git a/src/frontend/src/app/domain/models/chat.model.ts b/src/frontend/src/app/domain/models/chat.model.ts new file mode 100644 index 0000000..9e8d94a --- /dev/null +++ b/src/frontend/src/app/domain/models/chat.model.ts @@ -0,0 +1,20 @@ +export type ChatMessageRole = 'user' | 'assistant' | 'system'; +export type ChatMessageStatus = 'sending' | 'sent' | 'error'; + +export interface ChatMessage { + readonly id: string; + readonly role: ChatMessageRole; + readonly content: string; + readonly timestamp: string; + readonly status: ChatMessageStatus; +} + +export interface ChatPayload { + readonly message: string; + readonly sessionId: string; +} + +export interface ChatResponse { + readonly reply: string; + readonly sessionId: string; +} diff --git a/src/frontend/src/app/domain/ports/chat.port.ts b/src/frontend/src/app/domain/ports/chat.port.ts new file mode 100644 index 0000000..7ef2b09 --- /dev/null +++ b/src/frontend/src/app/domain/ports/chat.port.ts @@ -0,0 +1,6 @@ +import { Observable } from 'rxjs'; +import { ChatPayload, ChatResponse } from '../models/chat.model'; + +export abstract class ChatPort { + abstract sendMessage(payload: ChatPayload): Observable; +} diff --git a/src/frontend/src/app/infrastructure/adapters/n8n-chat.adapter.ts b/src/frontend/src/app/infrastructure/adapters/n8n-chat.adapter.ts new file mode 100644 index 0000000..902ba4b --- /dev/null +++ b/src/frontend/src/app/infrastructure/adapters/n8n-chat.adapter.ts @@ -0,0 +1,40 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, map, catchError, throwError } from 'rxjs'; +import { ChatPort } from '../../domain/ports/chat.port'; +import { ChatPayload, ChatResponse } from '../../domain/models/chat.model'; +import { N8nChatRequestDto, N8nChatResponseDto } from '../dtos/n8n-chat.dto'; +import { CHAT_WEBHOOK_URL } from '../../core/tokens/chat-config.token'; + +@Injectable({ providedIn: 'root' }) +export class N8nChatAdapter implements ChatPort { + private readonly http = inject(HttpClient); + private readonly webhookUrl = inject(CHAT_WEBHOOK_URL); + + sendMessage(payload: ChatPayload): Observable { + const requestDto: N8nChatRequestDto = { + message: payload.message, + sessionId: payload.sessionId + }; + + return this.http.post(this.webhookUrl, requestDto).pipe( + map(res => { + const replyText = res.reply ?? res.output ?? res.text ?? ''; + if (!replyText) { + throw new Error('Respuesta vacía del asistente.'); + } + return { + reply: replyText, + sessionId: res.sessionId ?? payload.sessionId + }; + }), + catchError(err => { + const errorMsg = + err.status === 0 + ? 'No se pudo conectar con el agente de n8n. Por favor verifica que el webhook esté activo.' + : err.error?.reply || err.message || 'Error temporal al procesar tu consulta con la IA.'; + return throwError(() => new Error(errorMsg)); + }) + ); + } +} diff --git a/src/frontend/src/app/infrastructure/dtos/n8n-chat.dto.ts b/src/frontend/src/app/infrastructure/dtos/n8n-chat.dto.ts new file mode 100644 index 0000000..74d37b3 --- /dev/null +++ b/src/frontend/src/app/infrastructure/dtos/n8n-chat.dto.ts @@ -0,0 +1,11 @@ +export interface N8nChatRequestDto { + readonly message: string; + readonly sessionId: string; +} + +export interface N8nChatResponseDto { + readonly reply?: string; + readonly output?: string; + readonly text?: string; + readonly sessionId?: string; +} diff --git a/src/frontend/src/app/presentation/features/chat-bot/chat-bot.component.css b/src/frontend/src/app/presentation/features/chat-bot/chat-bot.component.css new file mode 100644 index 0000000..9c1075d --- /dev/null +++ b/src/frontend/src/app/presentation/features/chat-bot/chat-bot.component.css @@ -0,0 +1,255 @@ +:host { display: block; } + +.zen-chat-container { + position: fixed; + bottom: 38px; + right: 38px; + z-index: 1050; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 12px; + font-family: var(--font-sans); +} + +/* Wrapper y Botón Circular Minimalista de Robot Centrado */ +.launcher-wrapper { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.launcher-pulse-ring { + position: absolute; + inset: -6px; + border-radius: 50%; + border: 1.5px solid var(--zen-accent-kintsugi); + pointer-events: none; + animation: zenPulseRing 2.6s cubic-bezier(0.25, 1, 0.5, 1) infinite; +} + +@keyframes zenPulseRing { + 0% { transform: scale(0.92); opacity: 0.85; } + 65%, 100% { transform: scale(1.38); opacity: 0; } +} + +.chat-launcher-btn { + position: relative; + width: 58px; + height: 58px; + padding: 0; + margin: 0; + border-radius: 50%; + background: rgba(16, 20, 28, 0.94); + backdrop-filter: blur(16px) saturate(180%); + border: 1.5px solid var(--zen-border-kintsugi); + color: var(--zen-accent-kintsugi); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.75), 0 0 18px rgba(197, 155, 39, 0.3); + transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), background 0.2s ease, box-shadow 0.25s ease; +} + +.chat-launcher-btn:hover { + transform: translateY(-3px) scale(1.07); + background: rgba(22, 28, 38, 0.98); + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.85), 0 0 26px rgba(197, 155, 39, 0.55); +} + +.chat-launcher-btn.active { + border-color: var(--zen-border-focus); + color: var(--zen-text-primary); +} + +.launcher-icon-wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} + +.launcher-bot-svg { + width: 30px; + height: 30px; + color: var(--zen-accent-kintsugi); + display: block; +} + +.launcher-close-svg { + width: 24px; + height: 24px; + color: var(--zen-text-primary); +} + +.launcher-status-dot { + position: absolute; + top: 2px; + right: 2px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--zen-accent-matcha); + border: 2px solid var(--zen-bg-base); + display: flex; + align-items: center; + justify-content: center; +} + +.status-radar-ping { + position: absolute; + inset: -2px; + border-radius: 50%; + background: var(--zen-accent-matcha); + animation: zenPing 2s cubic-bezier(0, 0, 0.2, 1) infinite; + opacity: 0.7; +} +@keyframes zenPing { 70%, 100% { transform: scale(2.2); opacity: 0; } } + +.launcher-badge { + position: absolute; + top: -4px; + left: -4px; + min-width: 19px; + height: 19px; + padding: 0 4px; + border-radius: 10px; + background: var(--zen-accent-kintsugi); + color: #000; + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; +} + +/* Ventana de Chat Flotante */ +.chat-window-card { + width: 360px; + height: 490px; + max-height: calc(100vh - 120px); + background: rgba(18, 21, 27, 0.96); + backdrop-filter: blur(16px) saturate(160%); + border: 1px solid var(--zen-border-subtle); + border-radius: var(--zen-radius-sm); + box-shadow: 0 14px 42px rgba(0, 0, 0, 0.85), 0 0 1px 1px rgba(255, 255, 255, 0.06); + display: flex; + flex-direction: column; + overflow: hidden; + animation: zenChatEnter 0.24s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +@keyframes zenChatEnter { + from { opacity: 0; transform: translateY(14px) scale(0.95); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.chat-header { + padding: 10px 14px; + background: rgba(12, 14, 20, 0.95); + border-bottom: 1px solid var(--zen-border-subtle); + display: flex; + align-items: center; + justify-content: space-between; +} +.header-identity { display: flex; align-items: center; gap: 9px; } +.avatar-ring { + width: 26px; + height: 26px; + border-radius: 50%; + border: 1px solid var(--zen-accent-kintsugi); + display: flex; + align-items: center; + justify-content: center; + color: var(--zen-accent-kintsugi); + background: #090c10; +} +.header-bot-svg { width: 16px; height: 16px; } +.identity-info { display: flex; flex-direction: column; } +.identity-name { font-size: 0.8125rem; font-weight: 700; color: var(--zen-text-primary); line-height: 1.2; } +.identity-meta { font-size: 0.65rem; font-family: var(--font-mono); color: var(--zen-accent-matcha); } + +.header-actions { display: flex; align-items: center; gap: 3px; } +.header-btn { + background: transparent; + border: none; + color: var(--zen-text-secondary); + padding: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s ease; +} +.header-btn:hover { color: var(--zen-accent-kintsugi); } +.action-svg { width: 16px; height: 16px; } + +/* Mensajes */ +.chat-messages-area { flex: 1; padding: 12px; overflow-y: auto; display: flex; flex-direction: column; gap: 9px; } +.message-row { display: flex; width: 100%; animation: zenMsgSlide 0.2s cubic-bezier(0.16, 1, 0.3, 1) both; } +@keyframes zenMsgSlide { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } + +.user-row { justify-content: flex-end; } +.bot-row { justify-content: flex-start; } +.message-bubble { + max-width: 86%; padding: 8px 12px; border-radius: var(--zen-radius-sm); + font-size: 0.8125rem; line-height: 1.45; word-break: break-word; +} +.user-row .message-bubble { background: var(--zen-bg-elevated); border: 1px solid rgba(197, 155, 39, 0.45); color: var(--zen-text-primary); } +.bot-row .message-bubble { background: var(--zen-bg-surface); border: 1px solid var(--zen-border-subtle); color: var(--zen-text-primary); } +.error-bubble { border-color: var(--zen-accent-sakura) !important; color: #ff99aa !important; } +.message-text { margin: 0; white-space: pre-wrap; } +.message-time { display: block; font-size: 0.6rem; font-family: var(--font-mono); color: var(--zen-text-muted); text-align: right; margin-top: 3px; } + +/* Typing & Chips */ +.typing-bubble { display: flex; align-items: center; gap: 4px; padding: 8px 12px; } +.typing-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--zen-accent-kintsugi); animation: typingBlink 1.2s infinite ease-in-out both; } +.typing-dot:nth-child(1) { animation-delay: -0.32s; } .typing-dot:nth-child(2) { animation-delay: -0.16s; } +@keyframes typingBlink { 0%, 80%, 100% { opacity: 0.2; transform: scale(0.8); } 40% { opacity: 1; transform: scale(1.2); } } + +.quick-prompts-group { margin-top: 4px; display: flex; flex-direction: column; gap: 5px; } +.quick-title { font-size: 0.65rem; font-family: var(--font-mono); color: var(--zen-text-muted); } +.chips-container { display: flex; flex-direction: column; gap: 4px; } +.quick-chip-btn { + text-align: left; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--zen-border-subtle); + border-radius: var(--zen-radius-xs); padding: 5px 9px; color: var(--zen-text-secondary); + font-size: 0.725rem; cursor: pointer; transition: all 0.15s ease; +} +.quick-chip-btn:hover { border-color: var(--zen-accent-kintsugi); color: var(--zen-text-gold); background: rgba(197, 155, 39, 0.08); } + +/* Input Bar */ +.chat-input-bar { + padding: 7px 9px; background: rgba(12, 14, 20, 0.95); border-top: 1px solid var(--zen-border-subtle); + display: flex; align-items: center; gap: 6px; +} +.chat-text-input { + flex: 1; background: var(--zen-bg-surface); border: 1px solid var(--zen-border-subtle); + border-radius: var(--zen-radius-xs); padding: 7px 10px; color: var(--zen-text-primary); + font-size: 0.8125rem; outline: none; +} +.chat-text-input:focus { border-color: var(--zen-border-kintsugi); } +.chat-send-btn { + background: var(--zen-bg-surface); border: 1px solid var(--zen-border-kintsugi); color: var(--zen-accent-kintsugi); + border-radius: var(--zen-radius-xs); padding: 6px 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; + transition: all 0.15s ease; +} +.chat-send-btn:hover:not(:disabled) { background: var(--zen-accent-kintsugi); color: #000; } +.chat-send-btn:disabled { opacity: 0.35; cursor: not-allowed; border-color: var(--zen-border-subtle); color: var(--zen-text-muted); } +.send-svg { width: 14px; height: 14px; } + +/* Accesibilidad & Móvil */ +@media (prefers-reduced-motion: reduce) { + .launcher-pulse-ring, .status-radar-ping { animation: none !important; } +} + +@media (max-width: 480px) { + .zen-chat-container { bottom: 24px; right: 20px; left: 20px; align-items: stretch; } + .launcher-wrapper { align-self: flex-end; } + .chat-launcher-btn { width: 52px; height: 52px; } + .launcher-bot-svg { width: 26px; height: 26px; } + .chat-window-card { width: 100%; height: 430px; max-height: calc(100vh - 100px); } +} diff --git a/src/frontend/src/app/presentation/features/chat-bot/chat-bot.component.html b/src/frontend/src/app/presentation/features/chat-bot/chat-bot.component.html new file mode 100644 index 0000000..dc644eb --- /dev/null +++ b/src/frontend/src/app/presentation/features/chat-bot/chat-bot.component.html @@ -0,0 +1,167 @@ + diff --git a/src/frontend/src/app/presentation/features/chat-bot/chat-bot.component.ts b/src/frontend/src/app/presentation/features/chat-bot/chat-bot.component.ts new file mode 100644 index 0000000..75f62d6 --- /dev/null +++ b/src/frontend/src/app/presentation/features/chat-bot/chat-bot.component.ts @@ -0,0 +1,79 @@ +import { + Component, + ChangeDetectionStrategy, + inject, + ViewChild, + ElementRef, + effect, + AfterViewInit +} from '@angular/core'; +import { ChatStore } from '../../../application/stores/chat.store'; +import { I18nService } from '../../../application/services/i18n.service'; + +@Component({ + selector: 'app-chat-bot', + standalone: true, + templateUrl: './chat-bot.component.html', + styleUrl: './chat-bot.component.css', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ChatBotComponent implements AfterViewInit { + protected readonly store = inject(ChatStore); + protected readonly i18n = inject(I18nService); + + @ViewChild('messagesContainer') private messagesContainer?: ElementRef; + @ViewChild('chatInput') private chatInput?: ElementRef; + + constructor() { + // Desplazar automáticamente hacia el final al recibir un mensaje o iniciar respuesta + effect(() => { + this.store.messages(); + this.store.isTyping(); + this.scrollToBottom(); + }); + + // Enfocar el input al abrir el chat + effect(() => { + if (this.store.isOpen()) { + setTimeout(() => this.chatInput?.nativeElement?.focus(), 150); + } + }); + } + + ngAfterViewInit(): void { + this.scrollToBottom(); + } + + protected onSend(inputEl: HTMLInputElement): void { + const val = inputEl.value; + if (!val.trim() || this.store.isTyping()) return; + this.store.sendMessage(val); + inputEl.value = ''; + this.scrollToBottom(); + } + + protected onSelectPrompt(prompt: string): void { + if (this.store.isTyping()) return; + this.store.sendMessage(prompt); + this.scrollToBottom(); + } + + protected onKeyDown(event: KeyboardEvent, inputEl: HTMLInputElement): void { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.onSend(inputEl); + } else if (event.key === 'Escape') { + this.store.closeChat(); + } + } + + private scrollToBottom(): void { + if (typeof window === 'undefined') return; + setTimeout(() => { + if (this.messagesContainer?.nativeElement) { + const el = this.messagesContainer.nativeElement; + el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); + } + }, 60); + } +} diff --git a/src/frontend/src/app/presentation/features/hero-3d/hero-3d.component.ts b/src/frontend/src/app/presentation/features/hero-3d/hero-3d.component.ts index cb47329..6d2e953 100644 --- a/src/frontend/src/app/presentation/features/hero-3d/hero-3d.component.ts +++ b/src/frontend/src/app/presentation/features/hero-3d/hero-3d.component.ts @@ -15,6 +15,8 @@ import { I18nService } from '../../../application/services/i18n.service'; const TOTAL_FRAMES = 240; const LERP_FACTOR = 0.09; +const SHARED_IMAGE_CACHE: HTMLImageElement[] = new Array(TOTAL_FRAMES + 1); +let isPreloadDone = false; @Component({ selector: 'app-hero-3d', @@ -32,13 +34,11 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy { protected readonly i18n = inject(I18nService); private readonly ngZone = inject(NgZone); - // Señales reactivas: Fase activa de tarjetas laterales y estado de carga protected readonly activePhase = signal(1); protected readonly loadProgress = signal(0); protected readonly isLoaded = signal(false); private ctx: CanvasRenderingContext2D | null = null; - private readonly images: HTMLImageElement[] = new Array(TOTAL_FRAMES + 1); private targetFrame = 1; private currentInterpolatedFrame = 1; private lastRenderedFrame = 0; @@ -50,12 +50,11 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy { private mediaQueryList?: MediaQueryList; ngOnInit(): void { - if (this.initialPosition() === 'bottom') { - this.targetFrame = TOTAL_FRAMES; - this.currentInterpolatedFrame = TOTAL_FRAMES; - this.lastRenderedFrame = TOTAL_FRAMES; - this.activePhase.set(3); - } + const isBottom = this.initialPosition() === 'bottom'; + this.targetFrame = isBottom ? TOTAL_FRAMES : 1; + this.currentInterpolatedFrame = isBottom ? TOTAL_FRAMES : 1; + this.lastRenderedFrame = isBottom ? TOTAL_FRAMES : 1; + this.activePhase.set(isBottom ? 3 : 1); this.checkReducedMotion(); if (typeof window !== 'undefined' && window.innerWidth <= 900) { @@ -93,15 +92,16 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy { if (typeof window !== 'undefined' && window.innerWidth > 900) { const container = this.containerRef.nativeElement; - const scrollableDistance = container.offsetHeight - window.innerHeight; + const isBottom = this.initialPosition() === 'bottom'; - if (this.initialPosition() === 'bottom' && scrollableDistance > 0) { - window.scrollTo({ top: scrollableDistance, behavior: 'instant' }); + if (isBottom) { + const scrollableDistance = container.offsetHeight - window.innerHeight; + window.scrollTo({ top: scrollableDistance > 0 ? scrollableDistance : 3000, behavior: 'instant' }); this.targetFrame = TOTAL_FRAMES; this.currentInterpolatedFrame = TOTAL_FRAMES; this.lastRenderedFrame = TOTAL_FRAMES; this.activePhase.set(3); - } else if (this.initialPosition() === 'top') { + } else { window.scrollTo({ top: 0, behavior: 'instant' }); this.targetFrame = 1; this.currentInterpolatedFrame = 1; @@ -111,6 +111,21 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy { this.onResize(); this.renderFrame(Math.round(this.currentInterpolatedFrame)); + + // Reasegurar la posición al desbloquear el scroll + if (isBottom) { + setTimeout(() => { + const maxDist = container.offsetHeight - window.innerHeight; + if (maxDist > 0) { + window.scrollTo({ top: maxDist, behavior: 'instant' }); + } + this.targetFrame = TOTAL_FRAMES; + this.currentInterpolatedFrame = TOTAL_FRAMES; + this.lastRenderedFrame = TOTAL_FRAMES; + this.activePhase.set(3); + this.renderFrame(TOTAL_FRAMES); + }, 780); + } } } @@ -133,36 +148,47 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy { } private preloadImages(): void { + if (isPreloadDone) { + this.loadProgress.set(100); + this.isLoaded.set(true); + this.renderFrame(this.targetFrame); + return; + } + let loaded = 0; const startFrame = this.initialPosition() === 'bottom' ? TOTAL_FRAMES : 1; - const firstImg = new Image(); - firstImg.src = this.getFramePath(startFrame); - firstImg.onload = () => { - this.images[startFrame] = firstImg; - loaded++; - this.renderFrame(startFrame); - - for (let i = 1; i <= TOTAL_FRAMES; i++) { - if (i === startFrame) continue; - const img = new Image(); - img.src = this.getFramePath(i); - img.onload = () => { - this.images[i] = img; - loaded++; - if (loaded % 12 === 0 || loaded === TOTAL_FRAMES) { - const pct = Math.round((loaded / TOTAL_FRAMES) * 100); - this.loadProgress.set(pct); - if (loaded === TOTAL_FRAMES) { - this.isLoaded.set(true); - } - } - }; - img.onerror = () => { - loaded++; - }; + const loadSingle = (index: number, cb?: () => void) => { + if (SHARED_IMAGE_CACHE[index]?.complete) { + loaded++; + cb?.(); + return; } + const img = new Image(); + img.src = this.getFramePath(index); + img.onload = () => { + SHARED_IMAGE_CACHE[index] = img; + loaded++; + cb?.(); + if (loaded % 12 === 0 || loaded === TOTAL_FRAMES) { + this.loadProgress.set(Math.round((loaded / TOTAL_FRAMES) * 100)); + if (loaded === TOTAL_FRAMES) { + isPreloadDone = true; + this.isLoaded.set(true); + } + } + }; + img.onerror = () => { + loaded++; + }; }; + + loadSingle(startFrame, () => { + this.renderFrame(startFrame); + for (let i = 1; i <= TOTAL_FRAMES; i++) { + if (i !== startFrame) loadSingle(i); + } + }); } private getFramePath(index: number): string { @@ -184,7 +210,6 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy { const progress = Math.min(Math.max(rawProgress, 0), 1); this.targetFrame = Math.min(Math.max(1, 1 + progress * (TOTAL_FRAMES - 1)), TOTAL_FRAMES); - // Si ocurre un salto grande de posición inicial, sincronizar frame directamente sin animación forzada const jumpDiff = Math.abs(this.targetFrame - this.currentInterpolatedFrame); if (jumpDiff > 40 && !this.isLoopRunning) { this.currentInterpolatedFrame = this.targetFrame; @@ -256,7 +281,7 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy { } else if (progress < 0.94) { phase = 3; } else { - phase = 0; // Desvanecimiento completo al final del scroll + phase = 0; } if (this.activePhase() !== phase) { @@ -279,15 +304,15 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy { private getBestFrame(targetIndex: number): HTMLImageElement | null { const rounded = Math.min(Math.max(1, Math.round(targetIndex)), TOTAL_FRAMES); - if (this.images[rounded]?.complete) return this.images[rounded]; + if (SHARED_IMAGE_CACHE[rounded]?.complete) return SHARED_IMAGE_CACHE[rounded]; for (let offset = 1; offset < TOTAL_FRAMES; offset++) { const prev = rounded - offset; - if (prev >= 1 && this.images[prev]?.complete) return this.images[prev]; + if (prev >= 1 && SHARED_IMAGE_CACHE[prev]?.complete) return SHARED_IMAGE_CACHE[prev]; const next = rounded + offset; - if (next <= TOTAL_FRAMES && this.images[next]?.complete) return this.images[next]; + if (next <= TOTAL_FRAMES && SHARED_IMAGE_CACHE[next]?.complete) return SHARED_IMAGE_CACHE[next]; } - return this.images[1]?.complete ? this.images[1] : null; + return SHARED_IMAGE_CACHE[1]?.complete ? SHARED_IMAGE_CACHE[1] : null; } private renderFrame(targetIndex: number): void { @@ -304,26 +329,13 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy { ctx.fillRect(0, 0, canvas.width, canvas.height); const imgAspect = 16 / 9; - const screenAspect = canvas.width / canvas.height; - let drawW: number; - let drawH: number; - let offsetX = 0; - let offsetY = 0; - - if (screenAspect > imgAspect) { - drawW = canvas.width; - drawH = canvas.width / imgAspect; - offsetY = (canvas.height - drawH) / 2; - } else { - drawW = canvas.width; - drawH = canvas.width / imgAspect; - offsetX = 0; - offsetY = (canvas.height - drawH) / 2; - } + const drawW = canvas.width; + const drawH = canvas.width / imgAspect; + const offsetY = (canvas.height - drawH) / 2; ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; - ctx.drawImage(img, offsetX, offsetY, drawW, drawH); + ctx.drawImage(img, 0, offsetY, drawW, drawH); } ngOnDestroy(): void { diff --git a/src/frontend/src/app/presentation/features/landing/landing.component.html b/src/frontend/src/app/presentation/features/landing/landing.component.html index 01a447e..d6b8342 100644 --- a/src/frontend/src/app/presentation/features/landing/landing.component.html +++ b/src/frontend/src/app/presentation/features/landing/landing.component.html @@ -193,4 +193,7 @@ } + + + \ No newline at end of file diff --git a/src/frontend/src/app/presentation/features/landing/landing.component.ts b/src/frontend/src/app/presentation/features/landing/landing.component.ts index d1911fb..39e19e4 100644 --- a/src/frontend/src/app/presentation/features/landing/landing.component.ts +++ b/src/frontend/src/app/presentation/features/landing/landing.component.ts @@ -6,6 +6,7 @@ import { FeaturedProjectsComponent } from '../featured-projects/featured-project import { ContactSectionComponent } from '../contact-section/contact-section.component'; import { TechnicalProfileComponent } from '../technical-profile/technical-profile.component'; import { IncidentSimulatorComponent } from '../incident-simulator/incident-simulator.component'; +import { ChatBotComponent } from '../chat-bot/chat-bot.component'; import { I18nService } from '../../../application/services/i18n.service'; export type LandingSection = 'hero' | 'retro' | 'projects' | 'simulator' | 'terminal' | 'contact'; @@ -29,7 +30,8 @@ export const LANDING_SECTIONS: readonly LandingSection[] = [ FeaturedProjectsComponent, ContactSectionComponent, TechnicalProfileComponent, - IncidentSimulatorComponent + IncidentSimulatorComponent, + ChatBotComponent ], templateUrl: './landing.component.html', styleUrl: './landing.component.css', @@ -130,13 +132,11 @@ export class LandingComponent implements OnInit, OnDestroy { window.history.replaceState(null, '', url); this.scrollActiveNavIntoView(section); - if (section !== 'hero') { - if (entryPosition === 'bottom') { - const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight); - window.scrollTo({ top: maxScroll, behavior: 'instant' }); - } else { - window.scrollTo({ top: 0, behavior: 'instant' }); - } + if (entryPosition === 'bottom') { + const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight); + window.scrollTo({ top: maxScroll, behavior: 'instant' }); + } else { + window.scrollTo({ top: 0, behavior: 'instant' }); } } @@ -147,6 +147,16 @@ export class LandingComponent implements OnInit, OnDestroy { document.documentElement.style.overflow = ''; document.body.style.overflow = ''; } + + if (typeof window !== 'undefined') { + if (entryPosition === 'bottom') { + const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight); + window.scrollTo({ top: maxScroll, behavior: 'instant' }); + } else { + window.scrollTo({ top: 0, behavior: 'instant' }); + } + } + setTimeout(() => { this.isTransitioning = false; }, 300);