diff --git a/chat-widget/.dockerignore b/chat-widget/.dockerignore
new file mode 100644
index 0000000..30f417e
--- /dev/null
+++ b/chat-widget/.dockerignore
@@ -0,0 +1,31 @@
+# Dependencias
+node_modules
+npm-debug.log
+
+# Build output
+dist
+.angular
+
+# IDE
+.vscode
+.idea
+*.swp
+*.swo
+
+# Git
+.git
+.gitignore
+
+# Tests
+coverage
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+
+# Environment
+.env
+.env.local
diff --git a/chat-widget/.editorconfig b/chat-widget/.editorconfig
new file mode 100644
index 0000000..59d9a3a
--- /dev/null
+++ b/chat-widget/.editorconfig
@@ -0,0 +1,16 @@
+# Editor configuration, see https://editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.ts]
+quote_type = single
+
+[*.md]
+max_line_length = off
+trim_trailing_whitespace = false
diff --git a/chat-widget/.gitignore b/chat-widget/.gitignore
new file mode 100644
index 0000000..aabbd81
--- /dev/null
+++ b/chat-widget/.gitignore
@@ -0,0 +1,43 @@
+# See http://help.github.com/ignore-files/ for more about ignoring files.
+
+# Compiled output
+/dist
+/tmp
+/out-tsc
+/bazel-out
+
+# Node
+node_modules/
+npm-debug.log
+yarn-error.log
+package-lock.json
+
+# IDEs and editors
+.idea/
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+
+# Visual Studio Code
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+.history/*
+
+# Miscellaneous
+/.angular/cache
+.sass-cache/
+/connect.lock
+/coverage
+/libpeerconnection.log
+testem.log
+/typings
+
+# System files
+.DS_Store
+Thumbs.db
diff --git a/chat-widget/Dockerfile b/chat-widget/Dockerfile
new file mode 100644
index 0000000..1dd0771
--- /dev/null
+++ b/chat-widget/Dockerfile
@@ -0,0 +1,13 @@
+FROM node:18-alpine
+
+WORKDIR /app
+
+COPY package*.json ./
+
+RUN npm ci
+
+COPY . .
+
+EXPOSE 4200
+
+CMD ["npm", "start", "--", "--host", "0.0.0.0", "--poll", "2000"]
diff --git a/chat-widget/README.md b/chat-widget/README.md
new file mode 100644
index 0000000..b7f8741
--- /dev/null
+++ b/chat-widget/README.md
@@ -0,0 +1,114 @@
+# 1millionBot Chat Widget
+
+## đ Description
+
+Reusable chat widget implementation for chatbots, developed as part of the 1millionbot frontend challenge.
+
+## đď¸ Architecture
+
+The project follows a **modular architecture** and clean-code principles. It is structured so the chat module can be extracted into an Angular library in the future with minimal changes.
+
+### Folder structure
+
+```
+
+src/app/
+âââ core/ # Business logic and base services
+â âââ models/ # Interfaces, types and enums
+â âââ services/ # Core services (ChatService, StateService)
+âââ chat/ # Chat module (reusable)
+â âââ components/ # Chat widget components
+âââ features/ # Demo-specific features
+â âââ mock-bot/ # Mock chatbot implementation
+âââ shared/ # Shared utilities
+âââ pipes/ # Custom pipes
+âââ directives/ # Custom directives
+
+```
+
+## ⨠Features
+
+### Core requirements
+
+- â
Chat window with a modern UI
+- â
Messages visually distinguished (bot vs user)
+- â
Bot avatar and name always visible
+- â
Dropdown menu (forget user data, change language, privacy)
+- â
Multiple message types:
+ - Text
+ - Image cards
+ - Interactive buttons
+- â
Responsive design (fullscreen on mobile)
+
+### Bonus
+
+- â
Pre-chat call to action
+- â
Internationalization (i18n) ES/EN
+- â
Unit tests (Jasmine/Karma)
+- â
E2E tests (Cypress)
+- â
Dockerized
+
+## đ Installation & Usage
+
+```bash
+# Install dependencies
+npm install
+
+# Development server
+npm start
+# Open http://localhost:4200
+
+# Production build
+npm run build
+
+# Tests
+npm test # Unit tests
+npm run e2e # E2E tests
+
+# Docker
+docker-compose up # Run with Docker
+```
+
+## đ ď¸ Tech Stack
+
+- **Angular 16+**
+- **TypeScript** (strict mode)
+- **Tailwind CSS** (utility-first styling)
+- **RxJS**
+- **Jasmine/Karma** (unit tests)
+- **Cypress** (E2E tests)
+- **Docker**
+
+## đ Technical Decisions
+
+### Why this architecture?
+
+1. **Separation of concerns**: Core/Chat/Features/Shared keeps the code organized
+2. **Reusable**: The `ChatModule` is structured so it can be extracted into a library
+3. **Testable**: Dependency injection and small services make testing straightforward
+4. **Scalable**: Easy to add new message types or features
+
+### Design Patterns
+
+- **Smart/Dumb components**: Container vs presentational components
+- **Reactive**: BehaviorSubjects for reactive state
+- **Dependency Injection**: Interfaces and injectable services
+
+## đ¨âđť Development
+
+### Coding Conventions
+
+- TypeScript strict mode
+- ESLint configured
+- Prettier for formatting
+- Semantic commits (feat:, fix:, docs:, etc.)
+
+### Project Status
+
+See the root-level TODO list for progress: `../README.md`.
+
+---
+
+**Author:** Juan Manuel PĂŠrez
+**Challenge:** 1millionbot Frontend Developer
+**Date:** November 2025
diff --git a/chat-widget/angular.json b/chat-widget/angular.json
new file mode 100644
index 0000000..56855dc
--- /dev/null
+++ b/chat-widget/angular.json
@@ -0,0 +1,108 @@
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "cli": {
+ "packageManager": "npm",
+ "analytics": "261ca842-37a4-4c4c-87c9-20a5ccf8feab"
+ },
+ "newProjectRoot": "projects",
+ "projects": {
+ "chat-app": {
+ "projectType": "application",
+ "schematics": {
+ "@schematics/angular:component": {
+ "style": "scss"
+ }
+ },
+ "root": "",
+ "sourceRoot": "src",
+ "prefix": "app",
+ "architect": {
+ "build": {
+ "builder": "@angular-devkit/build-angular:browser",
+ "options": {
+ "outputPath": "dist/chat-app",
+ "index": "src/index.html",
+ "main": "src/main.ts",
+ "polyfills": [
+ "zone.js"
+ ],
+ "tsConfig": "tsconfig.app.json",
+ "inlineStyleLanguage": "scss",
+ "assets": [
+ "src/favicon.ico",
+ "src/assets"
+ ],
+ "styles": [
+ "src/styles.scss"
+ ],
+ "scripts": []
+ },
+ "configurations": {
+ "production": {
+ "budgets": [
+ {
+ "type": "initial",
+ "maximumWarning": "500kb",
+ "maximumError": "1mb"
+ },
+ {
+ "type": "anyComponentStyle",
+ "maximumWarning": "2kb",
+ "maximumError": "4kb"
+ }
+ ],
+ "outputHashing": "all"
+ },
+ "development": {
+ "buildOptimizer": false,
+ "optimization": false,
+ "vendorChunk": true,
+ "extractLicenses": false,
+ "sourceMap": true,
+ "namedChunks": true
+ }
+ },
+ "defaultConfiguration": "production"
+ },
+ "serve": {
+ "builder": "@angular-devkit/build-angular:dev-server",
+ "configurations": {
+ "production": {
+ "browserTarget": "chat-app:build:production"
+ },
+ "development": {
+ "browserTarget": "chat-app:build:development"
+ }
+ },
+ "defaultConfiguration": "development"
+ },
+ "extract-i18n": {
+ "builder": "@angular-devkit/build-angular:extract-i18n",
+ "options": {
+ "browserTarget": "chat-app:build"
+ }
+ },
+ "test": {
+ "builder": "@angular-devkit/build-angular:karma",
+ "options": {
+ "polyfills": [
+ "zone.js",
+ "zone.js/testing"
+ ],
+ "tsConfig": "tsconfig.spec.json",
+ "inlineStyleLanguage": "scss",
+ "assets": [
+ "src/favicon.ico",
+ "src/assets"
+ ],
+ "styles": [
+ "src/styles.scss"
+ ],
+ "scripts": []
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/chat-widget/docker-compose.yml b/chat-widget/docker-compose.yml
new file mode 100644
index 0000000..c17db10
--- /dev/null
+++ b/chat-widget/docker-compose.yml
@@ -0,0 +1,17 @@
+version: "3.8"
+
+services:
+ chat-widget:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: 1millionbot-chat-widget
+ ports:
+ - "4200:4200"
+ volumes:
+ - .:/app
+ - /app/node_modules
+ environment:
+ - NODE_ENV=development
+ stdin_open: true
+ tty: true
diff --git a/chat-widget/package.json b/chat-widget/package.json
new file mode 100644
index 0000000..f3dc5e9
--- /dev/null
+++ b/chat-widget/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "chat-app",
+ "version": "0.0.0",
+ "scripts": {
+ "ng": "ng",
+ "start": "ng serve",
+ "build": "ng build",
+ "watch": "ng build --watch --configuration development",
+ "test": "ng test",
+ "docker:build": "docker-compose build",
+ "docker:start": "docker-compose up",
+ "docker:stop": "docker-compose stop",
+ "docker:down": "docker-compose down",
+ "docker:logs": "docker-compose logs -f"
+ },
+ "private": true,
+ "dependencies": {
+ "@angular/animations": "^16.2.0",
+ "@angular/common": "^16.2.0",
+ "@angular/compiler": "^16.2.0",
+ "@angular/core": "^16.2.0",
+ "@angular/forms": "^16.2.0",
+ "@angular/platform-browser": "^16.2.0",
+ "@angular/platform-browser-dynamic": "^16.2.0",
+ "@angular/router": "^16.2.0",
+ "rxjs": "~7.8.0",
+ "tslib": "^2.3.0",
+ "zone.js": "~0.13.0"
+ },
+ "devDependencies": {
+ "@angular-devkit/build-angular": "^16.2.16",
+ "@angular/cli": "^16.2.16",
+ "@angular/compiler-cli": "^16.2.0",
+ "@types/jasmine": "~4.3.0",
+ "autoprefixer": "^10.4.21",
+ "jasmine-core": "~4.6.0",
+ "karma": "~6.4.0",
+ "karma-chrome-launcher": "~3.2.0",
+ "karma-coverage": "~2.2.0",
+ "karma-jasmine": "~5.1.0",
+ "karma-jasmine-html-reporter": "~2.1.0",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^3.4.18",
+ "typescript": "~5.1.3"
+ }
+}
diff --git a/chat-widget/src/app/app.component.html b/chat-widget/src/app/app.component.html
new file mode 100644
index 0000000..060916a
--- /dev/null
+++ b/chat-widget/src/app/app.component.html
@@ -0,0 +1,8 @@
+
+
+
1MillionBot Chat Demo
+
Prueba nuestro asistente virtual interactivo
+
+
+
+
diff --git a/chat-widget/src/app/app.component.scss b/chat-widget/src/app/app.component.scss
new file mode 100644
index 0000000..f235c8c
--- /dev/null
+++ b/chat-widget/src/app/app.component.scss
@@ -0,0 +1,34 @@
+.demo-page {
+ min-height: 100vh;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+}
+
+.demo-content {
+ text-align: center;
+ color: white;
+}
+
+.demo-title {
+ font-size: 48px;
+ font-weight: 700;
+ margin: 0 0 16px 0;
+ text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+
+ @media (max-width: 768px) {
+ font-size: 32px;
+ }
+}
+
+.demo-subtitle {
+ font-size: 20px;
+ margin: 0;
+ opacity: 0.9;
+
+ @media (max-width: 768px) {
+ font-size: 16px;
+ }
+}
diff --git a/chat-widget/src/app/app.component.spec.ts b/chat-widget/src/app/app.component.spec.ts
new file mode 100644
index 0000000..10ba25d
--- /dev/null
+++ b/chat-widget/src/app/app.component.spec.ts
@@ -0,0 +1,25 @@
+import { TestBed } from '@angular/core/testing';
+import { AppComponent } from './app.component';
+
+describe('AppComponent', () => {
+ beforeEach(() =>
+ TestBed.configureTestingModule({
+ imports: [AppComponent],
+ })
+ );
+
+ it('should create the app', () => {
+ const fixture = TestBed.createComponent(AppComponent);
+ const app = fixture.componentInstance;
+ expect(app).toBeTruthy();
+ });
+
+ it('should have chatConfig with correct structure', () => {
+ const fixture = TestBed.createComponent(AppComponent);
+ const app = fixture.componentInstance;
+ expect(app.chatConfig).toBeTruthy();
+ expect(app.chatConfig.botProfile).toBeTruthy();
+ expect(app.chatConfig.theme).toBeTruthy();
+ expect(app.chatConfig.locale).toBe('es');
+ });
+});
diff --git a/chat-widget/src/app/app.component.ts b/chat-widget/src/app/app.component.ts
new file mode 100644
index 0000000..2dde1ab
--- /dev/null
+++ b/chat-widget/src/app/app.component.ts
@@ -0,0 +1,29 @@
+import { Component } from '@angular/core';
+import { ChatConfig } from './core/models';
+import { ChatContainerComponent } from './chat/chat-container/chat-container.component';
+
+@Component({
+ selector: 'app-root',
+ standalone: true,
+ imports: [ChatContainerComponent],
+ templateUrl: './app.component.html',
+ styleUrls: ['./app.component.scss'],
+})
+export class AppComponent {
+ chatConfig: ChatConfig = {
+ botProfile: {
+ id: 'bot-1',
+ name: '1MillionBot Assistant',
+ avatarUrl: 'https://api.dicebear.com/7.x/bottts/svg?seed=1millionbot',
+ welcomeMessage:
+ 'ÂĄHola! đ Soy tu asistente virtual. ÂżEn quĂŠ puedo ayudarte hoy?',
+ },
+ theme: {
+ primaryColor: '#0066ff',
+ botMessageBg: '#f3f4f6',
+ userMessageBg: '#0066ff',
+ },
+ locale: 'es',
+ enableCallToAction: true,
+ };
+}
diff --git a/chat-widget/src/app/app.module.ts b/chat-widget/src/app/app.module.ts
new file mode 100644
index 0000000..aba424e
--- /dev/null
+++ b/chat-widget/src/app/app.module.ts
@@ -0,0 +1,15 @@
+import { NgModule } from '@angular/core';
+import { BrowserModule } from '@angular/platform-browser';
+
+import { AppRoutingModule } from './app-routing.module';
+import { AppComponent } from './app.component';
+import { StateService } from './core/services';
+import { MockBotService } from './features/mock-bot/mock-bot.service';
+
+@NgModule({
+ declarations: [],
+ imports: [BrowserModule, AppRoutingModule, AppComponent],
+ providers: [StateService, MockBotService],
+ bootstrap: [AppComponent],
+})
+export class AppModule {}
diff --git a/chat-widget/src/app/chat/chat-container/chat-container.component.html b/chat-widget/src/app/chat/chat-container/chat-container.component.html
new file mode 100644
index 0000000..09ad463
--- /dev/null
+++ b/chat-widget/src/app/chat/chat-container/chat-container.component.html
@@ -0,0 +1,106 @@
+
diff --git a/chat-widget/src/app/chat/chat-container/chat-container.component.scss b/chat-widget/src/app/chat/chat-container/chat-container.component.scss
new file mode 100644
index 0000000..7a7c12d
--- /dev/null
+++ b/chat-widget/src/app/chat/chat-container/chat-container.component.scss
@@ -0,0 +1,243 @@
+.chat-widget {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ z-index: 1000;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+}
+
+.cta-button {
+ position: relative;
+ width: 70px;
+ height: 70px;
+ border-radius: 50%;
+ border: none;
+ background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
+ box-shadow: 0 8px 24px rgba(231, 76, 60, 0.4);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.3s ease;
+ animation: pulse 2s infinite;
+
+ &:hover {
+ transform: scale(1.05);
+ box-shadow: 0 12px 32px rgba(231, 76, 60, 0.5);
+ }
+
+ &:active {
+ transform: scale(0.95);
+ }
+
+ @media (max-width: 768px) {
+ width: 60px;
+ height: 60px;
+ }
+}
+
+.cta-avatar {
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ background: white;
+ padding: 3px;
+
+ @media (max-width: 768px) {
+ width: 42px;
+ height: 42px;
+ }
+}
+
+.cta-badge {
+ position: absolute;
+ bottom: 100%;
+ right: 0;
+ margin-bottom: 12px;
+ background: white;
+ padding: 12px 16px;
+ border-radius: 20px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ white-space: nowrap;
+ animation: bounceIn 0.5s ease-out;
+
+ &::after {
+ content: "";
+ position: absolute;
+ top: 100%;
+ right: 20px;
+ width: 0;
+ height: 0;
+ border-left: 8px solid transparent;
+ border-right: 8px solid transparent;
+ border-top: 8px solid white;
+ }
+
+ @media (max-width: 768px) {
+ display: none;
+ }
+}
+
+.cta-message {
+ color: #1f2937;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.chat-window {
+ width: 380px;
+ height: 600px;
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ animation: slideUp 0.3s ease-out;
+
+ @media (max-width: 768px) {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ width: 100%;
+ height: 100vh;
+ border-radius: 0;
+ }
+}
+
+.quick-suggestions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 16px;
+ background: white;
+ border-top: 1px solid #e5e7eb;
+ flex-shrink: 0;
+}
+
+.suggestions-scroll {
+ display: flex;
+ gap: 8px;
+ overflow-x: auto;
+ scroll-behavior: smooth;
+ flex: 1;
+ padding: 4px 0;
+
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+.suggestion-button {
+ padding: 8px 16px;
+ background: #f3f4f6;
+ border: 1px solid #e5e7eb;
+ color: #4b5563;
+ border-radius: 20px;
+ font-size: 13px;
+ font-weight: 400;
+ cursor: pointer;
+ transition: all 0.2s;
+ white-space: nowrap;
+ flex-shrink: 0;
+
+ &:hover:not(:disabled) {
+ background: #e5e7eb;
+ border-color: #d1d5db;
+ }
+
+ &:active:not(:disabled) {
+ transform: scale(0.98);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ background: #f9fafb;
+ }
+}
+
+.scroll-arrow {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: white;
+ border: 1px solid #e5e7eb;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: all 0.2s;
+ color: #6b7280;
+ animation: fadeIn 0.2s ease-out;
+
+ &:hover {
+ background: #f9fafb;
+ border-color: #d1d5db;
+ color: #374151;
+ }
+
+ &:active {
+ transform: scale(0.95);
+ }
+
+ &.scroll-arrow-left {
+ order: -1; // Asegura que aparezca a la izquierda
+ }
+
+ &.scroll-arrow-right {
+ order: 1; // Asegura que aparezca a la derecha
+ }
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: scale(0.8);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ box-shadow: 0 8px 24px rgba(231, 76, 60, 0.4);
+ }
+ 50% {
+ box-shadow: 0 8px 24px rgba(231, 76, 60, 0.6);
+ }
+}
+
+@keyframes bounceIn {
+ 0% {
+ opacity: 0;
+ transform: scale(0.3) translateY(10px);
+ }
+ 50% {
+ transform: scale(1.05);
+ }
+ 100% {
+ opacity: 1;
+ transform: scale(1) translateY(0);
+ }
+}
diff --git a/chat-widget/src/app/chat/chat-container/chat-container.component.ts b/chat-widget/src/app/chat/chat-container/chat-container.component.ts
new file mode 100644
index 0000000..2472916
--- /dev/null
+++ b/chat-widget/src/app/chat/chat-container/chat-container.component.ts
@@ -0,0 +1,317 @@
+import {
+ Component,
+ Input,
+ OnInit,
+ OnDestroy,
+ ViewChild,
+ ElementRef,
+} from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { Subject, takeUntil } from 'rxjs';
+import {
+ ChatConfig,
+ Message,
+ MessageType,
+ SenderType,
+} from '../../core/models';
+import { StateService, TranslationService } from '../../core/services';
+import { MockBotService } from '../../features/mock-bot/mock-bot.service';
+import { ChatHeaderComponent } from '../chat-header/chat-header.component';
+import { ChatMessagesComponent } from '../chat-messages/chat-messages.component';
+import { ChatInputComponent } from '../chat-input/chat-input.component';
+import { TranslatePipe } from '../../core/pipes/translate.pipe';
+
+@Component({
+ selector: 'app-chat-container',
+ standalone: true,
+ imports: [
+ CommonModule,
+ ChatHeaderComponent,
+ ChatMessagesComponent,
+ ChatInputComponent,
+ TranslatePipe,
+ ],
+ templateUrl: './chat-container.component.html',
+ styleUrls: ['./chat-container.component.scss'],
+})
+export class ChatContainerComponent implements OnInit, OnDestroy {
+ @Input() config!: ChatConfig;
+ @ViewChild('suggestionsScroll') suggestionsScrollElement!: ElementRef;
+
+ messages: Message[] = [];
+ isOpen = false;
+ isBotResponding = false;
+ showLeftArrow = false;
+ showRightArrow = true;
+ private destroy$ = new Subject();
+
+ quickSuggestions: Array<{ label: string; value: string }> = [];
+
+ constructor(
+ private stateService: StateService,
+ private botService: MockBotService,
+ private translationService: TranslationService
+ ) {}
+
+ ngOnInit(): void {
+ this.stateService.messages$
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((messages) => (this.messages = messages));
+
+ this.stateService.isChatOpen$
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((isOpen) => (this.isOpen = isOpen));
+
+ this.stateService.isBotResponding$
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((isResponding) => (this.isBotResponding = isResponding));
+
+ // Update suggestions based on language
+ this.translationService.currentLanguage$
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(() => {
+ this.updateQuickSuggestions();
+ // Reset scroll and update arrows when suggestions change
+ setTimeout(() => {
+ if (this.suggestionsScrollElement) {
+ this.suggestionsScrollElement.nativeElement.scrollLeft = 0;
+ }
+ this.updateArrowsVisibility();
+ }, 100);
+ });
+
+ this.loadWelcomeMessage();
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ private loadWelcomeMessage(): void {
+ if (this.messages.length === 0) {
+ this.botService.getWelcomeMessage().subscribe((message) => {
+ this.stateService.addMessage(message);
+ });
+ }
+ }
+
+ onSendMessage(text: string): void {
+ // Prevent sending if bot is already responding
+ if (this.isBotResponding) {
+ return;
+ }
+
+ // IMPORTANT: Mark as responding IMMEDIATELY to prevent multiple clicks
+ this.stateService.setBotResponding(true);
+
+ const userMessage: Message = {
+ id: Date.now().toString(),
+ type: MessageType.TEXT,
+ sender: SenderType.USER,
+ content: text,
+ timestamp: new Date(),
+ };
+
+ this.stateService.addMessage(userMessage);
+
+ // Activar indicador de escritura
+ this.stateService.setTyping(true);
+
+ this.botService.sendMessage(text).subscribe((response) => {
+ // Disable typing indicator before showing message
+ this.stateService.setTyping(false);
+ this.stateService.addMessage(response);
+ // setBotResponding(false) will be called when typewriter animation ends
+ });
+ }
+
+ onStopBot(): void {
+ // Stop bot (this will stop typewriter animation)
+ this.stateService.setBotResponding(false);
+ this.stateService.setTyping(false);
+ }
+
+ onMenuAction(action: string): void {
+ switch (action) {
+ case 'privacy':
+ this.showPrivacyPolicy();
+ break;
+ case 'language':
+ this.showLanguageSelector();
+ break;
+ case 'forget':
+ this.forgetUserData();
+ break;
+ case 'clear':
+ this.stateService.clearMessages();
+ this.loadWelcomeMessage();
+ break;
+ }
+ }
+
+ private showPrivacyPolicy(): void {
+ const privacyMessage: Message = {
+ id: Date.now().toString(),
+ type: MessageType.TEXT,
+ sender: SenderType.BOT,
+ content: this.translationService.translate('bot.privacy'),
+ timestamp: new Date(),
+ };
+ this.stateService.addMessage(privacyMessage);
+ }
+
+ private showLanguageSelector(): void {
+ const languageMessage: Message = {
+ id: Date.now().toString(),
+ type: MessageType.BUTTONS,
+ sender: SenderType.BOT,
+ text: this.translationService.translate('bot.language.question'),
+ timestamp: new Date(),
+ buttons: [
+ {
+ id: 'btn-es',
+ label: this.translationService.translate('language.spanish'),
+ value: 'es',
+ },
+ {
+ id: 'btn-en',
+ label: this.translationService.translate('language.english'),
+ value: 'en',
+ },
+ ],
+ };
+ this.stateService.addMessage(languageMessage);
+ }
+
+ private forgetUserData(): void {
+ if (confirm(this.translationService.translate('bot.forget.confirm'))) {
+ this.stateService.forgetUserData();
+ this.loadWelcomeMessage();
+
+ const confirmMessage: Message = {
+ id: Date.now().toString(),
+ type: MessageType.TEXT,
+ sender: SenderType.BOT,
+ content: this.translationService.translate('bot.forget.success'),
+ timestamp: new Date(),
+ };
+ this.stateService.addMessage(confirmMessage);
+ }
+ }
+
+ onSuggestionClick(value: string): void {
+ // Prevent multiple clicks if bot is responding
+ if (this.isBotResponding) {
+ return;
+ }
+ this.onSendMessage(value);
+ }
+
+ onButtonClick(value: string): void {
+ // Prevent multiple clicks if bot is responding
+ if (this.isBotResponding && value !== 'es' && value !== 'en') {
+ return;
+ }
+
+ // Language selection
+ if (value === 'es' || value === 'en') {
+ // Update preferences FIRST (saves to localStorage)
+ this.stateService.updateUserPreferences({ language: value });
+ // Update translation service (updates BehaviorSubject)
+ this.translationService.setLanguage(value);
+
+ // Confirmation message uses the NEW language
+ const confirmMessage: Message = {
+ id: Date.now().toString(),
+ type: MessageType.TEXT,
+ sender: SenderType.BOT,
+ content: this.translationService.translate(
+ value === 'es' ? 'bot.language.changed.es' : 'bot.language.changed.en'
+ ),
+ timestamp: new Date(),
+ };
+ this.stateService.addMessage(confirmMessage);
+ } else {
+ // Para otros botones, enviar como mensaje normal
+ this.onSendMessage(value);
+ }
+ }
+
+ toggleChat(): void {
+ this.stateService.toggleChat();
+ }
+
+ onClose(): void {
+ this.stateService.clearMessages();
+ this.stateService.setChatOpen(false);
+ this.loadWelcomeMessage();
+ }
+
+ private updateQuickSuggestions(): void {
+ const lang = this.translationService.getCurrentLanguage();
+
+ if (lang === 'es') {
+ this.quickSuggestions = [
+ { label: 'ÂżQuĂŠ es un chatbot?', value: 'ÂżQuĂŠ es un chatbot?' },
+ { label: 'CaracterĂsticas', value: 'CaracterĂsticas' },
+ { label: 'Precios', value: 'Precios' },
+ { label: 'Contacto', value: 'Contacto' },
+ { label: 'Ayuda', value: 'Ayuda' },
+ ];
+ } else {
+ this.quickSuggestions = [
+ { label: 'What is a chatbot?', value: 'What is a chatbot?' },
+ { label: 'Features', value: 'Features' },
+ { label: 'Pricing', value: 'Pricing' },
+ { label: 'Contact', value: 'Contact' },
+ { label: 'Help', value: 'Help' },
+ ];
+ }
+ }
+
+ scrollSuggestions(): void {
+ if (this.suggestionsScrollElement) {
+ const scrollContainer = this.suggestionsScrollElement.nativeElement;
+ const scrollAmount = 200;
+ scrollContainer.scrollBy({
+ left: scrollAmount,
+ behavior: 'smooth',
+ });
+
+ setTimeout(() => this.updateArrowsVisibility(), 300);
+ }
+ }
+
+ scrollSuggestionsLeft(): void {
+ if (this.suggestionsScrollElement) {
+ const scrollContainer = this.suggestionsScrollElement.nativeElement;
+ const scrollAmount = 200;
+ scrollContainer.scrollBy({
+ left: -scrollAmount,
+ behavior: 'smooth',
+ });
+
+ setTimeout(() => this.updateArrowsVisibility(), 300);
+ }
+ }
+
+ onSuggestionsScroll(): void {
+ this.updateArrowsVisibility();
+ }
+
+ private updateArrowsVisibility(): void {
+ if (this.suggestionsScrollElement) {
+ const scrollContainer = this.suggestionsScrollElement.nativeElement;
+ const scrollLeft = scrollContainer.scrollLeft;
+ const maxScroll =
+ scrollContainer.scrollWidth - scrollContainer.clientWidth;
+
+ // Mostrar flecha izquierda si no estamos al inicio
+ this.showLeftArrow = scrollLeft > 10;
+
+ // Mostrar flecha derecha si no estamos al final
+ this.showRightArrow = scrollLeft < maxScroll - 10;
+ }
+ }
+}
diff --git a/chat-widget/src/app/chat/chat-header/chat-header.component.html b/chat-widget/src/app/chat/chat-header/chat-header.component.html
new file mode 100644
index 0000000..fffb6a1
--- /dev/null
+++ b/chat-widget/src/app/chat/chat-header/chat-header.component.html
@@ -0,0 +1,90 @@
+
diff --git a/chat-widget/src/app/chat/chat-header/chat-header.component.scss b/chat-widget/src/app/chat/chat-header/chat-header.component.scss
new file mode 100644
index 0000000..1a66cc0
--- /dev/null
+++ b/chat-widget/src/app/chat/chat-header/chat-header.component.scss
@@ -0,0 +1,153 @@
+:host {
+ display: block;
+ flex-shrink: 0;
+}
+
+.chat-header {
+ background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
+ color: white;
+ padding: 16px 20px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ position: relative;
+ flex-shrink: 0;
+}
+
+.header-content {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.bot-avatar {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ background: white;
+ padding: 4px;
+}
+
+.bot-info {
+ display: flex;
+ flex-direction: column;
+}
+
+.bot-name {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.bot-status {
+ font-size: 12px;
+ opacity: 0.9;
+}
+
+.header-actions {
+ display: flex;
+ gap: 4px;
+}
+
+.icon-button {
+ background: transparent;
+ border: none;
+ color: white;
+ cursor: pointer;
+ padding: 6px;
+ border-radius: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.2s;
+ width: 32px;
+ height: 32px;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.1);
+ }
+
+ svg {
+ width: 18px;
+ height: 18px;
+ }
+}
+
+.dropdown-menu {
+ position: absolute;
+ top: calc(100% + 4px);
+ right: 8px;
+ background: white;
+ border-radius: 10px;
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04), 0 4px 6px -1px rgba(0, 0, 0, 0.1),
+ 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 20px 25px -5px rgba(0, 0, 0, 0.08);
+ min-width: 180px;
+ overflow: hidden;
+ z-index: 1000;
+ transform-origin: top right;
+ animation: slideDown 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
+
+ &::before {
+ content: "";
+ position: absolute;
+ top: -4px;
+ right: 12px;
+ width: 8px;
+ height: 8px;
+ background: white;
+ transform: rotate(45deg);
+ box-shadow: -1px -1px 2px rgba(0, 0, 0, 0.05);
+ }
+
+ .menu-item {
+ width: 100%;
+ padding: 10px 14px;
+ border: none;
+ background: white;
+ text-align: left;
+ cursor: pointer;
+ font-size: 13px;
+ color: #374151;
+ transition: all 0.15s ease;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.04);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 500;
+
+ &:first-child {
+ margin-top: 4px;
+ }
+
+ &:last-child {
+ border-bottom: none;
+ margin-bottom: 4px;
+ }
+
+ &:hover {
+ background: linear-gradient(
+ 90deg,
+ rgba(231, 76, 60, 0.06) 0%,
+ rgba(231, 76, 60, 0.02) 100%
+ );
+ color: #e74c3c;
+ padding-left: 18px;
+ }
+
+ &:active {
+ background: rgba(231, 76, 60, 0.1);
+ transform: scale(0.98);
+ }
+ }
+}
+
+@keyframes slideDown {
+ 0% {
+ opacity: 0;
+ transform: translateY(-8px) scale(0.95);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
diff --git a/chat-widget/src/app/chat/chat-header/chat-header.component.ts b/chat-widget/src/app/chat/chat-header/chat-header.component.ts
new file mode 100644
index 0000000..41d8ea1
--- /dev/null
+++ b/chat-widget/src/app/chat/chat-header/chat-header.component.ts
@@ -0,0 +1,37 @@
+import { Component, Input, Output, EventEmitter } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { BotProfile } from '../../core/models';
+import { TranslatePipe } from '../../core/pipes/translate.pipe';
+
+@Component({
+ selector: 'app-chat-header',
+ standalone: true,
+ imports: [CommonModule, TranslatePipe],
+ templateUrl: './chat-header.component.html',
+ styleUrls: ['./chat-header.component.scss'],
+})
+export class ChatHeaderComponent {
+ @Input() botProfile!: BotProfile;
+ @Output() menuAction = new EventEmitter();
+ @Output() minimize = new EventEmitter();
+ @Output() close = new EventEmitter();
+
+ showMenu = false;
+
+ toggleMenu(): void {
+ this.showMenu = !this.showMenu;
+ }
+
+ onMenuClick(action: string): void {
+ this.menuAction.emit(action);
+ this.showMenu = false;
+ }
+
+ onClose(): void {
+ this.close.emit();
+ }
+
+ onMinimize(): void {
+ this.minimize.emit();
+ }
+}
diff --git a/chat-widget/src/app/chat/chat-input/chat-input.component.html b/chat-widget/src/app/chat/chat-input/chat-input.component.html
new file mode 100644
index 0000000..3521f95
--- /dev/null
+++ b/chat-widget/src/app/chat/chat-input/chat-input.component.html
@@ -0,0 +1,35 @@
+
diff --git a/chat-widget/src/app/chat/chat-input/chat-input.component.scss b/chat-widget/src/app/chat/chat-input/chat-input.component.scss
new file mode 100644
index 0000000..ca8149f
--- /dev/null
+++ b/chat-widget/src/app/chat/chat-input/chat-input.component.scss
@@ -0,0 +1,75 @@
+:host {
+ display: block;
+ flex-shrink: 0;
+}
+
+.chat-input-container {
+ display: flex;
+ gap: 8px;
+ padding: 16px;
+ border-top: 1px solid #e5e7eb;
+ background: white;
+ flex-shrink: 0;
+}
+
+.message-input {
+ flex: 1;
+ padding: 12px 16px;
+ border: 1px solid #e5e7eb;
+ border-radius: 24px;
+ font-size: 14px;
+ outline: none;
+ transition: border-color 0.2s;
+
+ &:focus {
+ border-color: #0066ff;
+ }
+
+ &:disabled {
+ background: #f3f4f6;
+ cursor: not-allowed;
+ color: #9ca3af;
+ }
+
+ &::placeholder {
+ color: #9ca3af;
+ }
+}
+
+.send-button {
+ background: #0066ff;
+ border: none;
+ color: white;
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: all 0.2s;
+ flex-shrink: 0;
+
+ &:hover:not(:disabled) {
+ background: #0052cc;
+ transform: scale(1.05);
+ }
+
+ &:disabled {
+ background: #d1d5db;
+ cursor: not-allowed;
+ }
+
+ &.stop-button {
+ background: #ef4444;
+
+ &:hover {
+ background: #dc2626;
+ }
+ }
+
+ svg {
+ width: 20px;
+ height: 20px;
+ }
+}
diff --git a/chat-widget/src/app/chat/chat-input/chat-input.component.spec.ts b/chat-widget/src/app/chat/chat-input/chat-input.component.spec.ts
new file mode 100644
index 0000000..d8c7a57
--- /dev/null
+++ b/chat-widget/src/app/chat/chat-input/chat-input.component.spec.ts
@@ -0,0 +1,199 @@
+import {
+ ComponentFixture,
+ TestBed,
+ fakeAsync,
+ tick,
+} from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { ChatInputComponent } from './chat-input.component';
+import { StateService } from '../../core/services';
+import { TranslatePipe } from '../../core/pipes/translate.pipe';
+
+describe('ChatInputComponent', () => {
+ let component: ChatInputComponent;
+ let fixture: ComponentFixture;
+ let stateService: StateService;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ChatInputComponent, FormsModule, TranslatePipe],
+ providers: [StateService],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ChatInputComponent);
+ component = fixture.componentInstance;
+ stateService = TestBed.inject(StateService);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('Message Sending', () => {
+ it('should emit message on send', () => {
+ spyOn(component.sendMessage, 'emit');
+ component.messageText = 'Test message';
+ component.onSend();
+
+ expect(component.sendMessage.emit).toHaveBeenCalledWith('Test message');
+ });
+
+ it('should clear input after sending', () => {
+ component.messageText = 'Test message';
+ component.onSend();
+
+ expect(component.messageText).toBe('');
+ });
+
+ it('should not send empty message', () => {
+ spyOn(component.sendMessage, 'emit');
+ component.messageText = '';
+ component.onSend();
+
+ expect(component.sendMessage.emit).not.toHaveBeenCalled();
+ });
+
+ it('should trim whitespace before sending', () => {
+ spyOn(component.sendMessage, 'emit');
+ component.messageText = ' Test ';
+ component.onSend();
+
+ expect(component.sendMessage.emit).toHaveBeenCalledWith('Test');
+ });
+
+ it('should not send message with only whitespace', () => {
+ spyOn(component.sendMessage, 'emit');
+ component.messageText = ' ';
+ component.onSend();
+
+ expect(component.sendMessage.emit).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Enter Key Handling', () => {
+ it('should send message on Enter key', () => {
+ spyOn(component.sendMessage, 'emit');
+ component.messageText = 'Test';
+
+ const event = new KeyboardEvent('keypress', { key: 'Enter' });
+ spyOn(event, 'preventDefault');
+ component.onKeyPress(event);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(component.sendMessage.emit).toHaveBeenCalledWith('Test');
+ });
+
+ it('should not send on Shift+Enter', () => {
+ spyOn(component.sendMessage, 'emit');
+ component.messageText = 'Test';
+
+ const event = new KeyboardEvent('keypress', {
+ key: 'Enter',
+ shiftKey: true,
+ });
+ component.onKeyPress(event);
+
+ expect(component.sendMessage.emit).not.toHaveBeenCalled();
+ });
+
+ it('should not send on other keys', () => {
+ spyOn(component.sendMessage, 'emit');
+ component.messageText = 'Test';
+
+ const event = new KeyboardEvent('keypress', { key: 'a' });
+ component.onKeyPress(event);
+
+ expect(component.sendMessage.emit).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Bot Responding State', () => {
+ it('should disable input when bot is responding', () => {
+ stateService.setBotResponding(true);
+ fixture.detectChanges();
+
+ expect(component.isBotResponding).toBe(true);
+ });
+
+ it('should enable input when bot finished responding', () => {
+ stateService.setBotResponding(true);
+ fixture.detectChanges();
+
+ stateService.setBotResponding(false);
+ fixture.detectChanges();
+
+ expect(component.isBotResponding).toBe(false);
+ });
+
+ it('should emit stopBot when clicking send while bot responding', () => {
+ spyOn(component.stopBot, 'emit');
+ stateService.setBotResponding(true);
+ fixture.detectChanges();
+
+ component.onSend();
+
+ expect(component.stopBot.emit).toHaveBeenCalled();
+ });
+
+ it('should not send message when bot is responding', () => {
+ spyOn(component.sendMessage, 'emit');
+ component.messageText = 'Test';
+ stateService.setBotResponding(true);
+ fixture.detectChanges();
+
+ // Intentar enviar con Enter
+ const event = new KeyboardEvent('keypress', { key: 'Enter' });
+ component.onKeyPress(event);
+
+ expect(component.sendMessage.emit).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('UI Integration', () => {
+ it('should have input element', () => {
+ const input = fixture.nativeElement.querySelector('input');
+ expect(input).toBeTruthy();
+ });
+
+ it('should have send button', () => {
+ const button = fixture.nativeElement.querySelector('button');
+ expect(button).toBeTruthy();
+ });
+
+ it('should bind messageText to input', fakeAsync(() => {
+ const input = fixture.nativeElement.querySelector('input');
+ component.messageText = 'Test';
+ fixture.detectChanges();
+ tick();
+
+ expect(input.value).toBe('Test');
+ }));
+
+ it('should disable button when input is empty', () => {
+ component.messageText = '';
+ component.isBotResponding = false;
+ fixture.detectChanges();
+
+ const button = fixture.nativeElement.querySelector('button');
+ expect(button.disabled).toBe(true);
+ });
+
+ it('should enable button when input has text', () => {
+ component.messageText = 'Test';
+ component.isBotResponding = false;
+ fixture.detectChanges();
+
+ const button = fixture.nativeElement.querySelector('button');
+ expect(button.disabled).toBe(false);
+ });
+
+ it('should show stop icon when bot is responding', () => {
+ stateService.setBotResponding(true);
+ fixture.detectChanges();
+
+ const stopIcon = fixture.nativeElement.querySelector('svg rect');
+ expect(stopIcon).toBeTruthy();
+ });
+ });
+});
diff --git a/chat-widget/src/app/chat/chat-input/chat-input.component.ts b/chat-widget/src/app/chat/chat-input/chat-input.component.ts
new file mode 100644
index 0000000..5d54c93
--- /dev/null
+++ b/chat-widget/src/app/chat/chat-input/chat-input.component.ts
@@ -0,0 +1,66 @@
+import {
+ Component,
+ Output,
+ EventEmitter,
+ OnInit,
+ OnDestroy,
+} from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { Subject, takeUntil } from 'rxjs';
+import { TranslatePipe } from '../../core/pipes/translate.pipe';
+import { StateService } from '../../core/services';
+
+@Component({
+ selector: 'app-chat-input',
+ standalone: true,
+ imports: [CommonModule, FormsModule, TranslatePipe],
+ templateUrl: './chat-input.component.html',
+ styleUrls: ['./chat-input.component.scss'],
+})
+export class ChatInputComponent implements OnInit, OnDestroy {
+ @Output() sendMessage = new EventEmitter();
+ @Output() stopBot = new EventEmitter();
+
+ messageText = '';
+ isBotResponding = false;
+ private destroy$ = new Subject();
+
+ constructor(private stateService: StateService) {}
+
+ ngOnInit(): void {
+ this.stateService.isBotResponding$
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((isResponding) => {
+ this.isBotResponding = isResponding;
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ onSend(): void {
+ if (this.isBotResponding) {
+ // If bot is responding, stop it
+ this.stopBot.emit();
+ } else {
+ // Send normal message
+ const text = this.messageText.trim();
+ if (text) {
+ this.sendMessage.emit(text);
+ this.messageText = '';
+ }
+ }
+ }
+
+ onKeyPress(event: KeyboardEvent): void {
+ if (event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault();
+ if (!this.isBotResponding) {
+ this.onSend();
+ }
+ }
+ }
+}
diff --git a/chat-widget/src/app/chat/chat-messages/chat-messages.component.html b/chat-widget/src/app/chat/chat-messages/chat-messages.component.html
new file mode 100644
index 0000000..8ddb6ca
--- /dev/null
+++ b/chat-widget/src/app/chat/chat-messages/chat-messages.component.html
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/chat-widget/src/app/chat/chat-messages/chat-messages.component.scss b/chat-widget/src/app/chat/chat-messages/chat-messages.component.scss
new file mode 100644
index 0000000..e8c6de4
--- /dev/null
+++ b/chat-widget/src/app/chat/chat-messages/chat-messages.component.scss
@@ -0,0 +1,221 @@
+:host {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow: hidden;
+}
+
+.messages-container {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+ background: #f0f2f5;
+ scroll-behavior: smooth;
+
+ &::-webkit-scrollbar {
+ width: 6px;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: #c1c7cd;
+ border-radius: 3px;
+
+ &:hover {
+ background: #a8aeb4;
+ }
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+}
+
+.news-carousel {
+ display: flex;
+ flex-direction: column;
+ margin: 8px 0;
+ animation: fadeIn 0.5s ease-out;
+}
+
+.carousel-container {
+ position: relative;
+ overflow: hidden;
+ border-radius: 10px;
+ margin: 0 auto;
+ max-width: 220px;
+ width: 100%;
+}
+
+.carousel-arrow {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ background: rgba(255, 255, 255, 0.95);
+ border: none;
+ border-radius: 50%;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ z-index: 10;
+ transition: all 0.2s;
+ color: #374151;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ animation: fadeIn 0.2s ease-out;
+
+ &:hover {
+ background: white;
+ transform: translateY(-50%) scale(1.1);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+ }
+
+ &:active {
+ transform: translateY(-50%) scale(0.95);
+ }
+
+ &.carousel-arrow-left {
+ left: 8px;
+ }
+
+ &.carousel-arrow-right {
+ right: 8px;
+ }
+}
+
+.carousel-track {
+ display: flex;
+ transition: transform 0.3s ease-in-out;
+}
+
+.carousel-slide {
+ min-width: 100%;
+ width: 100%;
+ flex-shrink: 0;
+ display: flex;
+}
+
+.news-card {
+ background: white;
+ border-radius: 10px;
+ overflow: hidden;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
+ width: 100%;
+}
+
+.news-image-container {
+ width: 100%;
+ height: 110px;
+ overflow: hidden;
+ background: #f3f4f6;
+}
+
+.news-image {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.news-content {
+ padding: 10px;
+}
+
+.news-title {
+ margin: 0 0 4px 0;
+ font-size: 13px;
+ font-weight: 400;
+ color: #5b6dcd;
+ line-height: 1.3;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.news-description {
+ margin: 0 0 8px 0;
+ font-size: 11px;
+ color: #6b7280;
+ line-height: 1.3;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.news-link {
+ display: inline-block;
+ padding: 0;
+ background: transparent;
+ color: #5b6dcd;
+ text-decoration: none;
+ font-size: 12px;
+ font-weight: 400;
+ transition: color 0.2s;
+
+ &:hover {
+ color: #4a5ab8;
+ text-decoration: underline;
+ }
+}
+
+.carousel-dots {
+ display: flex;
+ justify-content: center;
+ gap: 5px;
+ padding: 8px;
+ margin-top: 4px;
+}
+
+.dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #d1d5db;
+ transition: all 0.3s;
+ cursor: pointer;
+
+ &.active {
+ background: #17a2b8;
+ transform: scale(1.2);
+ }
+
+ &:hover {
+ background: #9ca3af;
+ }
+}
+
+.messages-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.message-wrapper {
+ animation: slideIn 0.3s ease-out;
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
diff --git a/chat-widget/src/app/chat/chat-messages/chat-messages.component.ts b/chat-widget/src/app/chat/chat-messages/chat-messages.component.ts
new file mode 100644
index 0000000..9064e92
--- /dev/null
+++ b/chat-widget/src/app/chat/chat-messages/chat-messages.component.ts
@@ -0,0 +1,137 @@
+import {
+ Component,
+ Input,
+ Output,
+ EventEmitter,
+ AfterViewChecked,
+ ElementRef,
+ ViewChild,
+ OnInit,
+ OnDestroy,
+} from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { Subject, takeUntil } from 'rxjs';
+import {
+ Message,
+ BotProfile,
+ MessageType,
+ TextMessage,
+ ImageCardMessage,
+ ButtonsMessage,
+} from '../../core/models';
+import { StateService } from '../../core/services';
+import { MessageTextComponent } from '../message-text/message-text.component';
+import { MessageCardComponent } from '../message-card/message-card.component';
+import { MessageButtonsComponent } from '../message-buttons/message-buttons.component';
+import { TypingIndicatorComponent } from '../typing-indicator/typing-indicator.component';
+
+@Component({
+ selector: 'app-chat-messages',
+ standalone: true,
+ imports: [
+ CommonModule,
+ MessageTextComponent,
+ MessageCardComponent,
+ MessageButtonsComponent,
+ TypingIndicatorComponent,
+ ],
+ templateUrl: './chat-messages.component.html',
+ styleUrls: ['./chat-messages.component.scss'],
+})
+export class ChatMessagesComponent
+ implements AfterViewChecked, OnInit, OnDestroy
+{
+ @Input() messages: Message[] = [];
+ @Input() botProfile!: BotProfile;
+ @Output() buttonClick = new EventEmitter();
+ @ViewChild('messagesContainer') messagesContainer!: ElementRef;
+
+ MessageType = MessageType;
+ currentNewsIndex = 0;
+ isTyping = false;
+ private destroy$ = new Subject();
+
+ // TODO: Externalizar el carrusel de noticias como componente independiente
+ newsCards = [
+ {
+ imageUrl:
+ 'https://images.unsplash.com/photo-1485827404703-89b55fcc595e?w=400',
+ title: 'La Generalitat ordena el confinamiento de Igualada y su...',
+ description:
+ 'Hace 34 minutos ... Estas poblaciones quedan confinadas por un brote de coronavirus desde las 21.00 horas de este jueves, ha anunciado el conseller de Interior.',
+ link: '#',
+ },
+ {
+ imageUrl:
+ 'https://images.unsplash.com/photo-1531746790731-6c087fecd65a?w=400',
+ title: 'Nuevas medidas de seguridad en el transporte pĂşblico',
+ description:
+ 'El gobierno anuncia refuerzos en los protocolos de seguridad para garantizar la salud de los usuarios del transporte.',
+ link: '#',
+ },
+ {
+ imageUrl:
+ 'https://images.unsplash.com/photo-1488590528505-98d2b5aba04b?w=400',
+ title: 'Avances en inteligencia artificial para chatbots',
+ description:
+ 'Las Ăşltimas innovaciones en IA permiten crear asistentes virtuales mĂĄs inteligentes y naturales.',
+ link: '#',
+ },
+ ];
+
+ constructor(private stateService: StateService) {}
+
+ ngOnInit(): void {
+ this.stateService.isTyping$
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((isTyping) => (this.isTyping = isTyping));
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ ngAfterViewChecked(): void {
+ this.scrollToBottom();
+ }
+
+ goToNews(index: number): void {
+ this.currentNewsIndex = index;
+ }
+
+ nextNews(): void {
+ if (this.currentNewsIndex < this.newsCards.length - 1) {
+ this.currentNewsIndex++;
+ }
+ }
+
+ previousNews(): void {
+ if (this.currentNewsIndex > 0) {
+ this.currentNewsIndex--;
+ }
+ }
+
+ private scrollToBottom(): void {
+ try {
+ this.messagesContainer.nativeElement.scrollTop =
+ this.messagesContainer.nativeElement.scrollHeight;
+ } catch (err) {}
+ }
+
+ asTextMessage(message: Message): TextMessage {
+ return message as TextMessage;
+ }
+
+ asImageCardMessage(message: Message): ImageCardMessage {
+ return message as ImageCardMessage;
+ }
+
+ asButtonsMessage(message: Message): ButtonsMessage {
+ return message as ButtonsMessage;
+ }
+
+ onButtonClick(value: string): void {
+ this.buttonClick.emit(value);
+ }
+}
diff --git a/chat-widget/src/app/chat/components/.gitkeep b/chat-widget/src/app/chat/components/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/chat-widget/src/app/chat/message-buttons/message-buttons.component.html b/chat-widget/src/app/chat/message-buttons/message-buttons.component.html
new file mode 100644
index 0000000..8f4ba24
--- /dev/null
+++ b/chat-widget/src/app/chat/message-buttons/message-buttons.component.html
@@ -0,0 +1,34 @@
+
diff --git a/chat-widget/src/app/chat/message-buttons/message-buttons.component.scss b/chat-widget/src/app/chat/message-buttons/message-buttons.component.scss
new file mode 100644
index 0000000..0ebb7e2
--- /dev/null
+++ b/chat-widget/src/app/chat/message-buttons/message-buttons.component.scss
@@ -0,0 +1,92 @@
+.buttons-message {
+ max-width: 100%;
+ margin: 8px 0;
+}
+
+.buttons-text {
+ margin: 0 0 12px 0;
+ font-size: 14px;
+ color: #1f2937;
+ padding: 12px 16px;
+ background: #f3f4f6;
+ border-radius: 18px;
+ border-bottom-left-radius: 4px;
+}
+
+.buttons-container {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.buttons-scroll {
+ display: flex;
+ gap: 8px;
+ overflow-x: auto;
+ scroll-behavior: smooth;
+ padding: 4px 0;
+ flex: 1;
+
+ // Ocultar scrollbar pero mantener funcionalidad
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+.option-button {
+ padding: 10px 20px;
+ background: #f3f4f6;
+ border: 1px solid #e5e7eb;
+ color: #4b5563;
+ border-radius: 24px;
+ font-size: 14px;
+ font-weight: 400;
+ cursor: pointer;
+ transition: all 0.2s;
+ white-space: nowrap;
+ flex-shrink: 0;
+
+ &:hover:not(:disabled) {
+ background: #e5e7eb;
+ border-color: #d1d5db;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ &.selected {
+ background: #0066ff;
+ color: white;
+ border-color: #0066ff;
+ }
+}
+
+.scroll-arrow {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: white;
+ border: 1px solid #e5e7eb;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: all 0.2s;
+ color: #6b7280;
+
+ &:hover {
+ background: #f9fafb;
+ border-color: #d1d5db;
+ }
+
+ &:active {
+ transform: scale(0.95);
+ }
+}
diff --git a/chat-widget/src/app/chat/message-buttons/message-buttons.component.ts b/chat-widget/src/app/chat/message-buttons/message-buttons.component.ts
new file mode 100644
index 0000000..f44dc5a
--- /dev/null
+++ b/chat-widget/src/app/chat/message-buttons/message-buttons.component.ts
@@ -0,0 +1,48 @@
+import {
+ Component,
+ Input,
+ Output,
+ EventEmitter,
+ OnInit,
+ OnDestroy,
+} from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { Subject, takeUntil } from 'rxjs';
+import { ButtonsMessage } from '../../core/models';
+import { StateService } from '../../core/services';
+
+@Component({
+ selector: 'app-message-buttons',
+ standalone: true,
+ imports: [CommonModule],
+ templateUrl: './message-buttons.component.html',
+ styleUrls: ['./message-buttons.component.scss'],
+})
+export class MessageButtonsComponent implements OnInit, OnDestroy {
+ @Input() message!: ButtonsMessage;
+ @Output() buttonClick = new EventEmitter();
+
+ selectedButton: string | null = null;
+ isBotResponding = false;
+ private destroy$ = new Subject();
+
+ constructor(private stateService: StateService) {}
+
+ ngOnInit(): void {
+ this.stateService.isBotResponding$
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((isResponding) => (this.isBotResponding = isResponding));
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ onButtonClick(buttonId: string, buttonValue: string): void {
+ if (!this.isBotResponding && this.selectedButton === null) {
+ this.selectedButton = buttonId;
+ this.buttonClick.emit(buttonValue);
+ }
+ }
+}
diff --git a/chat-widget/src/app/chat/message-card/message-card.component.html b/chat-widget/src/app/chat/message-card/message-card.component.html
new file mode 100644
index 0000000..3f29226
--- /dev/null
+++ b/chat-widget/src/app/chat/message-card/message-card.component.html
@@ -0,0 +1,25 @@
+
diff --git a/chat-widget/src/app/chat/message-card/message-card.component.scss b/chat-widget/src/app/chat/message-card/message-card.component.scss
new file mode 100644
index 0000000..78a9d82
--- /dev/null
+++ b/chat-widget/src/app/chat/message-card/message-card.component.scss
@@ -0,0 +1,80 @@
+.card-message {
+ max-width: 300px;
+ background: white;
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+ margin: 8px 0;
+}
+
+.card-image-container {
+ width: 100%;
+ height: 180px;
+ overflow: hidden;
+ background: #f3f4f6;
+}
+
+.card-image {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.card-content {
+ padding: 16px;
+}
+
+.card-title {
+ margin: 0 0 8px 0;
+ font-size: 15px;
+ font-weight: 400;
+ color: #5b6dcd;
+ line-height: 1.4;
+}
+
+.card-description {
+ margin: 0 0 16px 0;
+ font-size: 13px;
+ color: #6b7280;
+ line-height: 1.5;
+}
+
+.card-button {
+ display: inline-block;
+ padding: 0;
+ background: transparent;
+ color: #5b6dcd;
+ text-decoration: none;
+ font-size: 14px;
+ font-weight: 400;
+ transition: color 0.2s;
+ border: none;
+ cursor: pointer;
+
+ &:hover {
+ color: #4a5ab8;
+ text-decoration: underline;
+ }
+}
+
+.carousel-dots {
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+ padding: 12px 16px;
+ background: linear-gradient(90deg, #17a2b8 0%, #138496 100%);
+}
+
+.dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.5);
+ transition: all 0.3s;
+ cursor: pointer;
+
+ &.active {
+ background: white;
+ transform: scale(1.2);
+ }
+}
diff --git a/chat-widget/src/app/chat/message-card/message-card.component.ts b/chat-widget/src/app/chat/message-card/message-card.component.ts
new file mode 100644
index 0000000..09ddbc4
--- /dev/null
+++ b/chat-widget/src/app/chat/message-card/message-card.component.ts
@@ -0,0 +1,22 @@
+import { Component, Input } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { ImageCardMessage } from '../../core/models';
+
+@Component({
+ selector: 'app-message-card',
+ standalone: true,
+ imports: [CommonModule],
+ templateUrl: './message-card.component.html',
+ styleUrls: ['./message-card.component.scss'],
+})
+export class MessageCardComponent {
+ @Input() message!: ImageCardMessage;
+ @Input() totalCards: number = 1;
+ @Input() currentIndex: number = 0;
+
+ get dots(): number[] {
+ return Array(this.totalCards)
+ .fill(0)
+ .map((_, i) => i);
+ }
+}
diff --git a/chat-widget/src/app/chat/message-text/message-text.component.html b/chat-widget/src/app/chat/message-text/message-text.component.html
new file mode 100644
index 0000000..3c79854
--- /dev/null
+++ b/chat-widget/src/app/chat/message-text/message-text.component.html
@@ -0,0 +1,22 @@
+
+
![Bot]()
+
+
+
+ {{ displayedContent
+ }}|
+
+
{{
+ message.timestamp | date : "shortTime"
+ }}
+
+
diff --git a/chat-widget/src/app/chat/message-text/message-text.component.scss b/chat-widget/src/app/chat/message-text/message-text.component.scss
new file mode 100644
index 0000000..9524762
--- /dev/null
+++ b/chat-widget/src/app/chat/message-text/message-text.component.scss
@@ -0,0 +1,87 @@
+.message {
+ display: flex;
+ gap: 10px;
+ align-items: flex-end;
+ margin-bottom: 4px;
+
+ &.bot {
+ justify-content: flex-start;
+ }
+
+ &.user {
+ justify-content: flex-end;
+ }
+}
+
+.avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: white;
+ padding: 2px;
+ flex-shrink: 0;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+}
+
+.message-bubble {
+ max-width: 75%;
+ padding: 10px 14px;
+ border-radius: 16px;
+ position: relative;
+ word-wrap: break-word;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
+
+ .message.bot & {
+ background: #ffffff;
+ color: #1f2937;
+ border-bottom-left-radius: 4px;
+ border: 1px solid rgba(0, 0, 0, 0.05);
+ }
+
+ .message.user & {
+ background: linear-gradient(135deg, #0084ff 0%, #0066cc 100%);
+ color: white;
+ border-bottom-right-radius: 4px;
+ box-shadow: 0 1px 3px rgba(0, 132, 255, 0.3);
+ }
+}
+
+.message-content {
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.5;
+ word-wrap: break-word;
+}
+
+.typing-cursor {
+ display: inline-block;
+ margin-left: 2px;
+ animation: blink 0.7s infinite;
+ font-weight: 300;
+}
+
+@keyframes blink {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0;
+ }
+}
+
+.message-time {
+ font-size: 11px;
+ opacity: 0.65;
+ display: block;
+ margin-top: 4px;
+ text-align: right;
+
+ .message.bot & {
+ color: #6b7280;
+ }
+
+ .message.user & {
+ color: rgba(255, 255, 255, 0.85);
+ }
+}
diff --git a/chat-widget/src/app/chat/message-text/message-text.component.ts b/chat-widget/src/app/chat/message-text/message-text.component.ts
new file mode 100644
index 0000000..8c42d33
--- /dev/null
+++ b/chat-widget/src/app/chat/message-text/message-text.component.ts
@@ -0,0 +1,75 @@
+import { Component, Input, OnInit, OnDestroy } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { Subject, takeUntil } from 'rxjs';
+import { Message, SenderType, TextMessage } from '../../core/models';
+import { StateService } from '../../core/services';
+
+@Component({
+ selector: 'app-message-text',
+ standalone: true,
+ imports: [CommonModule],
+ templateUrl: './message-text.component.html',
+ styleUrls: ['./message-text.component.scss'],
+})
+export class MessageTextComponent implements OnInit, OnDestroy {
+ @Input() message!: TextMessage;
+ @Input() botAvatar!: string;
+
+ SenderType = SenderType;
+ displayedContent = '';
+ isAnimating = false;
+ private typingInterval: any;
+ private destroy$ = new Subject();
+
+ constructor(private stateService: StateService) {}
+
+ ngOnInit(): void {
+ // Solo animar mensajes del bot
+ if (this.message.sender === SenderType.BOT) {
+ this.animateTyping();
+
+ // Escuchar si se cancela la respuesta
+ this.stateService.isBotResponding$
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((isResponding) => {
+ if (!isResponding && this.isAnimating) {
+ this.stopTyping();
+ }
+ });
+ } else {
+ this.displayedContent = this.message.content;
+ }
+ }
+
+ ngOnDestroy(): void {
+ this.stopTyping();
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ private animateTyping(): void {
+ this.isAnimating = true;
+ const fullText = this.message.content;
+ let currentIndex = 0;
+ const speed = 15;
+
+ this.typingInterval = setInterval(() => {
+ if (currentIndex < fullText.length) {
+ this.displayedContent = fullText.substring(0, currentIndex + 1);
+ currentIndex++;
+ } else {
+ this.stopTyping();
+ }
+ }, speed);
+ }
+
+ private stopTyping(): void {
+ if (this.typingInterval) {
+ clearInterval(this.typingInterval);
+ this.typingInterval = null;
+ }
+ this.isAnimating = false;
+ this.displayedContent = this.message.content;
+ this.stateService.setBotResponding(false);
+ }
+}
diff --git a/chat-widget/src/app/chat/typing-indicator/typing-indicator.component.html b/chat-widget/src/app/chat/typing-indicator/typing-indicator.component.html
new file mode 100644
index 0000000..472d5b1
--- /dev/null
+++ b/chat-widget/src/app/chat/typing-indicator/typing-indicator.component.html
@@ -0,0 +1,11 @@
+
+
![Bot]()
+
+
+
diff --git a/chat-widget/src/app/chat/typing-indicator/typing-indicator.component.scss b/chat-widget/src/app/chat/typing-indicator/typing-indicator.component.scss
new file mode 100644
index 0000000..ccf29fd
--- /dev/null
+++ b/chat-widget/src/app/chat/typing-indicator/typing-indicator.component.scss
@@ -0,0 +1,72 @@
+.typing-indicator {
+ display: flex;
+ gap: 10px;
+ align-items: flex-end;
+ margin-bottom: 4px;
+ animation: fadeIn 0.3s ease-out;
+}
+
+.avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: white;
+ padding: 2px;
+ flex-shrink: 0;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+}
+
+.typing-bubble {
+ background: #ffffff;
+ border: 1px solid rgba(0, 0, 0, 0.05);
+ border-radius: 16px;
+ border-bottom-left-radius: 4px;
+ padding: 14px 18px;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
+}
+
+.typing-dots {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+}
+
+.dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #9ca3af;
+ animation: bounce 1.4s infinite ease-in-out;
+
+ &:nth-child(1) {
+ animation-delay: -0.32s;
+ }
+
+ &:nth-child(2) {
+ animation-delay: -0.16s;
+ }
+}
+
+@keyframes bounce {
+ 0%,
+ 80%,
+ 100% {
+ transform: scale(0.8);
+ opacity: 0.5;
+ }
+ 40% {
+ transform: scale(1.1);
+ opacity: 1;
+ }
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
diff --git a/chat-widget/src/app/chat/typing-indicator/typing-indicator.component.ts b/chat-widget/src/app/chat/typing-indicator/typing-indicator.component.ts
new file mode 100644
index 0000000..810ab6e
--- /dev/null
+++ b/chat-widget/src/app/chat/typing-indicator/typing-indicator.component.ts
@@ -0,0 +1,13 @@
+import { Component, Input } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+@Component({
+ selector: 'app-typing-indicator',
+ standalone: true,
+ imports: [CommonModule],
+ templateUrl: './typing-indicator.component.html',
+ styleUrls: ['./typing-indicator.component.scss'],
+})
+export class TypingIndicatorComponent {
+ @Input() botAvatar!: string;
+}
diff --git a/chat-widget/src/app/core/models/bot-profile.model.ts b/chat-widget/src/app/core/models/bot-profile.model.ts
new file mode 100644
index 0000000..c04b2a0
--- /dev/null
+++ b/chat-widget/src/app/core/models/bot-profile.model.ts
@@ -0,0 +1,7 @@
+export interface BotProfile {
+ id: string;
+ name: string;
+ avatarUrl: string;
+ welcomeMessage?: string;
+ description?: string;
+}
diff --git a/chat-widget/src/app/core/models/chat-config.model.ts b/chat-widget/src/app/core/models/chat-config.model.ts
new file mode 100644
index 0000000..4e2cee1
--- /dev/null
+++ b/chat-widget/src/app/core/models/chat-config.model.ts
@@ -0,0 +1,17 @@
+import { BotProfile } from './bot-profile.model';
+
+export interface ChatConfig {
+ botProfile: BotProfile;
+ theme?: ChatTheme;
+ locale?: string;
+ enableCallToAction?: boolean;
+ privacyPolicyUrl?: string;
+ maxMessages?: number;
+}
+
+export interface ChatTheme {
+ primaryColor?: string;
+ botMessageBg?: string;
+ userMessageBg?: string;
+ fontFamily?: string;
+}
diff --git a/chat-widget/src/app/core/models/index.ts b/chat-widget/src/app/core/models/index.ts
new file mode 100644
index 0000000..8b59c94
--- /dev/null
+++ b/chat-widget/src/app/core/models/index.ts
@@ -0,0 +1,6 @@
+export * from './message-type.enum';
+export * from './menu-option.model';
+export * from './message.model';
+export * from './bot-profile.model';
+export * from './user.model';
+export * from './chat-config.model';
diff --git a/chat-widget/src/app/core/models/menu-option.model.ts b/chat-widget/src/app/core/models/menu-option.model.ts
new file mode 100644
index 0000000..c53cdb1
--- /dev/null
+++ b/chat-widget/src/app/core/models/menu-option.model.ts
@@ -0,0 +1,12 @@
+export enum MenuOptionType {
+ FORGET_DATA = 'forget-data',
+ CHANGE_LANGUAGE = 'change-language',
+ PRIVACY_POLICY = 'privacy-policy',
+}
+
+export interface MenuOption {
+ type: MenuOptionType;
+ label: string;
+ icon?: string;
+ action: () => void;
+}
diff --git a/chat-widget/src/app/core/models/message-type.enum.ts b/chat-widget/src/app/core/models/message-type.enum.ts
new file mode 100644
index 0000000..02c6e6d
--- /dev/null
+++ b/chat-widget/src/app/core/models/message-type.enum.ts
@@ -0,0 +1,10 @@
+export enum MessageType {
+ TEXT = 'text',
+ IMAGE_CARD = 'image-card',
+ BUTTONS = 'buttons',
+}
+
+export enum SenderType {
+ BOT = 'bot',
+ USER = 'user',
+}
diff --git a/chat-widget/src/app/core/models/message.model.ts b/chat-widget/src/app/core/models/message.model.ts
new file mode 100644
index 0000000..0e4a7b3
--- /dev/null
+++ b/chat-widget/src/app/core/models/message.model.ts
@@ -0,0 +1,37 @@
+import { MessageType, SenderType } from './message-type.enum';
+
+export interface BaseMessage {
+ id: string;
+ type: MessageType;
+ sender: SenderType;
+ timestamp: Date;
+}
+
+export interface TextMessage extends BaseMessage {
+ type: MessageType.TEXT;
+ content: string;
+}
+
+export interface ImageCardMessage extends BaseMessage {
+ type: MessageType.IMAGE_CARD;
+ imageUrl: string;
+ title: string;
+ description: string;
+ buttonText?: string;
+ buttonUrl?: string;
+}
+
+export interface ButtonOption {
+ id: string;
+ label: string;
+ value: string;
+}
+
+export interface ButtonsMessage extends BaseMessage {
+ type: MessageType.BUTTONS;
+ text: string;
+ buttons: ButtonOption[];
+ selectedButtonId?: string;
+}
+
+export type Message = TextMessage | ImageCardMessage | ButtonsMessage;
diff --git a/chat-widget/src/app/core/models/user.model.ts b/chat-widget/src/app/core/models/user.model.ts
new file mode 100644
index 0000000..f60456d
--- /dev/null
+++ b/chat-widget/src/app/core/models/user.model.ts
@@ -0,0 +1,12 @@
+export interface User {
+ id: string;
+ name?: string;
+ email?: string;
+ avatarUrl?: string;
+}
+
+export interface UserPreferences {
+ language: string;
+ theme?: 'light' | 'dark';
+ notificationsEnabled?: boolean;
+}
diff --git a/chat-widget/src/app/core/pipes/translate.pipe.ts b/chat-widget/src/app/core/pipes/translate.pipe.ts
new file mode 100644
index 0000000..6074588
--- /dev/null
+++ b/chat-widget/src/app/core/pipes/translate.pipe.ts
@@ -0,0 +1,41 @@
+import {
+ Pipe,
+ PipeTransform,
+ ChangeDetectorRef,
+ OnDestroy,
+} from '@angular/core';
+import { TranslationService } from '../services/translation.service';
+import { Subscription } from 'rxjs';
+
+@Pipe({
+ name: 'translate',
+ standalone: true,
+ pure: false,
+})
+export class TranslatePipe implements PipeTransform, OnDestroy {
+ private subscription?: Subscription;
+ private lastLanguage?: string;
+
+ constructor(
+ private translationService: TranslationService,
+ private cdr: ChangeDetectorRef
+ ) {
+ // Suscribirse a cambios de idioma
+ this.subscription = this.translationService.currentLanguage$.subscribe(
+ () => {
+ this.cdr.markForCheck();
+ }
+ );
+ }
+
+ transform(key: string, params?: Record): string {
+ if (params) {
+ return this.translationService.translateWithParams(key, params);
+ }
+ return this.translationService.translate(key);
+ }
+
+ ngOnDestroy(): void {
+ this.subscription?.unsubscribe();
+ }
+}
diff --git a/chat-widget/src/app/core/services/chat.service.interface.ts b/chat-widget/src/app/core/services/chat.service.interface.ts
new file mode 100644
index 0000000..e9fea77
--- /dev/null
+++ b/chat-widget/src/app/core/services/chat.service.interface.ts
@@ -0,0 +1,8 @@
+import { Observable } from 'rxjs';
+import { Message } from '../models';
+
+export interface IChatService {
+ sendMessage(message: string): Observable;
+ getWelcomeMessage(): Observable;
+ handleButtonClick(buttonValue: string): Observable;
+}
diff --git a/chat-widget/src/app/core/services/index.ts b/chat-widget/src/app/core/services/index.ts
new file mode 100644
index 0000000..ef37a94
--- /dev/null
+++ b/chat-widget/src/app/core/services/index.ts
@@ -0,0 +1,3 @@
+export * from './chat.service.interface';
+export * from './state.service';
+export * from './translation.service';
diff --git a/chat-widget/src/app/core/services/state.service.spec.ts b/chat-widget/src/app/core/services/state.service.spec.ts
new file mode 100644
index 0000000..bdfffb0
--- /dev/null
+++ b/chat-widget/src/app/core/services/state.service.spec.ts
@@ -0,0 +1,293 @@
+import { TestBed } from '@angular/core/testing';
+import { StateService } from './state.service';
+import {
+ Message,
+ MessageType,
+ SenderType,
+ UserPreferences,
+ TextMessage,
+} from '../models';
+
+describe('StateService', () => {
+ let service: StateService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(StateService);
+ // Limpiar localStorage antes de cada test
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('Messages Management', () => {
+ it('should add a message', (done) => {
+ const testMessage: Message = {
+ id: '1',
+ type: MessageType.TEXT,
+ sender: SenderType.USER,
+ content: 'Test message',
+ timestamp: new Date(),
+ };
+
+ service.messages$.subscribe((messages) => {
+ if (messages.length > 0) {
+ expect(messages.length).toBe(1);
+ expect((messages[0] as TextMessage).content).toBe('Test message');
+ done();
+ }
+ });
+
+ service.addMessage(testMessage);
+ });
+
+ it('should add multiple messages', (done) => {
+ const message1: Message = {
+ id: '1',
+ type: MessageType.TEXT,
+ sender: SenderType.USER,
+ content: 'Message 1',
+ timestamp: new Date(),
+ };
+
+ const message2: Message = {
+ id: '2',
+ type: MessageType.TEXT,
+ sender: SenderType.BOT,
+ content: 'Message 2',
+ timestamp: new Date(),
+ };
+
+ service.addMessage(message1);
+ service.addMessage(message2);
+
+ service.messages$.subscribe((messages) => {
+ if (messages.length === 2) {
+ expect(messages.length).toBe(2);
+ expect((messages[0] as TextMessage).content).toBe('Message 1');
+ expect((messages[1] as TextMessage).content).toBe('Message 2');
+ done();
+ }
+ });
+ });
+
+ it('should get current messages', () => {
+ const testMessage: Message = {
+ id: '1',
+ type: MessageType.TEXT,
+ sender: SenderType.USER,
+ content: 'Test',
+ timestamp: new Date(),
+ };
+
+ service.addMessage(testMessage);
+ const messages = service.getMessages();
+
+ expect(messages.length).toBe(1);
+ expect(messages[0].id).toBe('1');
+ });
+
+ it('should clear all messages', (done) => {
+ const testMessage: Message = {
+ id: '1',
+ type: MessageType.TEXT,
+ sender: SenderType.USER,
+ content: 'Test',
+ timestamp: new Date(),
+ };
+
+ service.addMessage(testMessage);
+ service.clearMessages();
+
+ service.messages$.subscribe((messages) => {
+ expect(messages.length).toBe(0);
+ done();
+ });
+ });
+
+ it('should persist messages to localStorage', () => {
+ const testMessage: Message = {
+ id: '1',
+ type: MessageType.TEXT,
+ sender: SenderType.USER,
+ content: 'Test',
+ timestamp: new Date(),
+ };
+
+ service.addMessage(testMessage);
+
+ const stored = localStorage.getItem('chat_messages');
+ expect(stored).toBeTruthy();
+ const parsed = JSON.parse(stored!);
+ expect(parsed.length).toBe(1);
+ expect(parsed[0].content).toBe('Test');
+ });
+ });
+
+ describe('User Preferences', () => {
+ it('should update user preferences', (done) => {
+ const newPrefs: Partial = {
+ language: 'en',
+ };
+
+ service.updateUserPreferences(newPrefs);
+
+ service.userPreferences$.subscribe((prefs) => {
+ if (prefs.language === 'en') {
+ expect(prefs.language).toBe('en');
+ done();
+ }
+ });
+ });
+
+ it('should get current user preferences', () => {
+ const prefs = service.getUserPreferences();
+ expect(prefs).toBeTruthy();
+ expect(prefs.language).toBeDefined();
+ expect(prefs.theme).toBeDefined();
+ });
+
+ it('should persist preferences to localStorage', () => {
+ const newPrefs: Partial = {
+ language: 'en',
+ };
+
+ service.updateUserPreferences(newPrefs);
+
+ const stored = localStorage.getItem('chat_user_preferences');
+ expect(stored).toBeTruthy();
+ const parsed = JSON.parse(stored!);
+ expect(parsed.language).toBe('en');
+ });
+ });
+
+ describe('Chat State', () => {
+ it('should toggle chat open state', (done) => {
+ let callCount = 0;
+
+ service.isChatOpen$.subscribe((isOpen) => {
+ callCount++;
+ if (callCount === 1) {
+ expect(isOpen).toBe(false);
+ } else if (callCount === 2) {
+ expect(isOpen).toBe(true);
+ done();
+ }
+ });
+
+ service.toggleChat();
+ });
+
+ it('should set chat open state directly', (done) => {
+ service.setChatOpen(true);
+
+ service.isChatOpen$.subscribe((isOpen) => {
+ if (isOpen) {
+ expect(isOpen).toBe(true);
+ done();
+ }
+ });
+ });
+ });
+
+ describe('Typing State', () => {
+ it('should set typing state', (done) => {
+ service.setTyping(true);
+
+ service.isTyping$.subscribe((isTyping) => {
+ if (isTyping) {
+ expect(isTyping).toBe(true);
+ done();
+ }
+ });
+ });
+
+ it('should set bot responding state', (done) => {
+ service.setBotResponding(true);
+
+ service.isBotResponding$.subscribe((isResponding) => {
+ if (isResponding) {
+ expect(isResponding).toBe(true);
+ done();
+ }
+ });
+ });
+ });
+
+ describe('Forget User Data', () => {
+ it('should clear all data when forgetting user', (done) => {
+ // Agregar datos
+ const testMessage: Message = {
+ id: '1',
+ type: MessageType.TEXT,
+ sender: SenderType.USER,
+ content: 'Test',
+ timestamp: new Date(),
+ };
+ service.addMessage(testMessage);
+ service.updateUserPreferences({ language: 'en' });
+
+ // Olvidar datos
+ service.forgetUserData();
+
+ service.messages$.subscribe((messages) => {
+ expect(messages.length).toBe(0);
+ });
+
+ service.userPreferences$.subscribe((prefs) => {
+ expect(prefs.language).toBe('en'); // Reset a valores por defecto
+ done();
+ });
+
+ // Verificar localStorage limpio
+ expect(localStorage.getItem('chat_messages')).toBeNull();
+ expect(localStorage.getItem('chat_user_data')).toBeNull();
+ });
+ });
+
+ describe('LocalStorage Integration', () => {
+ it('should load messages from localStorage on init', () => {
+ const testMessages = [
+ {
+ id: '1',
+ type: MessageType.TEXT,
+ sender: SenderType.USER,
+ content: 'Stored message',
+ timestamp: new Date().toISOString(),
+ },
+ ];
+
+ localStorage.setItem('chat_messages', JSON.stringify(testMessages));
+
+ // Crear nueva instancia del servicio
+ const newService = new StateService();
+ const messages = newService.getMessages();
+
+ expect(messages.length).toBe(1);
+ expect((messages[0] as TextMessage).content).toBe('Stored message');
+ });
+
+ it('should load preferences from localStorage on init', () => {
+ const testPrefs: UserPreferences = {
+ language: 'en',
+ theme: 'dark',
+ notificationsEnabled: false,
+ };
+
+ localStorage.setItem('chat_user_preferences', JSON.stringify(testPrefs));
+
+ // Crear nueva instancia del servicio
+ const newService = new StateService();
+ const prefs = newService.getUserPreferences();
+
+ expect(prefs.language).toBe('en');
+ expect(prefs.theme).toBe('dark');
+ });
+ });
+});
diff --git a/chat-widget/src/app/core/services/state.service.ts b/chat-widget/src/app/core/services/state.service.ts
new file mode 100644
index 0000000..75b06ea
--- /dev/null
+++ b/chat-widget/src/app/core/services/state.service.ts
@@ -0,0 +1,162 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject, Observable } from 'rxjs';
+import { Message, UserPreferences } from '../models';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class StateService {
+ private readonly STORAGE_KEYS = {
+ MESSAGES: 'chat_messages',
+ USER_PREFS: 'chat_user_preferences',
+ USER_DATA: 'chat_user_data',
+ };
+
+ private messagesSubject = new BehaviorSubject([]);
+ public messages$: Observable = this.messagesSubject.asObservable();
+
+ private userPreferencesSubject = new BehaviorSubject({
+ language: 'es',
+ theme: 'light',
+ notificationsEnabled: true,
+ });
+ public userPreferences$: Observable =
+ this.userPreferencesSubject.asObservable();
+
+ private isChatOpenSubject = new BehaviorSubject(false);
+ public isChatOpen$: Observable =
+ this.isChatOpenSubject.asObservable();
+
+ private isTypingSubject = new BehaviorSubject(false);
+ public isTyping$: Observable =
+ this.isTypingSubject.asObservable();
+
+ private isBotRespondingSubject = new BehaviorSubject(false);
+ public isBotResponding$: Observable =
+ this.isBotRespondingSubject.asObservable();
+
+ constructor() {
+ this.loadFromStorage();
+ }
+
+ addMessage(message: Message): void {
+ const currentMessages = this.messagesSubject.value;
+ const updatedMessages = [...currentMessages, message];
+ this.messagesSubject.next(updatedMessages);
+ this.saveMessagesToStorage(updatedMessages);
+ }
+
+ addMessages(messages: Message[]): void {
+ const currentMessages = this.messagesSubject.value;
+ const updatedMessages = [...currentMessages, ...messages];
+ this.messagesSubject.next(updatedMessages);
+ this.saveMessagesToStorage(updatedMessages);
+ }
+
+ getMessages(): Message[] {
+ return this.messagesSubject.value;
+ }
+
+ clearMessages(): void {
+ this.messagesSubject.next([]);
+ this.saveMessagesToStorage([]);
+ }
+
+ clearPreferences(): void {
+ const defaultPrefs: UserPreferences = {
+ language: 'en',
+ theme: 'light',
+ notificationsEnabled: true,
+ };
+ this.userPreferencesSubject.next(defaultPrefs);
+ this.saveUserPreferencesToStorage(defaultPrefs);
+ }
+
+ updateUserPreferences(preferences: Partial): void {
+ const current = this.userPreferencesSubject.value;
+ const updated = { ...current, ...preferences };
+ this.userPreferencesSubject.next(updated);
+ this.saveUserPreferencesToStorage(updated);
+ }
+
+ getUserPreferences(): UserPreferences {
+ return this.userPreferencesSubject.value;
+ }
+
+ toggleChat(): void {
+ this.isChatOpenSubject.next(!this.isChatOpenSubject.value);
+ }
+
+ setChatOpen(isOpen: boolean): void {
+ this.isChatOpenSubject.next(isOpen);
+ }
+
+ setTyping(isTyping: boolean): void {
+ this.isTypingSubject.next(isTyping);
+ }
+
+ setBotResponding(isResponding: boolean): void {
+ this.isBotRespondingSubject.next(isResponding);
+ }
+
+ forgetUserData(): void {
+ this.clearMessages();
+ localStorage.removeItem(this.STORAGE_KEYS.USER_DATA);
+ localStorage.removeItem(this.STORAGE_KEYS.MESSAGES);
+ localStorage.removeItem(this.STORAGE_KEYS.USER_PREFS);
+
+ this.userPreferencesSubject.next({
+ language: 'en',
+ theme: 'light',
+ notificationsEnabled: true,
+ });
+ }
+
+ private loadFromStorage(): void {
+ const savedMessages = localStorage.getItem(this.STORAGE_KEYS.MESSAGES);
+ if (savedMessages) {
+ try {
+ const messages = JSON.parse(savedMessages);
+ const parsedMessages = messages.map((msg: any) => ({
+ ...msg,
+ timestamp: new Date(msg.timestamp),
+ }));
+ this.messagesSubject.next(parsedMessages);
+ } catch (error) {
+ console.error('Error loading messages from storage:', error);
+ }
+ }
+
+ const savedPrefs = localStorage.getItem(this.STORAGE_KEYS.USER_PREFS);
+ if (savedPrefs) {
+ try {
+ const prefs = JSON.parse(savedPrefs);
+ this.userPreferencesSubject.next(prefs);
+ } catch (error) {
+ console.error('Error loading preferences from storage:', error);
+ }
+ }
+ }
+
+ private saveMessagesToStorage(messages: Message[]): void {
+ try {
+ localStorage.setItem(
+ this.STORAGE_KEYS.MESSAGES,
+ JSON.stringify(messages)
+ );
+ } catch (error) {
+ console.error('Error saving messages to storage:', error);
+ }
+ }
+
+ private saveUserPreferencesToStorage(preferences: UserPreferences): void {
+ try {
+ localStorage.setItem(
+ this.STORAGE_KEYS.USER_PREFS,
+ JSON.stringify(preferences)
+ );
+ } catch (error) {
+ console.error('Error saving preferences to storage:', error);
+ }
+ }
+}
diff --git a/chat-widget/src/app/core/services/translation.service.spec.ts b/chat-widget/src/app/core/services/translation.service.spec.ts
new file mode 100644
index 0000000..3b9171f
--- /dev/null
+++ b/chat-widget/src/app/core/services/translation.service.spec.ts
@@ -0,0 +1,188 @@
+import { TestBed } from '@angular/core/testing';
+import { TranslationService } from './translation.service';
+
+describe('TranslationService', () => {
+ let service: TranslationService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(TranslationService);
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('Language Management', () => {
+ it('should have default language as "es"', () => {
+ const currentLang = service.getCurrentLanguage();
+ expect(currentLang).toBe('es');
+ });
+
+ it('should change language to English', (done) => {
+ service.setLanguage('en');
+
+ service.currentLanguage$.subscribe((lang) => {
+ if (lang === 'en') {
+ expect(lang).toBe('en');
+ done();
+ }
+ });
+ });
+
+ it('should change language to Spanish', (done) => {
+ service.setLanguage('es');
+
+ service.currentLanguage$.subscribe((lang) => {
+ if (lang === 'es') {
+ expect(lang).toBe('es');
+ done();
+ }
+ });
+ });
+
+ it('should get current language', () => {
+ service.setLanguage('en');
+ const lang = service.getCurrentLanguage();
+ expect(lang).toBe('en');
+ });
+ });
+
+ describe('Translation', () => {
+ it('should translate header.online to Spanish', () => {
+ service.setLanguage('es');
+ const translation = service.translate('header.online');
+ expect(translation).toBe('Online');
+ });
+
+ it('should translate header.online to English', () => {
+ service.setLanguage('en');
+ const translation = service.translate('header.online');
+ expect(translation).toBe('Online');
+ });
+
+ it('should translate bot.greeting to Spanish', () => {
+ service.setLanguage('es');
+ const translation = service.translate('bot.greeting');
+ expect(translation).toContain('Hola');
+ });
+
+ it('should translate bot.greeting to English', () => {
+ service.setLanguage('en');
+ const translation = service.translate('bot.greeting');
+ expect(translation).toContain('Hello');
+ });
+
+ it('should translate input.placeholder to Spanish', () => {
+ service.setLanguage('es');
+ const translation = service.translate('input.placeholder');
+ expect(translation).toBe('Escribe un mensaje...');
+ });
+
+ it('should translate input.placeholder to English', () => {
+ service.setLanguage('en');
+ const translation = service.translate('input.placeholder');
+ expect(translation).toBe('Type a message...');
+ });
+
+ it('should return key if translation not found', () => {
+ const translation = service.translate('nonexistent.key');
+ expect(translation).toBe('nonexistent.key');
+ });
+
+ it('should warn when translation key not found', () => {
+ spyOn(console, 'warn');
+ service.translate('invalid.key');
+ expect(console.warn).toHaveBeenCalledWith(
+ 'Translation key not found: invalid.key'
+ );
+ });
+ });
+
+ describe('Language Persistence', () => {
+ it('should load language from localStorage on init', () => {
+ const testPrefs = {
+ language: 'en',
+ theme: 'light',
+ notificationsEnabled: true,
+ };
+
+ localStorage.setItem('chat_user_preferences', JSON.stringify(testPrefs));
+
+ // Crear nueva instancia del servicio
+ const newService = new TranslationService();
+ const lang = newService.getCurrentLanguage();
+
+ expect(lang).toBe('en');
+ });
+
+ it('should default to "es" if no preferences in localStorage', () => {
+ localStorage.clear();
+ const newService = new TranslationService();
+ const lang = newService.getCurrentLanguage();
+
+ expect(lang).toBe('es');
+ });
+ });
+
+ describe('Comprehensive Translations', () => {
+ const translationKeys = [
+ 'header.online',
+ 'header.minimize',
+ 'header.close',
+ 'header.menu',
+ 'menu.privacy',
+ 'menu.language',
+ 'menu.forget',
+ 'input.placeholder',
+ 'cta.message',
+ 'bot.greeting',
+ 'bot.features',
+ 'bot.pricing',
+ 'bot.contact',
+ 'bot.help',
+ 'bot.goodbye',
+ 'bot.default',
+ 'language.spanish',
+ 'language.english',
+ ];
+
+ translationKeys.forEach((key) => {
+ it(`should have translation for "${key}" in Spanish`, () => {
+ service.setLanguage('es');
+ const translation = service.translate(key);
+ expect(translation).toBeTruthy();
+ expect(translation).not.toBe(key);
+ });
+
+ it(`should have translation for "${key}" in English`, () => {
+ service.setLanguage('en');
+ const translation = service.translate(key);
+ expect(translation).toBeTruthy();
+ expect(translation).not.toBe(key);
+ });
+ });
+ });
+
+ describe('Observable behavior', () => {
+ it('should emit language changes', (done) => {
+ const emissions: string[] = [];
+
+ service.currentLanguage$.subscribe((lang) => {
+ emissions.push(lang);
+ if (emissions.length === 3) {
+ expect(emissions).toEqual(['es', 'en', 'es']);
+ done();
+ }
+ });
+
+ service.setLanguage('en');
+ service.setLanguage('es');
+ });
+ });
+});
diff --git a/chat-widget/src/app/core/services/translation.service.ts b/chat-widget/src/app/core/services/translation.service.ts
new file mode 100644
index 0000000..914526d
--- /dev/null
+++ b/chat-widget/src/app/core/services/translation.service.ts
@@ -0,0 +1,198 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject, Observable } from 'rxjs';
+
+export type Language = 'es' | 'en';
+
+interface Translations {
+ [key: string]: {
+ es: string;
+ en: string;
+ };
+}
+
+@Injectable({
+ providedIn: 'root',
+})
+export class TranslationService {
+ private currentLanguageSubject = new BehaviorSubject('es');
+ public currentLanguage$: Observable =
+ this.currentLanguageSubject.asObservable();
+
+ private translations: Translations = {
+ // CTA Button
+ 'cta.message': {
+ es: 'ÂĄBuenas! ÂżQuieres conocerme?',
+ en: 'Hi! Want to meet me?',
+ },
+
+ // Header
+ 'header.online': {
+ es: 'Online',
+ en: 'Online',
+ },
+ 'header.minimize': {
+ es: 'Minimizar',
+ en: 'Minimize',
+ },
+ 'header.close': {
+ es: 'Cerrar',
+ en: 'Close',
+ },
+ 'header.menu': {
+ es: 'MenĂş',
+ en: 'Menu',
+ },
+
+ // Menu
+ 'menu.privacy': {
+ es: 'PolĂtica de privacidad',
+ en: 'Privacy Policy',
+ },
+ 'menu.language': {
+ es: 'Seleccionar Idioma',
+ en: 'Select Language',
+ },
+ 'menu.forget': {
+ es: 'Olvidar mis datos',
+ en: 'Forget my data',
+ },
+
+ // Input
+ 'input.placeholder': {
+ es: 'Escribe un mensaje...',
+ en: 'Type a message...',
+ },
+ 'input.send': {
+ es: 'Enviar',
+ en: 'Send',
+ },
+
+ // Quick Suggestions
+ 'suggestions.title': {
+ es: 'Sugerencias rĂĄpidas',
+ en: 'Quick suggestions',
+ },
+
+ // Bot Messages
+ 'bot.welcome': {
+ es: 'ÂĄHola! Soy el asistente virtual de 1millionbot. ÂżEn quĂŠ puedo ayudarte?',
+ en: 'Hello! I am the 1millionbot virtual assistant. How can I help you?',
+ },
+ 'bot.greeting': {
+ es: 'đ ÂĄHola! Encantado de saludarte. ÂżQuĂŠ puedo hacer por ti hoy?',
+ en: 'đ Hello! Nice to meet you. What can I do for you today?',
+ },
+ 'bot.chatbot.info': {
+ es: 'đ¤ Un chatbot es un asistente virtual que puede mantener conversaciones con usuarios de forma automĂĄtica. En 1millionbot creamos chatbots inteligentes que ayudan a empresas a mejorar su atenciĂłn al cliente 24/7.',
+ en: 'đ¤ A chatbot is a virtual assistant that can have conversations with users automatically. At 1millionbot we create intelligent chatbots that help businesses improve their customer service 24/7.',
+ },
+ 'bot.features': {
+ es: '⨠Nuestras principales caracterĂsticas:\n\n⢠IA conversacional avanzada\n⢠IntegraciĂłn multicanal (Web, WhatsApp, Telegram)\n⢠AnalĂticas en tiempo real\n⢠PersonalizaciĂłn total\n⢠Soporte 24/7\n\nÂżTe gustarĂa saber mĂĄs sobre alguna?',
+ en: '⨠Our main features:\n\n⢠Advanced conversational AI\n⢠Multichannel integration (Web, WhatsApp, Telegram)\n⢠Real-time analytics\n⢠Full customization\n⢠24/7 support\n\nWould you like to know more about any of these?',
+ },
+ 'bot.pricing': {
+ es: 'đ° Nuestros planes son flexibles y escalables segĂşn tus necesidades:\n\n⢠Plan Starter: Desde 99âŹ/mes\n⢠Plan Business: Desde 299âŹ/mes\n⢠Plan Enterprise: Personalizado\n\nÂżTe gustarĂa agendar una demo?',
+ en: 'đ° Our plans are flexible and scalable according to your needs:\n\n⢠Starter Plan: From âŹ99/month\n⢠Business Plan: From âŹ299/month\n⢠Enterprise Plan: Customized\n\nWould you like to schedule a demo?',
+ },
+ 'bot.contact': {
+ es: 'đ§ Puedes contactarnos:\n\n⢠Email: hello@1millionbot.com\n⢠TelĂŠfono: +34 900 123 456\n⢠Web: www.1millionbot.com\n\nÂżEn quĂŠ mĂĄs puedo ayudarte?',
+ en: 'đ§ You can contact us:\n\n⢠Email: hello@1millionbot.com\n⢠Phone: +34 900 123 456\n⢠Web: www.1millionbot.com\n\nHow else can I help you?',
+ },
+ 'bot.goodbye': {
+ es: 'đ ÂĄHasta pronto! Si necesitas algo mĂĄs, estarĂŠ aquĂ para ayudarte.',
+ en: 'đ See you soon! If you need anything else, I will be here to help you.',
+ },
+ 'bot.privacy': {
+ es: 'Puedes consultar nuestra polĂtica de privacidad en: https://www.1millionbot.com/privacy-policy',
+ en: 'You can check our privacy policy at: https://www.1millionbot.com/privacy-policy',
+ },
+ 'bot.language.question': {
+ es: 'ÂżEn quĂŠ idioma prefieres continuar?',
+ en: 'Which language do you prefer?',
+ },
+ 'bot.language.changed.es': {
+ es: 'â
Idioma cambiado a EspaĂąol',
+ en: 'â
Language changed to Spanish',
+ },
+ 'bot.language.changed.en': {
+ es: 'â
Idioma cambiado a InglĂŠs',
+ en: 'â
Language changed to English',
+ },
+ 'bot.forget.confirm': {
+ es: 'ÂżEstĂĄs seguro de que quieres eliminar todos tus datos? Esta acciĂłn no se puede deshacer.',
+ en: 'Are you sure you want to delete all your data? This action cannot be undone.',
+ },
+ 'bot.forget.success': {
+ es: 'â
Todos tus datos han sido eliminados correctamente.',
+ en: 'â
All your data has been successfully deleted.',
+ },
+ 'bot.help': {
+ es: 'đ Puedo ayudarte con:\n\n⢠InformaciĂłn sobre chatbots\n⢠CaracterĂsticas de 1millionbot\n⢠Planes y precios\n⢠Contacto y soporte\n\nÂżQuĂŠ te gustarĂa saber?',
+ en: 'đ I can help you with:\n\n⢠Information about chatbots\n⢠1millionbot features\n⢠Plans and pricing\n⢠Contact and support\n\nWhat would you like to know?',
+ },
+ 'bot.default': {
+ es: 'đ¤ Interesante. CuĂŠntame mĂĄs sobre lo que necesitas y te ayudarĂŠ encantado.',
+ en: 'đ¤ Interesting. Tell me more about what you need and I will gladly help you.',
+ },
+
+ // News Carousel
+ 'news.title': {
+ es: 'Ăltimas noticias',
+ en: 'Latest news',
+ },
+
+ // Language Selector Buttons
+ 'language.spanish': {
+ es: 'đŞđ¸ EspaĂąol',
+ en: 'đŞđ¸ Spanish',
+ },
+ 'language.english': {
+ es: 'đŹđ§ English',
+ en: 'đŹđ§ English',
+ },
+ };
+
+ constructor() {
+ // Cargar idioma guardado desde las preferencias del usuario
+ const savedPrefs = localStorage.getItem('chat_user_preferences');
+ if (savedPrefs) {
+ try {
+ const prefs = JSON.parse(savedPrefs);
+ if (
+ prefs.language &&
+ (prefs.language === 'es' || prefs.language === 'en')
+ ) {
+ this.currentLanguageSubject.next(prefs.language);
+ }
+ } catch (error) {
+ console.error('Error loading language from preferences:', error);
+ }
+ }
+ }
+
+ setLanguage(lang: Language): void {
+ this.currentLanguageSubject.next(lang);
+ }
+
+ getCurrentLanguage(): Language {
+ return this.currentLanguageSubject.value;
+ }
+
+ translate(key: string): string {
+ const translation = this.translations[key];
+ if (!translation) {
+ console.warn(`Translation key not found: ${key}`);
+ return key;
+ }
+ const currentLang = this.currentLanguageSubject.value;
+ return translation[currentLang];
+ }
+
+ translateWithParams(key: string, params: Record): string {
+ let text = this.translate(key);
+ Object.keys(params).forEach((param) => {
+ text = text.replace(`{${param}}`, params[param]);
+ });
+ return text;
+ }
+}
diff --git a/chat-widget/src/app/features/mock-bot/mock-bot.service.spec.ts b/chat-widget/src/app/features/mock-bot/mock-bot.service.spec.ts
new file mode 100644
index 0000000..75c40b9
--- /dev/null
+++ b/chat-widget/src/app/features/mock-bot/mock-bot.service.spec.ts
@@ -0,0 +1,263 @@
+import { TestBed } from '@angular/core/testing';
+import { MockBotService } from './mock-bot.service';
+import { TranslationService } from '../../core/services/translation.service';
+import { MessageType, SenderType, TextMessage } from '../../core/models';
+
+describe('MockBotService', () => {
+ let service: MockBotService;
+ let translationService: TranslationService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [MockBotService, TranslationService],
+ });
+ service = TestBed.inject(MockBotService);
+ translationService = TestBed.inject(TranslationService);
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('Welcome Message', () => {
+ it('should return welcome message', (done) => {
+ service.getWelcomeMessage().subscribe((message) => {
+ expect(message).toBeTruthy();
+ expect(message.sender).toBe(SenderType.BOT);
+ expect(message.type).toBe(MessageType.TEXT);
+ expect((message as TextMessage).content).toContain('asistente virtual');
+ done();
+ });
+ });
+
+ it('should include welcome message timestamp', (done) => {
+ service.getWelcomeMessage().subscribe((message) => {
+ expect(message.timestamp).toBeInstanceOf(Date);
+ done();
+ });
+ });
+ });
+
+ describe('Contextual Responses - Greetings', () => {
+ it('should respond to "hola"', (done) => {
+ service.sendMessage('hola').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toContain('Hola');
+ done();
+ });
+ });
+
+ it('should respond to "hello"', (done) => {
+ translationService.setLanguage('en');
+ service.sendMessage('hello').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toBeTruthy();
+ done();
+ });
+ });
+
+ it('should respond to "buenos dĂas"', (done) => {
+ service.sendMessage('buenos dĂas').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toBeTruthy();
+ done();
+ });
+ });
+ });
+
+ describe('Contextual Responses - Chatbot Questions', () => {
+ it('should respond to "quĂŠ es un chatbot"', (done) => {
+ service.sendMessage('ÂżquĂŠ es un chatbot?').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toContain('chatbot');
+ done();
+ });
+ });
+
+ it('should respond to "what is a chatbot"', (done) => {
+ translationService.setLanguage('en');
+ service.sendMessage('what is a chatbot?').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toBeTruthy();
+ done();
+ });
+ });
+ });
+
+ describe('Contextual Responses - Features', () => {
+ it('should respond to "caracterĂsticas"', (done) => {
+ service.sendMessage('caracterĂsticas').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toContain('IA');
+ done();
+ });
+ });
+
+ it('should respond to "features"', (done) => {
+ translationService.setLanguage('en');
+ service.sendMessage('features').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toBeTruthy();
+ done();
+ });
+ });
+ });
+
+ describe('Contextual Responses - Pricing', () => {
+ it('should respond to "precios"', (done) => {
+ service.sendMessage('precios').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toBeTruthy();
+ done();
+ });
+ });
+
+ it('should respond to "pricing"', (done) => {
+ translationService.setLanguage('en');
+ service.sendMessage('pricing').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toBeTruthy();
+ done();
+ });
+ });
+
+ it('should respond to "cuĂĄnto cuesta"', (done) => {
+ service.sendMessage('ÂżcuĂĄnto cuesta?').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toBeTruthy();
+ done();
+ });
+ });
+ });
+
+ describe('Contextual Responses - Contact', () => {
+ it('should respond to "contacto"', (done) => {
+ service.sendMessage('contacto').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toContain('contact');
+ done();
+ });
+ });
+
+ it('should respond to "contact"', (done) => {
+ translationService.setLanguage('en');
+ service.sendMessage('contact').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toBeTruthy();
+ done();
+ });
+ });
+ });
+
+ describe('Contextual Responses - Help', () => {
+ it('should respond to "ayuda"', (done) => {
+ service.sendMessage('ayuda').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toBeTruthy();
+ done();
+ });
+ });
+
+ it('should respond to "help"', (done) => {
+ translationService.setLanguage('en');
+ service.sendMessage('help').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toBeTruthy();
+ done();
+ });
+ });
+ });
+
+ describe('Contextual Responses - Goodbye', () => {
+ it('should respond to "adiĂłs"', (done) => {
+ service.sendMessage('adiĂłs').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toBeTruthy();
+ done();
+ });
+ });
+
+ it('should respond to "goodbye"', (done) => {
+ translationService.setLanguage('en');
+ service.sendMessage('goodbye').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toBeTruthy();
+ done();
+ });
+ });
+ });
+
+ describe('Default Response', () => {
+ it('should provide default response for unrecognized input', (done) => {
+ service.sendMessage('xyz123random').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toBeTruthy();
+ done();
+ });
+ });
+ });
+
+ describe('Response Timing', () => {
+ it('should have realistic delay (500-1000ms)', (done) => {
+ const startTime = Date.now();
+
+ service.sendMessage('test').subscribe((message) => {
+ const endTime = Date.now();
+ const delay = endTime - startTime;
+
+ expect(delay).toBeGreaterThanOrEqual(500);
+ expect(delay).toBeLessThanOrEqual(1100); // +100ms buffer
+ done();
+ });
+ });
+ });
+
+ describe('Message Structure', () => {
+ it('should return message with correct structure', (done) => {
+ service.sendMessage('test').subscribe((message) => {
+ expect(message.id).toBeTruthy();
+ expect(message.type).toBe(MessageType.TEXT);
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toBeTruthy();
+ expect(message.timestamp).toBeInstanceOf(Date);
+ done();
+ });
+ });
+
+ it('should have unique message IDs', (done) => {
+ let id1: string;
+
+ service.sendMessage('test1').subscribe((message1) => {
+ id1 = message1.id;
+
+ service.sendMessage('test2').subscribe((message2) => {
+ expect(message2.id).not.toBe(id1);
+ done();
+ });
+ });
+ });
+ });
+
+ describe('Case Insensitivity', () => {
+ it('should respond to uppercase input', (done) => {
+ service.sendMessage('HOLA').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toBeTruthy();
+ done();
+ });
+ });
+
+ it('should respond to mixed case input', (done) => {
+ service.sendMessage('HoLa').subscribe((message) => {
+ expect(message.sender).toBe(SenderType.BOT);
+ expect((message as TextMessage).content).toBeTruthy();
+ done();
+ });
+ });
+ });
+});
diff --git a/chat-widget/src/app/features/mock-bot/mock-bot.service.ts b/chat-widget/src/app/features/mock-bot/mock-bot.service.ts
new file mode 100644
index 0000000..a9c3bd3
--- /dev/null
+++ b/chat-widget/src/app/features/mock-bot/mock-bot.service.ts
@@ -0,0 +1,240 @@
+import { Injectable } from '@angular/core';
+import { Observable, of, delay } from 'rxjs';
+import { IChatService } from '../../core/services/chat.service.interface';
+import { TranslationService } from '../../core/services/translation.service';
+import {
+ Message,
+ MessageType,
+ SenderType,
+ TextMessage,
+ ImageCardMessage,
+ ButtonsMessage,
+} from '../../core/models';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class MockBotService implements IChatService {
+ private messageCounter = 0;
+
+ constructor(private translationService: TranslationService) {}
+
+ private generateId(): string {
+ return `msg_${Date.now()}_${++this.messageCounter}`;
+ }
+
+ getWelcomeMessage(): Observable {
+ const message: TextMessage = {
+ id: this.generateId(),
+ type: MessageType.TEXT,
+ sender: SenderType.BOT,
+ timestamp: new Date(),
+ content: this.translationService.translate('bot.welcome'),
+ };
+
+ return of(message).pipe(delay(500));
+ }
+
+ sendMessage(userMessage: string): Observable {
+ const lowerMessage = userMessage.toLowerCase();
+
+ // Saludos
+ if (lowerMessage.match(/hola|hello|hi|buenas|hey/)) {
+ return this.getGreetingResponse();
+ }
+
+ // Preguntas sobre chatbot
+ if (lowerMessage.match(/chatbot|bot|quĂŠ es|what is/)) {
+ return this.getChatbotInfoResponse();
+ }
+
+ if (
+ lowerMessage.match(
+ /caracterĂsticas|features|funcionalidades|capabilities/
+ )
+ ) {
+ return this.getFeaturesResponse();
+ }
+
+ // Precios
+ if (lowerMessage.match(/precio|price|pricing|costo|cost/)) {
+ return this.getPricingResponse();
+ }
+
+ // Contacto
+ if (lowerMessage.match(/contacto|contact|email|telĂŠfono|phone/)) {
+ return this.getContactResponse();
+ }
+
+ // Ayuda
+ if (lowerMessage.match(/ayuda|help|ayĂşdame|can you help/)) {
+ return this.getHelpResponse();
+ }
+
+ // Despedida
+ if (lowerMessage.match(/adiĂłs|adios|bye|chao|goodbye|hasta luego/)) {
+ return this.getGoodbyeResponse();
+ }
+
+ // Respuesta por defecto
+ return this.getDefaultResponse();
+ }
+
+ handleButtonClick(buttonValue: string): Observable {
+ const responses: { [key: string]: string } = {
+ option1: 'Great choice! You selected Option 1. How else can I help?',
+ option2:
+ 'Excellent! Option 2 is a popular choice. What would you like to know?',
+ option3:
+ 'Perfect! You chose Option 3. Let me know if you need anything else.',
+ demo: 'Sure! I can show you a demo of our features.',
+ pricing:
+ 'Our pricing is flexible and scalable. Would you like more details?',
+ contact:
+ 'You can reach us at contact@1millionbot.com or through our website.',
+ };
+
+ const content = responses[buttonValue] || 'Thanks for your selection!';
+
+ const message: TextMessage = {
+ id: this.generateId(),
+ type: MessageType.TEXT,
+ sender: SenderType.BOT,
+ timestamp: new Date(),
+ content,
+ };
+
+ return of(message).pipe(delay(800));
+ }
+
+ private getGreetingResponse(): Observable {
+ const message: TextMessage = {
+ id: this.generateId(),
+ type: MessageType.TEXT,
+ sender: SenderType.BOT,
+ timestamp: new Date(),
+ content: this.translationService.translate('bot.greeting'),
+ };
+
+ return of(message).pipe(delay(700));
+ }
+
+ private getChatbotInfoResponse(): Observable {
+ const message: TextMessage = {
+ id: this.generateId(),
+ type: MessageType.TEXT,
+ sender: SenderType.BOT,
+ timestamp: new Date(),
+ content: this.translationService.translate('bot.chatbot.info'),
+ };
+
+ return of(message).pipe(delay(900));
+ }
+
+ private getFeaturesResponse(): Observable {
+ const message: TextMessage = {
+ id: this.generateId(),
+ type: MessageType.TEXT,
+ sender: SenderType.BOT,
+ timestamp: new Date(),
+ content: this.translationService.translate('bot.features'),
+ };
+
+ return of(message).pipe(delay(1000));
+ }
+
+ private getPricingResponse(): Observable {
+ const message: TextMessage = {
+ id: this.generateId(),
+ type: MessageType.TEXT,
+ sender: SenderType.BOT,
+ timestamp: new Date(),
+ content: this.translationService.translate('bot.pricing'),
+ };
+
+ return of(message).pipe(delay(900));
+ }
+
+ private getContactResponse(): Observable {
+ const message: TextMessage = {
+ id: this.generateId(),
+ type: MessageType.TEXT,
+ sender: SenderType.BOT,
+ timestamp: new Date(),
+ content: this.translationService.translate('bot.contact'),
+ };
+
+ return of(message).pipe(delay(800));
+ }
+
+ private getGoodbyeResponse(): Observable {
+ const message: TextMessage = {
+ id: this.generateId(),
+ type: MessageType.TEXT,
+ sender: SenderType.BOT,
+ timestamp: new Date(),
+ content: this.translationService.translate('bot.goodbye'),
+ };
+
+ return of(message).pipe(delay(600));
+ }
+
+ private getImageCardResponse(): Observable {
+ const message: ImageCardMessage = {
+ id: this.generateId(),
+ type: MessageType.IMAGE_CARD,
+ sender: SenderType.BOT,
+ timestamp: new Date(),
+ imageUrl:
+ 'https://via.placeholder.com/400x200/0066ff/ffffff?text=1millionbot',
+ title: '1millionbot Platform',
+ description:
+ 'Discover our powerful chatbot platform that helps businesses automate conversations and improve customer experience.',
+ buttonText: 'Learn More',
+ buttonUrl: 'https://1millionbot.com',
+ };
+
+ return of(message).pipe(delay(1000));
+ }
+
+ private getButtonsResponse(): Observable {
+ const message: ButtonsMessage = {
+ id: this.generateId(),
+ type: MessageType.BUTTONS,
+ sender: SenderType.BOT,
+ timestamp: new Date(),
+ text: 'What would you like to know more about?',
+ buttons: [
+ { id: 'btn1', label: 'đą Request Demo', value: 'demo' },
+ { id: 'btn2', label: 'đ° Pricing', value: 'pricing' },
+ { id: 'btn3', label: 'đ§ Contact Us', value: 'contact' },
+ ],
+ };
+
+ return of(message).pipe(delay(800));
+ }
+
+ private getHelpResponse(): Observable {
+ const message: TextMessage = {
+ id: this.generateId(),
+ type: MessageType.TEXT,
+ sender: SenderType.BOT,
+ timestamp: new Date(),
+ content: this.translationService.translate('bot.help'),
+ };
+
+ return of(message).pipe(delay(900));
+ }
+
+ private getDefaultResponse(): Observable {
+ const message: TextMessage = {
+ id: this.generateId(),
+ type: MessageType.TEXT,
+ sender: SenderType.BOT,
+ timestamp: new Date(),
+ content: this.translationService.translate('bot.default'),
+ };
+
+ return of(message).pipe(delay(600));
+ }
+}
diff --git a/chat-widget/src/app/shared/directives/.gitkeep b/chat-widget/src/app/shared/directives/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/chat-widget/src/app/shared/pipes/.gitkeep b/chat-widget/src/app/shared/pipes/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/chat-widget/src/assets/.gitkeep b/chat-widget/src/assets/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/chat-widget/src/favicon.ico b/chat-widget/src/favicon.ico
new file mode 100644
index 0000000..997406a
Binary files /dev/null and b/chat-widget/src/favicon.ico differ
diff --git a/chat-widget/src/index.html b/chat-widget/src/index.html
new file mode 100644
index 0000000..3a06955
--- /dev/null
+++ b/chat-widget/src/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+ ChatApp
+
+
+
+
+
+
+
+
diff --git a/chat-widget/src/main.ts b/chat-widget/src/main.ts
new file mode 100644
index 0000000..0a16e18
--- /dev/null
+++ b/chat-widget/src/main.ts
@@ -0,0 +1,9 @@
+import { bootstrapApplication } from '@angular/platform-browser';
+import { AppComponent } from './app/app.component';
+import { provideRouter } from '@angular/router';
+import { StateService } from './app/core/services';
+import { MockBotService } from './app/features/mock-bot/mock-bot.service';
+
+bootstrapApplication(AppComponent, {
+ providers: [provideRouter([]), StateService, MockBotService],
+}).catch((err) => console.error(err));
diff --git a/chat-widget/src/styles.scss b/chat-widget/src/styles.scss
new file mode 100644
index 0000000..695e9e7
--- /dev/null
+++ b/chat-widget/src/styles.scss
@@ -0,0 +1,27 @@
+/* Tailwind CSS imports */
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* Global styles */
+@layer base {
+ * {
+ @apply box-border;
+ }
+
+ body {
+ @apply m-0 font-sans antialiased;
+ }
+}
+
+/* Custom utilities for chat widget */
+@layer utilities {
+ .scrollbar-hide {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ }
+
+ .scrollbar-hide::-webkit-scrollbar {
+ display: none;
+ }
+}
diff --git a/chat-widget/tailwind.config.js b/chat-widget/tailwind.config.js
new file mode 100644
index 0000000..fb3c713
--- /dev/null
+++ b/chat-widget/tailwind.config.js
@@ -0,0 +1,57 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: ["./src/**/*.{html,ts}"],
+ theme: {
+ extend: {
+ colors: {
+ // 1millionbot corporate colors
+ primary: {
+ 50: "#e6f0ff",
+ 100: "#cce0ff",
+ 200: "#99c2ff",
+ 300: "#66a3ff",
+ 400: "#3385ff",
+ 500: "#0066ff", // Main brand color
+ 600: "#0052cc",
+ 700: "#003d99",
+ 800: "#002966",
+ 900: "#001433",
+ },
+ bot: {
+ light: "#f9fafb",
+ DEFAULT: "#f3f4f6",
+ dark: "#e5e7eb",
+ },
+ user: {
+ light: "#3385ff",
+ DEFAULT: "#0066ff",
+ dark: "#0052cc",
+ },
+ },
+ boxShadow: {
+ chat: "0 4px 12px rgba(0, 0, 0, 0.15)",
+ message: "0 2px 4px rgba(0, 0, 0, 0.05)",
+ card: "0 4px 6px rgba(0, 0, 0, 0.1)",
+ },
+ borderRadius: {
+ chat: "1rem",
+ },
+ animation: {
+ fadeIn: "fadeIn 0.3s ease-in-out",
+ slideUp: "slideUp 0.3s ease-out",
+ "pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
+ },
+ keyframes: {
+ fadeIn: {
+ "0%": { opacity: "0", transform: "translateY(10px)" },
+ "100%": { opacity: "1", transform: "translateY(0)" },
+ },
+ slideUp: {
+ "0%": { transform: "translateY(100%)" },
+ "100%": { transform: "translateY(0)" },
+ },
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/chat-widget/tsconfig.app.json b/chat-widget/tsconfig.app.json
new file mode 100644
index 0000000..374cc9d
--- /dev/null
+++ b/chat-widget/tsconfig.app.json
@@ -0,0 +1,14 @@
+/* To learn more about this file see: https://angular.io/config/tsconfig. */
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/app",
+ "types": []
+ },
+ "files": [
+ "src/main.ts"
+ ],
+ "include": [
+ "src/**/*.d.ts"
+ ]
+}
diff --git a/chat-widget/tsconfig.json b/chat-widget/tsconfig.json
new file mode 100644
index 0000000..ed966d4
--- /dev/null
+++ b/chat-widget/tsconfig.json
@@ -0,0 +1,33 @@
+/* To learn more about this file see: https://angular.io/config/tsconfig. */
+{
+ "compileOnSave": false,
+ "compilerOptions": {
+ "baseUrl": "./",
+ "outDir": "./dist/out-tsc",
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "sourceMap": true,
+ "declaration": false,
+ "downlevelIteration": true,
+ "experimentalDecorators": true,
+ "moduleResolution": "node",
+ "importHelpers": true,
+ "target": "ES2022",
+ "module": "ES2022",
+ "useDefineForClassFields": false,
+ "lib": [
+ "ES2022",
+ "dom"
+ ]
+ },
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": true
+ }
+}
diff --git a/chat-widget/tsconfig.spec.json b/chat-widget/tsconfig.spec.json
new file mode 100644
index 0000000..be7e9da
--- /dev/null
+++ b/chat-widget/tsconfig.spec.json
@@ -0,0 +1,14 @@
+/* To learn more about this file see: https://angular.io/config/tsconfig. */
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/spec",
+ "types": [
+ "jasmine"
+ ]
+ },
+ "include": [
+ "src/**/*.spec.ts",
+ "src/**/*.d.ts"
+ ]
+}