From fe570d637953094ac9bdfbd0d38f297c429b1ea9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 05:00:59 +0000 Subject: [PATCH] Add Happy Hour Bot chat feature for Belmont Shore, Naples, and 2nd & PCH areas Co-Authored-By: alexjansonmcbride@gmail.com --- src/app/app.routes.ts | 4 + src/app/core/interceptors/api.interceptor.ts | 4 + src/app/core/layout/footer.component.html | 8 +- src/app/core/layout/header.component.html | 54 +--- src/app/core/layout/header.component.ts | 11 +- src/app/features/chat/chat.component.css | 279 +++++++++++++++++++ src/app/features/chat/chat.component.html | 87 ++++++ src/app/features/chat/chat.component.ts | 130 +++++++++ src/app/features/chat/chat.service.ts | 81 ++++++ src/index.html | 2 +- 10 files changed, 596 insertions(+), 64 deletions(-) create mode 100644 src/app/features/chat/chat.component.css create mode 100644 src/app/features/chat/chat.component.html create mode 100644 src/app/features/chat/chat.component.ts create mode 100644 src/app/features/chat/chat.service.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 5420b3787..329a317b8 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -6,6 +6,10 @@ import { map } from "rxjs/operators"; export const routes: Routes = [ { path: "", + loadComponent: () => import("./features/chat/chat.component"), + }, + { + path: "home", loadComponent: () => import("./features/article/pages/home/home.component"), }, { diff --git a/src/app/core/interceptors/api.interceptor.ts b/src/app/core/interceptors/api.interceptor.ts index 5a8222450..1a32a142f 100644 --- a/src/app/core/interceptors/api.interceptor.ts +++ b/src/app/core/interceptors/api.interceptor.ts @@ -1,6 +1,10 @@ import { HttpInterceptorFn } from "@angular/common/http"; export const apiInterceptor: HttpInterceptorFn = (req, next) => { + // Skip URL transformation for requests that already have a full URL (e.g., localhost) + if (req.url.startsWith("http://") || req.url.startsWith("https://")) { + return next(req); + } const apiReq = req.clone({ url: `https://api.realworld.show/api${req.url}` }); return next(apiReq); }; diff --git a/src/app/core/layout/footer.component.html b/src/app/core/layout/footer.component.html index d37a4304c..dfbe2346d 100644 --- a/src/app/core/layout/footer.component.html +++ b/src/app/core/layout/footer.component.html @@ -1,11 +1,9 @@ diff --git a/src/app/core/layout/header.component.html b/src/app/core/layout/header.component.html index 36d290460..2a93e3285 100644 --- a/src/app/core/layout/header.component.html +++ b/src/app/core/layout/header.component.html @@ -1,28 +1,9 @@ diff --git a/src/app/core/layout/header.component.ts b/src/app/core/layout/header.component.ts index b33ed46f3..3218e10f8 100644 --- a/src/app/core/layout/header.component.ts +++ b/src/app/core/layout/header.component.ts @@ -1,14 +1,9 @@ -import { Component, inject } from "@angular/core"; -import { UserService } from "../auth/services/user.service"; +import { Component } from "@angular/core"; import { RouterLink, RouterLinkActive } from "@angular/router"; -import { AsyncPipe } from "@angular/common"; -import { IfAuthenticatedDirective } from "../auth/if-authenticated.directive"; @Component({ selector: "app-layout-header", templateUrl: "./header.component.html", - imports: [RouterLinkActive, RouterLink, AsyncPipe, IfAuthenticatedDirective], + imports: [RouterLinkActive, RouterLink], }) -export class HeaderComponent { - currentUser$ = inject(UserService).currentUser; -} +export class HeaderComponent {} diff --git a/src/app/features/chat/chat.component.css b/src/app/features/chat/chat.component.css new file mode 100644 index 000000000..4ff24338c --- /dev/null +++ b/src/app/features/chat/chat.component.css @@ -0,0 +1,279 @@ +.chat-container { + display: flex; + flex-direction: column; + height: calc(100vh - 120px); + max-width: 900px; + margin: 0 auto; + background: #f8f9fa; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); +} + +.chat-header { + background: linear-gradient(135deg, #5cb85c 0%, #449d44 100%); + color: white; + padding: 20px; + text-align: center; +} + +.chat-header h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; +} + +.chat-header p { + margin: 5px 0 0; + font-size: 0.9rem; + opacity: 0.9; +} + +.messages-container { + flex: 1; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.message { + display: flex; + gap: 12px; + max-width: 85%; + animation: fadeIn 0.3s ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.user-message { + align-self: flex-end; + flex-direction: row-reverse; +} + +.bot-message { + align-self: flex-start; +} + +.message-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + flex-shrink: 0; +} + +.user-message .message-avatar { + background: #5cb85c; + color: white; +} + +.bot-message .message-avatar { + background: #6c757d; + color: white; +} + +.message-content { + background: white; + padding: 12px 16px; + border-radius: 16px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +.user-message .message-content { + background: #5cb85c; + color: white; + border-bottom-right-radius: 4px; +} + +.bot-message .message-content { + border-bottom-left-radius: 4px; +} + +.message-text { + line-height: 1.5; + word-wrap: break-word; +} + +.message-text strong { + font-weight: 600; +} + +.message-time { + font-size: 0.7rem; + opacity: 0.6; + margin-top: 6px; + text-align: right; +} + +.typing-indicator { + display: flex; + gap: 4px; + padding: 8px 0; +} + +.typing-indicator span { + width: 8px; + height: 8px; + background: #6c757d; + border-radius: 50%; + animation: bounce 1.4s infinite ease-in-out both; +} + +.typing-indicator span:nth-child(1) { + animation-delay: -0.32s; +} + +.typing-indicator span:nth-child(2) { + animation-delay: -0.16s; +} + +@keyframes bounce { + 0%, + 80%, + 100% { + transform: scale(0); + } + 40% { + transform: scale(1); + } +} + +.suggested-questions { + padding: 0 20px 10px; +} + +.suggested-questions p { + font-size: 0.85rem; + color: #6c757d; + margin-bottom: 10px; +} + +.suggestions-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.suggestion-btn { + background: white; + border: 1px solid #dee2e6; + border-radius: 20px; + padding: 8px 16px; + font-size: 0.85rem; + color: #495057; + cursor: pointer; + transition: all 0.2s ease; +} + +.suggestion-btn:hover { + background: #5cb85c; + color: white; + border-color: #5cb85c; +} + +.input-container { + display: flex; + gap: 12px; + padding: 16px 20px; + background: white; + border-top: 1px solid #e9ecef; +} + +.input-container textarea { + flex: 1; + border: 1px solid #dee2e6; + border-radius: 24px; + padding: 12px 20px; + font-size: 1rem; + resize: none; + outline: none; + font-family: inherit; + transition: border-color 0.2s ease; +} + +.input-container textarea:focus { + border-color: #5cb85c; +} + +.input-container textarea:disabled { + background: #f8f9fa; +} + +.send-btn { + width: 48px; + height: 48px; + border-radius: 50%; + border: none; + background: #5cb85c; + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.send-btn:hover:not(:disabled) { + background: #449d44; + transform: scale(1.05); +} + +.send-btn:disabled { + background: #adb5bd; + cursor: not-allowed; +} + +.send-btn svg { + width: 20px; + height: 20px; +} + +/* Scrollbar styling */ +.messages-container::-webkit-scrollbar { + width: 6px; +} + +.messages-container::-webkit-scrollbar-track { + background: transparent; +} + +.messages-container::-webkit-scrollbar-thumb { + background: #ced4da; + border-radius: 3px; +} + +.messages-container::-webkit-scrollbar-thumb:hover { + background: #adb5bd; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .chat-container { + height: calc(100vh - 80px); + border-radius: 0; + } + + .message { + max-width: 90%; + } + + .chat-header h1 { + font-size: 1.25rem; + } +} diff --git a/src/app/features/chat/chat.component.html b/src/app/features/chat/chat.component.html new file mode 100644 index 000000000..a88da3871 --- /dev/null +++ b/src/app/features/chat/chat.component.html @@ -0,0 +1,87 @@ +
+
+

Belmont Shore Happy Hour Bot

+

Find happy hours in Belmont Shore, Naples & 2nd & PCH

+
+ +
+
+
+ You + Bot +
+
+
+
+ {{ message.timestamp | date: "shortTime" }} +
+
+
+ +
+
+ Bot +
+
+
+ + + +
+
+
+
+ +
+

Try asking:

+
+ +
+
+ +
+ + +
+
diff --git a/src/app/features/chat/chat.component.ts b/src/app/features/chat/chat.component.ts new file mode 100644 index 000000000..bac02652a --- /dev/null +++ b/src/app/features/chat/chat.component.ts @@ -0,0 +1,130 @@ +import { + Component, + OnInit, + ViewChild, + ElementRef, + AfterViewChecked, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { FormsModule } from "@angular/forms"; +import { ChatService, ChatResponse, EstablishmentData } from "./chat.service"; + +interface ChatMessage { + type: "user" | "bot"; + content: string; + timestamp: Date; + establishments?: EstablishmentData[]; +} + +@Component({ + selector: "app-chat", + templateUrl: "./chat.component.html", + styleUrls: ["./chat.component.css"], + imports: [CommonModule, FormsModule], +}) +export default class ChatComponent implements OnInit, AfterViewChecked { + @ViewChild("messagesContainer") private messagesContainer!: ElementRef; + + messages: ChatMessage[] = []; + userInput = ""; + isLoading = false; + sessionId = this.generateSessionId(); + + suggestedQuestions = [ + "What places have happy hours right now?", + "Do any bars have happy hour on Tuesdays?", + "What are the specials at Belmont Brewing Co?", + "Show me drink specials", + "List all places", + ]; + + constructor(private chatService: ChatService) {} + + ngOnInit(): void { + this.addBotMessage( + 'Welcome to the Belmont Shore Happy Hour Bot! I can help you find happy hours in Belmont Shore, Naples, and 2nd & PCH areas of Long Beach.\n\nTry asking me:\n- "What places have happy hours right now?"\n- "Do any bars have happy hour on Tuesdays?"\n- "What are the happy hour specials at [restaurant name]?"\n\nHow can I help you today?', + ); + } + + ngAfterViewChecked(): void { + this.scrollToBottom(); + } + + sendMessage(): void { + if (!this.userInput.trim() || this.isLoading) { + return; + } + + const userMessage = this.userInput.trim(); + this.addUserMessage(userMessage); + this.userInput = ""; + this.isLoading = true; + + this.chatService.sendMessage(userMessage, this.sessionId).subscribe({ + next: (response: ChatResponse) => { + this.addBotMessage(response.response, response.establishments); + this.isLoading = false; + }, + error: (error) => { + console.error("Error sending message:", error); + this.addBotMessage( + "Sorry, I'm having trouble connecting to the server. Please make sure the backend is running and try again.", + ); + this.isLoading = false; + }, + }); + } + + askSuggestedQuestion(question: string): void { + this.userInput = question; + this.sendMessage(); + } + + private addUserMessage(content: string): void { + this.messages.push({ + type: "user", + content, + timestamp: new Date(), + }); + } + + private addBotMessage( + content: string, + establishments?: EstablishmentData[], + ): void { + this.messages.push({ + type: "bot", + content, + timestamp: new Date(), + establishments, + }); + } + + private scrollToBottom(): void { + try { + if (this.messagesContainer) { + this.messagesContainer.nativeElement.scrollTop = + this.messagesContainer.nativeElement.scrollHeight; + } + } catch (err) { + console.error("Error scrolling to bottom:", err); + } + } + + private generateSessionId(): string { + return "session-" + Math.random().toString(36).substring(2, 15); + } + + onKeyPress(event: KeyboardEvent): void { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + this.sendMessage(); + } + } + + formatMessageContent(content: string): string { + return content + .replace(/\*\*(.*?)\*\*/g, "$1") + .replace(/\n/g, "
"); + } +} diff --git a/src/app/features/chat/chat.service.ts b/src/app/features/chat/chat.service.ts new file mode 100644 index 000000000..bbff0f757 --- /dev/null +++ b/src/app/features/chat/chat.service.ts @@ -0,0 +1,81 @@ +import { Injectable } from "@angular/core"; +import { HttpClient } from "@angular/common/http"; +import { Observable } from "rxjs"; + +export interface ChatRequest { + message: string; + sessionId?: string; +} + +export interface SpecialData { + id: string; + description: string; + itemName: string; + itemType: string; + happyHourPrice: number; + originalPrice: number; + discountInfo: string; + formattedPrice: string; +} + +export interface HappyHourData { + id: string; + establishmentId: string; + establishmentName: string; + dayOfWeek: number; + dayName: string; + startTime: string; + endTime: string; + lastVerifiedAt: string; + specials: SpecialData[]; +} + +export interface EstablishmentData { + id: string; + name: string; + type: string; + address: string; + phone: string; + website: string; + instagram: string; + facebook: string; + description: string; + lastVerifiedAt: string; + happyHours: HappyHourData[]; +} + +export interface ChatResponse { + response: string; + establishments: EstablishmentData[]; + queryType: string; +} + +@Injectable({ + providedIn: "root", +}) +export class ChatService { + private apiUrl = "http://localhost:8080/api/chat"; + + constructor(private http: HttpClient) {} + + sendMessage(message: string, sessionId?: string): Observable { + const request = { chat: { message, sessionId } }; + return this.http.post(`${this.apiUrl}/message`, request); + } + + getEstablishments(): Observable<{ + establishments: EstablishmentData[]; + count: number; + }> { + return this.http.get<{ + establishments: EstablishmentData[]; + count: number; + }>(`${this.apiUrl}/establishments`); + } + + healthCheck(): Observable<{ status: string; service: string; area: string }> { + return this.http.get<{ status: string; service: string; area: string }>( + `${this.apiUrl}/health`, + ); + } +} diff --git a/src/index.html b/src/index.html index 5bab3ff1a..e5f864134 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - Conduit + Happy Hour Bot