add chat system

This commit is contained in:
2025-11-25 15:07:00 +02:00
parent 28771d4c6a
commit 65e1df9ebe
6 changed files with 1220 additions and 5 deletions

385
UI_V2/admin/admin_chat.py Normal file
View File

@@ -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()

View File

@@ -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()

352
UI_V2/dbActions/chat.py Normal file
View File

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

View File

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

View File

@@ -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()

View File

@@ -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('/')