diff --git a/UI_V2/admin/admin_chat.py b/UI_V2/admin/admin_chat.py new file mode 100644 index 0000000..c42eb16 --- /dev/null +++ b/UI_V2/admin/admin_chat.py @@ -0,0 +1,385 @@ +import flet as ft +from dbActions.chat import Chat +from dbActions.users import Users + +class AdminChat: + def __init__(self, page: ft.Page): + self.page = page + self.chat_manager = Chat() + self.user_namager = Users() + + # Logged in user (ensure this is an admin) + self.user = self.page.session.get("user") + + # Access flag: if not admin, we won't build the chat UI + self.access_denied = False + if not self.user or self.user.get("role") != "admin": + self.access_denied = True + return + + # --- Admin chat state --- + self.active_chat_id: int | None = None + self.active_chat_session: dict | None = None + + # UI containers + self.sessions_column = ft.Column(spacing=4, expand=True) + self.messages_list = ft.ListView(expand=True, spacing=10, auto_scroll=True) + self.input_field = ft.TextField( + hint_text="Scrie răspunsul tău aici...", + expand=True, + dense=True, + border_radius=20, + content_padding=ft.Padding(12, 8, 12, 8), + on_submit=self.on_send_click + ) + self.send_button = ft.IconButton( + icon=ft.Icons.SEND_ROUNDED, + tooltip="Trimite", + on_click=self.on_send_click, + ) + + # Subscribe to PubSub for live updates from clients + try: + self.page.pubsub.subscribe(self.on_pubsub_message) + except Exception as e: + # If PubSub is not configured, admin can still see messages with manual refresh + print(e) + + # Cleanup old sessions (keep only chats from today in DB) + try: + self.chat_manager.cleanup_old_sessions() + except Exception as e: + print(e) + + # Preload sessions data so that when build() is called, lists are already populated + self.load_sessions() + + def build(self) -> ft.Control: + """Return main layout: left panel (sessions) + right panel (messages). + + This allows the admin chat UI to be embedded inside another container/page. + """ + # If access is denied, return an informational message + if getattr(self, "access_denied", False): + return ft.Column( + controls=[ + ft.Text("Acces restricționat", size=22, weight=ft.FontWeight.BOLD), + ft.Text("Această pagină este disponibilă doar pentru administratori."), + ], + alignment=ft.MainAxisAlignment.CENTER, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + ) + + # Left side: list of chat sessions + left_panel = ft.Container( + bgcolor=ft.Colors.GREY_100, + padding=10, + width=280, + content=ft.Column( + expand=True, + controls=[ + ft.Text("Conversații", size=18, weight=ft.FontWeight.BOLD), + ft.Divider(), + ft.Container( + expand=True, + content=ft.ListView( + expand=True, + spacing=4, + controls=[self.sessions_column], + ), + ), + ], + ), + ) + + # Right side: active conversation + if not hasattr(self, "header_label"): + self.header_label = ft.Text("Selectează o conversație", size=18, weight=ft.FontWeight.BOLD) + + right_panel = ft.Container( + expand=True, + padding=10, + content=ft.Column( + expand=True, + controls=[ + ft.Row( + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + controls=[ + self.header_label, + ft.Icon(ft.Icons.SUPPORT_AGENT), + ], + ), + ft.Divider(), + ft.Container( + expand=True, + content=self.messages_list, + ), + ft.Divider(), + ft.Row( + vertical_alignment=ft.CrossAxisAlignment.CENTER, + controls=[self.input_field, self.send_button], + ), + ], + ), + ) + + main_row = ft.Row( + expand=True, + controls=[left_panel, right_panel], + ) + + return main_row + + def load_sessions(self): + """Load all chat sessions from DB and populate the left panel.""" + self.sessions_column.controls.clear() + + try: + sessions = self.chat_manager.get_chatsessions_with_messages() + except Exception: + sessions = [] + + if not sessions: + self.sessions_column.controls.append( + ft.Text("Nu există conversații deschise.", size=12, color=ft.Colors.GREY_600) + ) + return + + # Sort by last_message_at DESC (if available) or created_at + def sort_key(session): + return session.get("last_message_at") or session.get("created_at") or "" + + sessions = sorted(sessions, key=sort_key, reverse=True) + + for session in sessions: + self.sessions_column.controls.append(self._build_session_tile(session)) + + def _build_session_tile(self, session: dict) -> ft.Container: + """Create a clickable row for a chat session.""" + chat_id = session.get("id") + user_id = session.get("user_id") + status = session.get("status", "open") + unread_for_admin = session.get("unread_for_admin", 0) + + title = f"Chat #{chat_id}" + if user_id: + user = self.user_namager.get(user_id) + title += f" • User {user['name'].replace("~", " ")}" + + subtitle_parts = [] + if status == "open": + subtitle_parts.append("Deschis") + else: + subtitle_parts.append("Închis") + + last_from = session.get("last_message_from") + if last_from: + subtitle_parts.append(f"Ultimul mesaj: {last_from}") + + subtitle = " • ".join(subtitle_parts) + + badge = None + if unread_for_admin: + badge = ft.Container( + bgcolor=ft.Colors.RED_400, + border_radius=20, + padding=ft.padding.symmetric(horizontal=8, vertical=2), + content=ft.Text("nou", size=10, color=ft.Colors.WHITE), + ) + + bg_color = ft.Colors.BROWN_100 if unread_for_admin else ft.Colors.WHITE + + tile = ft.Container( + padding=8, + border_radius=8, + bgcolor=bg_color, + ink=True, + on_click=lambda e, cid=chat_id: self.on_select_session(cid), + content=ft.Row( + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + controls=[ + ft.Column( + spacing=2, + controls=[ + ft.Text(title, weight=ft.FontWeight.BOLD, size=13), + ft.Text(subtitle, size=11, color=ft.Colors.GREY_600), + ], + ), + badge if badge else ft.Container(), + ], + ), + ) + + return tile + + def on_select_session(self, chat_id: int): + """Triggered when admin clicks a chat session on the left.""" + self.active_chat_id = chat_id + + # Load session details + try: + session = self.chat_manager.get_chatsession(chat_id) + except Exception as e: + print(e) + session = None + + self.active_chat_session = session + + # Update header + if session: + user_id = session.get("user_id") + label = f"Chat #{chat_id}" + if user_id: + user = self.user_namager.get(user_id) + label += f" • User {user['name'].replace("~", " ")}" + self.header_label.value = label + else: + self.header_label.value = f"Chat #{chat_id}" + + # Load messages + self.load_messages_for_active_chat() + + # Mark messages as read for admin and update session's unread flag + try: + self.chat_manager.mark_messages_as_read(chat_id, "admin") + if session: + session["unread_for_admin"] = 0 + self.chat_manager.update_chatsession(session) + except Exception as e: + print(e) + + # Refresh session list to remove unread badge + self.load_sessions() + self.page.update() + + def load_messages_for_active_chat(self): + """Load messages from DB for the currently active chat.""" + self.messages_list.controls.clear() + + if not self.active_chat_id: + return + + try: + messages = self.chat_manager.get_chatmessages_for_chat(self.active_chat_id) + except Exception: + messages = [] + + for msg in messages: + sender_type = msg.get("sender_type") + text = msg.get("text", "") + + if sender_type == "admin": + alignment = ft.MainAxisAlignment.END + bubble_color = ft.Colors.BROWN_100 + else: + alignment = ft.MainAxisAlignment.START + bubble_color = ft.Colors.GREY_200 + + self.messages_list.controls.append( + ft.Row( + alignment=alignment, + controls=[ + ft.Container( + bgcolor=bubble_color, + padding=8, + border_radius=12, + content=ft.Text(text, size=13), + ) + ], + ) + ) + + def on_send_click(self, e): + """Admin sends a new message to the active chat.""" + text = self.input_field.value.strip() + if not text or not self.active_chat_id: + return + + # Append admin bubble locally + self.messages_list.controls.append( + ft.Row( + alignment=ft.MainAxisAlignment.END, + controls=[ + ft.Container( + bgcolor=ft.Colors.BROWN_100, + padding=8, + border_radius=12, + content=ft.Text(text, size=13), + ) + ], + ) + ) + + # Persist message in DB + message = { + "chat_id": self.active_chat_id, + "session_token": None, + "sender_type": "admin", + "sender_id": self.user.get("id") if self.user else None, + "text": text, + } + try: + self.chat_manager.add_chatmessage(message) + except Exception as e: + print(e) + + # Update chat session metadata + try: + session = self.chat_manager.get_chatsession(self.active_chat_id) + if session: + session["last_message_from"] = "admin" + session["unread_for_user"] = 1 + self.chat_manager.update_chatsession(session) + except Exception as e: + print(e) + + # Notify client side via PubSub + try: + self.page.pubsub.send_all( + { + "type": "chat_message", + "chat_id": self.active_chat_id, + "sender": "admin", + "text": text, + } + ) + except Exception as e: + print (e) + + # Refresh the sessions list to reflect new last_message_from and ordering + self.load_sessions() + + self.input_field.value = "" + self.page.update() + + def on_pubsub_message(self, data): + """Handle PubSub messages coming from client pages.""" + if not isinstance(data, dict): + return + + # New message from a client + if data.get("type") == "chat_message" and data.get("sender") == "client": + chat_id = data.get("chat_id") + + # If this is the currently open chat, append bubble directly + if self.active_chat_id == chat_id: + text = data.get("text", "") + self.messages_list.controls.append( + ft.Row( + alignment=ft.MainAxisAlignment.START, + controls=[ + ft.Container( + bgcolor=ft.Colors.GREY_200, + padding=8, + border_radius=12, + content=ft.Text(text, size=13), + ) + ], + ) + ) + self.page.update() + + # In any case, refresh sessions to update unread badges + self.load_sessions() + self.page.update() \ No newline at end of file diff --git a/UI_V2/admin/dashboard.py b/UI_V2/admin/dashboard.py index 038084b..7eebcbf 100644 --- a/UI_V2/admin/dashboard.py +++ b/UI_V2/admin/dashboard.py @@ -7,10 +7,20 @@ from admin.clients import Clients from admin.fidelity_cards import FidelityCards from admin.settings import Settings from admin.inventory.inventory import Inventory +from admin.admin_chat import AdminChat class Dashboard: def __init__(self, page: ft.Page): self.page = page + # Hide client floating chat button when in admin dashboard + # Home/landing page is responsible for re-creating it when needed. + try: + # If a floating_action_button exists (client chat), remove it for admin views + if getattr(self.page, "floating_action_button", None) is not None: + self.page.floating_action_button = None + except Exception as e: + # If anything goes wrong, fail silently; admin UI will still work. + print(e) self.category = Category(self.page) self.placeholder = ft.Container( content=self.category.build(), @@ -56,9 +66,9 @@ class Dashboard: label_content=ft.Text("Banner"), ), ft.NavigationRailDestination( - icon=ft.Icons.CARD_GIFTCARD_OUTLINED, - selected_icon=ft.Icon(ft.Icons.CARD_GIFTCARD), - label_content=ft.Text("Card de\nfidelitate"), + icon=ft.Icons.CHAT_OUTLINED, + selected_icon=ft.Icon(ft.Icons.CHAT), + label_content=ft.Text("Chat"), ), ft.NavigationRailDestination( icon=ft.Icons.INVENTORY_2_OUTLINED, @@ -105,9 +115,13 @@ class Dashboard: self.placeholder.content = self.banner.build() self.placeholder.update() case 6: - self.fidelity_cards = FidelityCards(self.page) - self.placeholder.content = self.fidelity_cards.build() + # self.fidelity_cards = FidelityCards(self.page) + # self.placeholder.content = self.fidelity_cards.build() + # self.placeholder.update() + self.chat = AdminChat(self.page) + self.placeholder.content = self.chat.build() self.placeholder.update() + case 7: self.inventory = Inventory(self.page, self) self.placeholder.content = self.inventory.build() diff --git a/UI_V2/dbActions/chat.py b/UI_V2/dbActions/chat.py new file mode 100644 index 0000000..c09bf01 --- /dev/null +++ b/UI_V2/dbActions/chat.py @@ -0,0 +1,352 @@ +import sqlite3 +from typing import Optional + +class Chat: + def __init__(self, db_path="instance/app_database.db"): + self.db_path = db_path + self._create_chatsessions_table() + self._create_chatmessages_table() + + def _create_chatsessions_table(self): + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS chatsessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + session_token TEXT, + status TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_message_at DATETIME , + last_message_from TEXT, + unread_for_admin INTEGER, + unread_for_user INTEGER + ); + + """) + conn.commit() + + def _create_chatmessages_table(self): + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS chatmessages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER, + session_token TEXT, + sender_type TEXT, + sender_id INTEGER, + text TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + read_at DATETIME + ); + + """) + conn.commit() + + def add_chatsession(self, chatsession): + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO chatsessions (user_id, session_token, status, last_message_at, last_message_from, unread_for_admin, unread_for_user) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, ( + chatsession['user_id'], + chatsession['session_token'], + chatsession['status'], + chatsession['last_message_at'], + chatsession['last_message_from'], + chatsession['unread_for_admin'], + chatsession['unread_for_user'] + ) + ) + conn.commit() + return True + except sqlite3.IntegrityError: + return False + + def get_chatsession(self, chat_id: int): + """Return a single chat session by its primary key id.""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT * FROM chatsessions + WHERE id = ? + """, + (chat_id,), + ) + row = cursor.fetchone() + if row: + return { + 'id': row[0], + 'user_id': row[1], + 'session_token': row[2], + 'status': row[3], + 'created_at': row[4], + 'last_message_at': row[5], + 'last_message_from': row[6], + 'unread_for_admin': row[7], + 'unread_for_user': row[8], + } + return None + + def get_chatsession_by_token(self, session_token: str) -> Optional[dict]: + """Return the chat session associated with a given browser / user session token.""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT * FROM chatsessions + WHERE session_token = ? + """, + (session_token,), + ) + row = cursor.fetchone() + if row: + return { + 'id': row[0], + 'user_id': row[1], + 'session_token': row[2], + 'status': row[3], + 'created_at': row[4], + 'last_message_at': row[5], + 'last_message_from': row[6], + 'unread_for_admin': row[7], + 'unread_for_user': row[8], + } + return None + + def get_all_chatsessions(self): + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT * FROM chatsessions + """) + rows = cursor.fetchall() + if rows: + buffer = [] + for row in rows: + buffer.append( + { + 'id': row[0], + 'user_id': row[1], + 'session_token': row[2], + 'status': row[3], + 'created_at' : row[4], + 'last_message_at': row[5], + 'last_message_from': row[6], + 'unread_for_admin': row[7], + 'unread_for_user': row[8], + } + ) + return buffer + return [] + + def update_chatsession(self, chatsession): + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE chatsessions SET + user_id = ?, + status = ?, + last_message_at = ?, + last_message_from = ?, + unread_for_admin = ?, + unread_for_user = ? + WHERE id = ? + ''', ( + chatsession['user_id'], + chatsession['status'], + chatsession['last_message_at'], + chatsession['last_message_from'], + chatsession['unread_for_admin'], + chatsession['unread_for_user'], + chatsession['id'] + ) + ) + conn.commit() + + def delete(self, id): + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(''' + DELETE FROM chatsessions WHERE id=?; + ''', (id,)) + conn.commit() + + + def add_chatmessage(self, message: dict) -> bool: + """Insert a new chat message. + + Expected keys in message dict: + - chat_id (int) + - session_token (str) + - sender_type (str: 'client' or 'admin') + - sender_id (int or None) + - text (str) + """ + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO chatmessages (chat_id, session_token, sender_type, sender_id, text) + VALUES (?, ?, ?, ?, ?) + """, + ( + message['chat_id'], + message.get('session_token'), + message['sender_type'], + message.get('sender_id'), + message['text'], + ), + ) + conn.commit() + # Refresh last_message_at on the corresponding chat session + cursor.execute( + """ + UPDATE chatsessions + SET last_message_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (message["chat_id"],), + ) + conn.commit() + return True + except sqlite3.IntegrityError: + return False + + def get_chatmessages_for_chat(self, chat_id: int): + """Return all messages for a given chat, ordered by creation time.""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT id, chat_id, session_token, sender_type, sender_id, text, created_at, read_at + FROM chatmessages + WHERE chat_id = ? + ORDER BY created_at ASC, id ASC + """, + (chat_id,), + ) + rows = cursor.fetchall() + messages = [] + for row in rows: + messages.append( + { + 'id': row[0], + 'chat_id': row[1], + 'session_token': row[2], + 'sender_type': row[3], + 'sender_id': row[4], + 'text': row[5], + 'created_at': row[6], + 'read_at': row[7], + } + ) + return messages + + def mark_messages_as_read(self, chat_id: int, reader_type: str): + """Mark messages in a chat as read for a given reader. + + Typically called with reader_type='admin' or 'client'. + We mark as read all messages where sender_type != reader_type and read_at IS NULL. + """ + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + UPDATE chatmessages + SET read_at = CURRENT_TIMESTAMP + WHERE chat_id = ? AND sender_type != ? AND read_at IS NULL + """, + (chat_id, reader_type), + ) + conn.commit() + + def delete_chatmessages_for_chat(self, chat_id: int): + """Delete all messages for a given chat (e.g. when cleaning up a closed session).""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + DELETE FROM chatmessages + WHERE chat_id = ? + """, + (chat_id,), + ) + conn.commit() + + def cleanup_old_sessions(self): + """Delete chat sessions and their messages older than today. + + Any session whose effective date (last_message_at if set, otherwise created_at) + is strictly before today's date will be removed together with its messages. + """ + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + # Identify sessions to delete: older than today based on last_message_at or created_at + cursor.execute( + """ + SELECT id FROM chatsessions + WHERE DATE(COALESCE(last_message_at, created_at)) < DATE('now','localtime') + """ + ) + rows = cursor.fetchall() + if not rows: + return + + old_ids = [row[0] for row in rows] + + # Delete messages for these sessions + cursor.execute( + f""" + DELETE FROM chatmessages + WHERE chat_id IN ({','.join(['?'] * len(old_ids))}) + """, + old_ids, + ) + + # Delete the sessions themselves + cursor.execute( + f""" + DELETE FROM chatsessions + WHERE id IN ({','.join(['?'] * len(old_ids))}) + """, + old_ids, + ) + + conn.commit() + + def get_chatsessions_with_messages(self): + """Return only chat sessions that have at least one message.""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT cs.id, cs.user_id, cs.session_token, cs.status, + cs.created_at, cs.last_message_at, cs.last_message_from, + cs.unread_for_admin, cs.unread_for_user + FROM chatsessions cs + WHERE cs.id IN (SELECT DISTINCT chat_id FROM chatmessages) + """ + ) + rows = cursor.fetchall() + sessions = [] + for row in rows: + sessions.append( + { + "id": row[0], + "user_id": row[1], + "session_token": row[2], + "status": row[3], + "created_at": row[4], + "last_message_at": row[5], + "last_message_from": row[6], + "unread_for_admin": row[7], + "unread_for_user": row[8], + } + ) + return sessions \ No newline at end of file diff --git a/UI_V2/dbActions/users.py b/UI_V2/dbActions/users.py index 588db64..a66b4a0 100644 --- a/UI_V2/dbActions/users.py +++ b/UI_V2/dbActions/users.py @@ -119,6 +119,29 @@ class Users: } return None + def get_user_by_role(self, role: str) -> Optional[dict]: + """Retrieve user details by username.""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT * FROM users + WHERE role = ? + """, (role,)) + row = cursor.fetchone() + if row: + return { + "id": row[0], + "email": row[1], + "token": row[3], + "name":row[4], + "phone": row[5], + "address": row[6], + "created_at": row[7], + "status": row[8], + "role":row[9] + } + return None + def get(self, id: int) -> Optional[dict]: """Retrieve user details by username.""" with sqlite3.connect(self.db_path) as conn: diff --git a/UI_V2/helpers/notifications.py b/UI_V2/helpers/notifications.py new file mode 100644 index 0000000..280c37a --- /dev/null +++ b/UI_V2/helpers/notifications.py @@ -0,0 +1,53 @@ + + +import flet as ft +from typing import Any + + +def send_browser_notification(page: ft.Page, title: str, body: str) -> None: + """Broadcast a browser notification event to all connected sessions. + + This is typically called from the client (shop) side when a new chat + is opened or the first message is sent. The actual visual notification + will be displayed only on admin pages that handle the event. + """ + if page is None: + return + + payload: dict[str, Any] = { + "type": "browser_notification", + "title": title, + "body": body, + } + + # Use PubSub so that admin sessions can react and show a snackbar/toast. + try: + # send_all so that any admin pages, and potentially this page, can receive it + page.pubsub.send_all(payload) + except Exception: + # If PubSub is not available, we simply do nothing; email still covers notification. + pass + + +def show_local_browser_notification(page: ft.Page, title: str, body: str) -> None: + """Show a visual notification (snackbar) on the current page for admin users. + + This is intended to be called from admin pages when a `browser_notification` + event is received via PubSub. It will silently do nothing for non-admin users. + """ + if page is None: + return + + user = page.session.get("user") if hasattr(page, "session") else None + if not user or user.get("role") != "admin": + # Only admins see these notifications in the browser + return + + message = f"{title}: {body}" if title else body + + page.snack_bar = ft.SnackBar( + content=ft.Text(message), + open=True, + action="OK", + ) + page.update() \ No newline at end of file diff --git a/UI_V2/pages/home/home.py b/UI_V2/pages/home/home.py index 5aa22fa..5ea6b81 100644 --- a/UI_V2/pages/home/home.py +++ b/UI_V2/pages/home/home.py @@ -1,11 +1,66 @@ import flet as ft +import uuid from dbActions.categories import Categories from dbActions.products import Products +from dbActions.chat import Chat +from helpers.emails import send_gmail +from helpers.notifications import send_browser_notification +from dbActions.users import Users class Home: def __init__(self, page: ft.Page): self.page = page self.categories_manager = Categories() + self.chat_manager = Chat() + # --- Chat session setup --- + # Logged-in user (if any) + self.user = self.page.session.get("user") + print(self.user) + + self.users_manager = Users() + self.admin = self.users_manager.get_user_by_role("admin") + + # Persistent token per browser session, used to associate a chat session + self.chat_session_token = self.page.session.get("chat_session_token") + if not self.chat_session_token: + self.chat_session_token = uuid.uuid4().hex + self.page.session.set("chat_session_token", self.chat_session_token) + + # Get or create chat session in DB + chat_session = self.chat_manager.get_chatsession_by_token(self.chat_session_token) + if chat_session is None: + chat_data = { + "user_id": self.user["id"] if self.user else None, + "session_token": self.chat_session_token, + "status": "open", + "last_message_at": None, + "last_message_from": None, + "unread_for_admin": 0, + "unread_for_user": 0, + } + self.chat_manager.add_chatsession(chat_data) + chat_session = self.chat_manager.get_chatsession_by_token(self.chat_session_token) + + # If we have a logged-in user, always make sure the chat session is linked to that user + if self.user and chat_session is not None: + current_user_id = chat_session.get("user_id") + new_user_id = self.user.get("id") + # Only update if different or missing to avoid unnecessary writes + if current_user_id is None or current_user_id != new_user_id: + chat_session["user_id"] = new_user_id + try: + self.chat_manager.update_chatsession(chat_session) + except Exception as e: + print("Unable to update chatsession user_id:", e) + + self.chat_id = chat_session["id"] + + # Subscribe to PubSub to receive admin messages for this chat + try: + self.page.pubsub.subscribe(self.on_pubsub_message) + except Exception: + # If PubSub is not configured, just ignore; chat will still work locally. + pass self.header = ft.Row( [ #ft.Button("Acasa", icon=ft.Icons.HOME, on_click=self.on_acasa_btn_click) @@ -66,6 +121,339 @@ class Home: self.page.session.set("search_for", None) self.searchbar.value = '' + page.floating_action_button = ft.FloatingActionButton( + icon=ft.Icons.CHAT, + tooltip="Ai nevoie de ajutor?", + on_click=self.open_chat, + bgcolor=ft.Colors.BROWN_50, + badge=ft.Badge(bgcolor=ft.Colors.GREEN, small_size=10) + ) + page.floating_action_button_location = ft.FloatingActionButtonLocation.END_FLOAT + + # Flag: does this chat already have any messages persisted in DB? + self.chat_has_messages = False + + # Messages list (scrollable) + self.messages_list = ft.ListView( + expand=True, + spacing=10, + auto_scroll=True, + padding=0, + ) + + # Load existing chat history from DB or show a default welcome message + self.load_chat_history() + + # Input field + self.chat_input = ft.TextField( + hint_text="Scrie mesajul tău aici...", + border_radius=20, + dense=True, + content_padding=ft.Padding(12, 8, 12, 8), + expand=True, + on_submit=self.on_send_click + ) + + self.send_button = ft.IconButton( + icon=ft.Icons.SEND_ROUNDED, + tooltip="Trimite", + on_click=self.on_send_click, + ) + + self.chat_header = ft.Row( + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + controls=[ + ft.Row( + spacing=10, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + controls=[ + ft.CircleAvatar( + content=ft.Icon(ft.Icons.SUPPORT_AGENT), + bgcolor=ft.Colors.BROWN_100, + radius=18, + ), + ft.Column( + spacing=0, + controls=[ + ft.Text("Suport online", weight="bold", size=15), + ft.Text("De obicei răspundem în câteva minute", size=11, color=ft.Colors.GREY_600), + ], + ), + ], + ), + ft.IconButton( + icon=ft.Icons.CLOSE, + tooltip="Închide chat-ul", + on_click=self.close_chat, + ), + ], + ) + + # Whole chat panel inside BottomSheet + self.chat_content = ft.Container( + # This keeps it nice on desktop (not full width) but still responsive + content=ft.Column( + expand=True, + controls=[ + self.chat_header, + ft.Divider(), + ft.Container( + height=280, # chat messages area height + content=self.messages_list, + ), + ft.Divider(), + ft.Row( + controls=[self.chat_input, self.send_button], + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + ], + ), + padding=15, + ) + + self.chat_sheet = ft.BottomSheet( + ft.Column( + controls=[ + ft.Text(), + ft.Row( + controls=[ + ft.Container( + content=self.chat_content, + bgcolor=ft.Colors.WHITE, + border_radius=16, + shadow=ft.BoxShadow( + blur_radius=18, + spread_radius=0, + color=ft.Colors.with_opacity(0.25, ft.Colors.BLACK), + ), + # Max width for desktop; on mobile it will just fill the width + width=350, + ) + ], + alignment=ft.MainAxisAlignment.CENTER, + ) + ], + width=400, + height=500 + ), + # Optional: set this to True if you want click-outside to close + enable_drag=True, + is_scroll_controlled=True, + ) + + # Header close action + def close_chat(self, e): + self.page.close(self.chat_sheet) + + def load_chat_history(self): + """Load messages from DB for this chat and render them in the list view. + + If there is no history, show a default welcome message. + """ + # Clear any existing controls, just to be safe + self.messages_list.controls.clear() + + history = [] + try: + history = self.chat_manager.get_chatmessages_for_chat(self.chat_id) + except Exception: + history = [] + + # Track if this chat already has any messages + self.chat_has_messages = bool(history) + + if not history: + # Pre-fill with a welcome message (design only) + self.messages_list.controls.append( + ft.Container( + bgcolor=ft.Colors.GREY_200, + padding=8, + border_radius=12, + content=ft.Text( + "Bună! 👋 Cu ce te pot ajuta azi?", + size=13, + ), + ) + ) + return + + for msg in history: + if msg.get("sender_type") == "client": + alignment = ft.MainAxisAlignment.END + bubble_color = ft.Colors.BROWN_100 + else: + alignment = ft.MainAxisAlignment.START + bubble_color = ft.Colors.GREY_200 + + self.messages_list.controls.append( + ft.Row( + alignment=alignment, + controls=[ + ft.Container( + bgcolor=bubble_color, + padding=8, + border_radius=12, + content=ft.Text(msg.get("text", ""), size=13), + ) + ], + ) + ) + + def on_send_click(self, e): + text = self.chat_input.value.strip() + if not text: + return + + # Determine if this is the first message in this chat + is_first_message = not getattr(self, "chat_has_messages", False) + + # Append user bubble locally + self.messages_list.controls.append( + ft.Row( + alignment=ft.MainAxisAlignment.END, + controls=[ + ft.Container( + bgcolor=ft.Colors.BROWN_100, + padding=8, + border_radius=12, + content=ft.Text(text, size=13), + ) + ], + ) + ) + + # Persist message in DB + message = { + "chat_id": self.chat_id, + "session_token": self.chat_session_token, + "sender_type": "client", + "sender_id": self.user["id"] if self.user else None, + "text": text, + } + try: + self.chat_manager.add_chatmessage(message) + except Exception: + # If DB fails, ignore for now but still show message locally + pass + + # From this point on, the chat definitely has at least one message + self.chat_has_messages = True + + # If this was the first message, trigger notifications (email + browser) for ADMIN + if is_first_message: + # 1) Email to admin, if we have a usable email address + try: + to_email = None + if getattr(self, "admin", None): + # Depending on how get_user_by_role works, self.admin may be a dict or a list + admin_obj = self.admin + if isinstance(admin_obj, dict): + to_email = admin_obj.get("email") + elif isinstance(admin_obj, (list, tuple)) and admin_obj: + # Take the first admin record if a list is returned + first_admin = admin_obj[0] + if isinstance(first_admin, dict): + to_email = first_admin.get("email") + + if to_email and "@" in to_email: + subject = "[Taina Gustului] Chat nou de la client" + client_info = "un vizitator neautentificat" + if self.user: + client_email = self.user.get("email") + client_name = self.user.get("name") or self.user.get("full_name") + if client_email: + client_info = f"clientul cu email {client_email}" + elif client_name: + client_info = f"clientul {client_name}" + + body = ( + "Bună,\n\n" + f"{client_info} a început o discuție pe chat în magazinul online Taina Gustului.\n" + "Poți răspunde direct din panoul de administrare, în secțiunea de chat.\n\n" + "Acest mesaj este generat automat." + ) + send_gmail(to_email, subject, body) + except Exception as ex: + print("Unable to send chat email notification to admin:", ex) + + # 2) Browser notification (intended for admin side) + # This simply delegates to helpers.notifications.send_browser_notification, + # which you can implement to show notifications in the admin UI. + try: + title = "Chat nou de la client" + client_info = None + if self.user: + client_info = self.user.get("email") or self.user.get("name") + + body = ( + "Un client a început o discuție pe chat." + if not client_info + else f"{client_info} a început o discuție pe chat." + ) + send_browser_notification(self.page, title, body) + except Exception as ex: + print("Unable to send browser notification for admin:", ex) + + # Update chat session metadata + try: + chatsession = self.chat_manager.get_chatsession(self.chat_id) + if chatsession: + chatsession["last_message_from"] = "client" + chatsession["unread_for_admin"] = 1 + # do not change unread_for_user here + self.chat_manager.update_chatsession(chatsession) + except Exception: + pass + + # Notify other sessions (e.g., admin console) via PubSub + try: + self.page.pubsub.send_others( + { + "type": "chat_message", + "chat_id": self.chat_id, + "sender": "client", + "text": text, + } + ) + except Exception: + # PubSub might not be available; safe to ignore + pass + + self.chat_input.value = "" + # Keep the focus in the chat input so the user can continue typing + self.chat_input.focus() + self.chat_input.update() + self.page.update() + + def on_pubsub_message(self, data): + """Handle incoming PubSub messages (e.g., from admin).""" + # Only process dictionary payloads + if not isinstance(data, dict): + return + + # Handle incoming chat messages for this chat + if data.get("type") == "chat_message" and data.get("chat_id") == self.chat_id: + if data.get("sender") == "admin": + # Show admin message as a bubble on the left + self.messages_list.controls.append( + ft.Row( + alignment=ft.MainAxisAlignment.START, + controls=[ + ft.Container( + bgcolor=ft.Colors.GREY_200, + padding=8, + border_radius=12, + content=ft.Text(data.get("text", ""), size=13), + ) + ], + ) + ) + self.page.update() + + def open_chat(self, e): + self.page.open(self.chat_sheet) + def on_acasa_btn_click(self, e): self.page.go('/')