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.fidelity_cards import FidelityCards
from admin.settings import Settings from admin.settings import Settings
from admin.inventory.inventory import Inventory from admin.inventory.inventory import Inventory
from admin.admin_chat import AdminChat
class Dashboard: class Dashboard:
def __init__(self, page: ft.Page): def __init__(self, page: ft.Page):
self.page = 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.category = Category(self.page)
self.placeholder = ft.Container( self.placeholder = ft.Container(
content=self.category.build(), content=self.category.build(),
@@ -56,9 +66,9 @@ class Dashboard:
label_content=ft.Text("Banner"), label_content=ft.Text("Banner"),
), ),
ft.NavigationRailDestination( ft.NavigationRailDestination(
icon=ft.Icons.CARD_GIFTCARD_OUTLINED, icon=ft.Icons.CHAT_OUTLINED,
selected_icon=ft.Icon(ft.Icons.CARD_GIFTCARD), selected_icon=ft.Icon(ft.Icons.CHAT),
label_content=ft.Text("Card de\nfidelitate"), label_content=ft.Text("Chat"),
), ),
ft.NavigationRailDestination( ft.NavigationRailDestination(
icon=ft.Icons.INVENTORY_2_OUTLINED, icon=ft.Icons.INVENTORY_2_OUTLINED,
@@ -105,9 +115,13 @@ class Dashboard:
self.placeholder.content = self.banner.build() self.placeholder.content = self.banner.build()
self.placeholder.update() self.placeholder.update()
case 6: case 6:
self.fidelity_cards = FidelityCards(self.page) # self.fidelity_cards = FidelityCards(self.page)
self.placeholder.content = self.fidelity_cards.build() # self.placeholder.content = self.fidelity_cards.build()
# self.placeholder.update()
self.chat = AdminChat(self.page)
self.placeholder.content = self.chat.build()
self.placeholder.update() self.placeholder.update()
case 7: case 7:
self.inventory = Inventory(self.page, self) self.inventory = Inventory(self.page, self)
self.placeholder.content = self.inventory.build() 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 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]: def get(self, id: int) -> Optional[dict]:
"""Retrieve user details by username.""" """Retrieve user details by username."""
with sqlite3.connect(self.db_path) as conn: 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 flet as ft
import uuid
from dbActions.categories import Categories from dbActions.categories import Categories
from dbActions.products import Products 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: class Home:
def __init__(self, page: ft.Page): def __init__(self, page: ft.Page):
self.page = page self.page = page
self.categories_manager = Categories() 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( self.header = ft.Row(
[ [
#ft.Button("Acasa", icon=ft.Icons.HOME, on_click=self.on_acasa_btn_click) #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.page.session.set("search_for", None)
self.searchbar.value = '' 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): def on_acasa_btn_click(self, e):
self.page.go('/') self.page.go('/')