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:
parent
64657c3b18
commit
7c2582b209
|
|
@ -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"
|
||||
}
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
@ -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:
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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));
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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); }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue