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

{{ botProfile.name }}

+ {{ "header.online" | translate }} +
+
+ +
+ + + + + +
+ + +
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 @@ +
+

{{ message.text }}

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

{{ message.title }}

+

{{ message.description }}

+ + {{ message.buttonText || "Seguir leyendo" }} + +
+ +
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" + ] +}