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.
This commit is contained in:
bryancito1090 2026-09-08 11:46:41 -05:00
parent 64657c3b18
commit 7c2582b209
17 changed files with 1302 additions and 69 deletions

View File

@ -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"
}
```

View File

@ -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.

View File

@ -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:

View File

@ -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"
}
}

View File

@ -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 }
]
};

View File

@ -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<boolean>(false);
private readonly _isTyping = signal<boolean>(false);
private readonly _unreadCount = signal<number>(0);
private readonly _errorMessage = signal<string | null>(null);
private readonly _history = signal<ChatMessage[]>([]);
private readonly _sessionId = signal<string>(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<ChatMessage[]>(() => {
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]);
}
});
}
}

View File

@ -0,0 +1,7 @@
import { InjectionToken } from '@angular/core';
export const CHAT_WEBHOOK_URL = new InjectionToken<string>('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'
});

View File

@ -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;
}

View File

@ -0,0 +1,6 @@
import { Observable } from 'rxjs';
import { ChatPayload, ChatResponse } from '../models/chat.model';
export abstract class ChatPort {
abstract sendMessage(payload: ChatPayload): Observable<ChatResponse>;
}

View File

@ -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<ChatResponse> {
const requestDto: N8nChatRequestDto = {
message: payload.message,
sessionId: payload.sessionId
};
return this.http.post<N8nChatResponseDto>(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));
})
);
}
}

View File

@ -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;
}

View File

@ -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); }
}

View File

@ -0,0 +1,167 @@
<aside class="zen-chat-container" aria-label="Asistente virtual interactivo">
<!-- Botón Disparador Flotante Minimalista (Robot Avatar perfectamente centrado) -->
<div class="launcher-wrapper">
@if (!store.isOpen()) {
<span class="launcher-pulse-ring" aria-hidden="true"></span>
}
<button
type="button"
class="chat-launcher-btn"
[class.active]="store.isOpen()"
(click)="store.toggleChat()"
[attr.aria-expanded]="store.isOpen()"
[attr.aria-label]="store.isOpen() ? 'Cerrar asistente virtual' : 'Abrir asistente virtual con IA'"
>
<div class="launcher-icon-wrapper">
@if (store.isOpen()) {
<!-- Icono de Minimizar / Cerrar -->
<svg class="launcher-close-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
} @else {
<!-- Icono de Robot Cyber-Zen Minimalista Centrado -->
<svg class="launcher-bot-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3v4"></path>
<circle cx="12" cy="2.5" r="1.2" fill="currentColor"></circle>
<rect x="4" y="7" width="16" height="13" rx="3.5" fill="rgba(197, 155, 39, 0.15)"></rect>
<circle cx="8.5" cy="12.5" r="1.5" fill="currentColor"></circle>
<circle cx="15.5" cy="12.5" r="1.5" fill="currentColor"></circle>
<path d="M9 16.5h6"></path>
<path d="M2 12.5h2M20 12.5h2"></path>
</svg>
}
</div>
<!-- Indicador de Estado Online (Verde matcha con radar ping) -->
<span class="launcher-status-dot" aria-hidden="true">
<span class="status-radar-ping"></span>
</span>
<!-- Badge de Mensajes no Leídos -->
@if (!store.isOpen() && store.hasUnread()) {
<span class="launcher-badge" aria-label="Mensajes nuevos">{{ store.unreadCount() }}</span>
}
</button>
</div>
<!-- Ventana de Chat Flotante Cyber-Zen -->
@if (store.isOpen()) {
<div class="chat-window-card" role="dialog" aria-modal="true" aria-label="Conversación con asistente">
<!-- Cabecera de la Ventana -->
<header class="chat-header">
<div class="header-identity">
<div class="avatar-ring">
<svg class="header-bot-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3v4"></path>
<circle cx="12" cy="2.5" r="1.2" fill="currentColor"></circle>
<rect x="4" y="7" width="16" height="13" rx="3.5" fill="rgba(197, 155, 39, 0.25)"></rect>
<circle cx="8.5" cy="12.5" r="1.5" fill="currentColor"></circle>
<circle cx="15.5" cy="12.5" r="1.5" fill="currentColor"></circle>
<path d="M9 16.5h6"></path>
</svg>
</div>
<div class="identity-info">
<span class="identity-name">Bryan Bot</span>
<span class="identity-meta">Online // n8n & Gemini</span>
</div>
</div>
<div class="header-actions">
<button
type="button"
class="header-btn"
(click)="store.clearHistory()"
title="Reiniciar conversación"
aria-label="Reiniciar conversación"
>
<svg class="action-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="1 4 1 10 7 10"></polyline>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
</svg>
</button>
<button
type="button"
class="header-btn"
(click)="store.closeChat()"
title="Minimizar chat"
aria-label="Minimizar chat"
>
<svg class="action-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</header>
<!-- Contenedor de Mensajes -->
<div #messagesContainer class="chat-messages-area">
@for (msg of store.messages(); track msg.id) {
<div class="message-row" [class.user-row]="msg.role === 'user'" [class.bot-row]="msg.role === 'assistant'">
<div class="message-bubble" [class.error-bubble]="msg.status === 'error'">
<p class="message-text">{{ msg.content }}</p>
<span class="message-time">{{ msg.timestamp }}</span>
</div>
</div>
}
<!-- Indicador de Escritura -->
@if (store.isTyping()) {
<div class="message-row bot-row">
<div class="message-bubble typing-bubble">
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
</div>
</div>
}
<!-- Sugerencias Rápidas -->
@if (store.messages().length <= 2 && !store.isTyping()) {
<div class="quick-prompts-group">
<span class="quick-title">Preguntas sugeridas:</span>
<div class="chips-container">
@for (prompt of store.quickPrompts(); track prompt) {
<button
type="button"
class="quick-chip-btn"
(click)="onSelectPrompt(prompt)"
>
{{ prompt }}
</button>
}
</div>
</div>
}
</div>
<!-- Barra de Envío / Input -->
<footer class="chat-input-bar">
<input
#chatInput
type="text"
class="chat-text-input"
placeholder="Escribe una pregunta sobre mi perfil..."
maxlength="400"
[disabled]="store.isTyping()"
(keydown)="onKeyDown($event, chatInput)"
aria-label="Mensaje para el asistente"
/>
<button
type="button"
class="chat-send-btn"
[disabled]="store.isTyping()"
(click)="onSend(chatInput)"
aria-label="Enviar mensaje"
>
<svg class="send-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
</footer>
</div>
}
</aside>

View File

@ -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<HTMLDivElement>;
@ViewChild('chatInput') private chatInput?: ElementRef<HTMLInputElement>;
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);
}
}

View File

@ -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<number>(1);
protected readonly loadProgress = signal<number>(0);
protected readonly isLoaded = signal<boolean>(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 {

View File

@ -193,4 +193,7 @@
<app-contact-section></app-contact-section>
</section>
}
<!-- Asistente Virtual Flotante Cyber-Zen (n8n + Gemini) -->
<app-chat-bot></app-chat-bot>
</div>

View File

@ -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);