From d8a1da1f13657904bc35514273c96c2d4c533ebb Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Wed, 20 May 2026 16:38:24 +0300
Subject: [PATCH] feat: add support tickets and imrpove web app loading
---
.dockerignore | 1 +
.env.example | 11 +
.gitignore | 3 +
backend/bot/app/factories/build_services.py | 23 +
backend/bot/app/web/admin_api.py | 2 +
backend/bot/app/web/admin_api_impl/routes.py | 10 +
backend/bot/app/web/admin_api_impl/support.py | 251 +++
.../bot/app/web/admin_settings_manifest.py | 81 +-
backend/bot/app/web/subscription_webapp.py | 2 +
.../web/templates/subscription_webapp.html | 71 +-
backend/bot/app/web/webapp/_runtime.py | 2 +-
backend/bot/app/web/webapp/application.py | 3 +
backend/bot/app/web/webapp/assets.py | 94 +-
backend/bot/app/web/webapp/payloads.py | 51 +
backend/bot/app/web/webapp/routes.py | 13 +-
backend/bot/app/web/webapp/serializers.py | 11 +
backend/bot/app/web/webapp/support.py | 152 ++
backend/bot/handlers/user/start.py | 29 +-
backend/bot/main_bot.py | 3 +
backend/bot/services/email_templates.py | 152 ++
backend/bot/services/notification_service.py | 368 +++-
backend/bot/services/support_service.py | 465 +++++
backend/config/settings.py | 13 +-
backend/db/dal/__init__.py | 2 +
backend/db/dal/app_settings_dal.py | 11 +
backend/db/dal/support_dal.py | 375 ++++
backend/db/migrator.py | 130 ++
backend/db/models.py | 56 +
deploy/docker/Dockerfile | 9 +
deploy/docker/frontend/nginx.conf | 26 +
.../scripts/build_subscription_webapp_js.mjs | 40 +-
frontend/src/App.svelte | 133 +-
frontend/src/admin/AdminPanel.svelte | 48 +-
.../admin/sections/AppearanceSection.svelte | 7 +-
.../src/admin/sections/SettingsSection.svelte | 17 +-
.../src/admin/sections/SupportSection.svelte | 347 ++++
frontend/src/lib/admin/stores/supportStore.js | 216 ++
frontend/src/lib/admin/stores/themesStore.js | 32 +-
.../patterns/admin/AdminRevenueChart.svelte | 2 +-
.../patterns/admin/SupportComposer.svelte | 50 +
.../patterns/admin/SupportInboxRow.svelte | 84 +
.../patterns/admin/SupportTicketHeader.svelte | 79 +
.../admin/SupportUserContextPanel.svelte | 75 +
.../lib/components/patterns/admin/index.js | 4 +
.../patterns/webapp/TicketCard.svelte | 59 +
.../patterns/webapp/TicketComposer.svelte | 41 +
.../webapp/TicketMessageBubble.svelte | 79 +
.../lib/components/patterns/webapp/index.js | 3 +
.../lib/components/ui/attention-dot.svelte | 14 +
frontend/src/lib/components/ui/icons.js | 4 +
frontend/src/lib/components/ui/index.js | 3 +
.../src/lib/components/ui/scroll-area.svelte | 15 +
.../src/lib/components/ui/textarea.svelte | 23 +
frontend/src/lib/webapp/constants.js | 2 +
frontend/src/lib/webapp/i18n.js | 21 +-
frontend/src/lib/webapp/mockApi.js | 270 +++
frontend/src/lib/webapp/routes.js | 24 +-
.../src/lib/webapp/stores/supportStore.js | 287 +++
frontend/src/main.js | 3 +-
frontend/src/styles/admin.css | 1758 ++++++++++++++++-
frontend/src/styles/components.css | 1510 +++++++++++++-
frontend/src/styles/webapp.css | 116 +-
frontend/src/webapp/BottomNav.svelte | 80 +-
frontend/src/webapp/WebAppShell.svelte | 12 +-
.../src/webapp/screens/SettingsScreen.svelte | 5 +-
.../src/webapp/screens/SupportScreen.svelte | 265 +++
.../webapp/screens/SupportTicketScreen.svelte | 172 ++
locales/en.json | 112 +-
locales/ru.json | 112 +-
tests/test_admin_settings_manifest_i18n.py | 52 +
tests/test_build_services_wiring.py | 6 +-
tests/test_settings.py | 11 +
tests/test_support_api_admin.py | 17 +
tests/test_support_api_user.py | 25 +
tests/test_support_dal.py | 12 +
tests/test_support_migration.py | 23 +
tests/test_support_notifications.py | 366 ++++
tests/test_support_service.py | 102 +
78 files changed, 8990 insertions(+), 168 deletions(-)
create mode 100644 backend/bot/app/web/admin_api_impl/support.py
create mode 100644 backend/bot/app/web/webapp/support.py
create mode 100644 backend/bot/services/support_service.py
create mode 100644 backend/db/dal/support_dal.py
create mode 100644 frontend/src/admin/sections/SupportSection.svelte
create mode 100644 frontend/src/lib/admin/stores/supportStore.js
create mode 100644 frontend/src/lib/components/patterns/admin/SupportComposer.svelte
create mode 100644 frontend/src/lib/components/patterns/admin/SupportInboxRow.svelte
create mode 100644 frontend/src/lib/components/patterns/admin/SupportTicketHeader.svelte
create mode 100644 frontend/src/lib/components/patterns/admin/SupportUserContextPanel.svelte
create mode 100644 frontend/src/lib/components/patterns/webapp/TicketCard.svelte
create mode 100644 frontend/src/lib/components/patterns/webapp/TicketComposer.svelte
create mode 100644 frontend/src/lib/components/patterns/webapp/TicketMessageBubble.svelte
create mode 100644 frontend/src/lib/components/ui/attention-dot.svelte
create mode 100644 frontend/src/lib/components/ui/scroll-area.svelte
create mode 100644 frontend/src/lib/components/ui/textarea.svelte
create mode 100644 frontend/src/lib/webapp/stores/supportStore.js
create mode 100644 frontend/src/webapp/screens/SupportScreen.svelte
create mode 100644 frontend/src/webapp/screens/SupportTicketScreen.svelte
create mode 100644 tests/test_admin_settings_manifest_i18n.py
create mode 100644 tests/test_support_api_admin.py
create mode 100644 tests/test_support_api_user.py
create mode 100644 tests/test_support_dal.py
create mode 100644 tests/test_support_migration.py
create mode 100644 tests/test_support_notifications.py
create mode 100644 tests/test_support_service.py
diff --git a/.dockerignore b/.dockerignore
index 9708826..303a129 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -33,6 +33,7 @@ tmp/
backend/bot/app/web/templates/subscription_webapp.css
backend/bot/app/web/templates/subscription_webapp.js
backend/bot/app/web/templates/subscription_webapp.min.*.js
+backend/bot/app/web/templates/subscription_webapp.*.css
# Byte-compiled / optimized / DLL files
**/__pycache__/
diff --git a/.env.example b/.env.example
index 9038982..a7b7384 100644
--- a/.env.example
+++ b/.env.example
@@ -304,13 +304,24 @@ LOG_LEVEL=INFO #
# Admin Logging Configuration
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
+LOG_SUPPORT_THREAD_ID= # Optional: Thread ID for support ticket messages
LOG_NEW_USERS=True # Log new user registrations
LOG_PAYMENTS=True # Log payments
+LOG_SUPPORT=True # Log support tickets and replies
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
LOG_ADMIN_ACTIONS=True # Log actions from users listed in ADMIN_IDS
+# Support tickets
+SUPPORT_TICKETS_ENABLED=True # Enable support tickets in the Mini App
+SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=False # Send support ticket emails to admins with email addresses
+SUPPORT_TICKET_MAX_BODY_LENGTH=4000 # Max message length
+SUPPORT_TICKET_MAX_SUBJECT_LENGTH=160 # Max subject length
+SUPPORT_TICKET_RATE_LIMIT_PER_HOUR=5 # New tickets per user per hour
+SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS=300 # Min seconds between admin Telegram/log notifications per unread ticket
+SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS=1800 # Min seconds between admin email notifications per unread ticket
+
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
diff --git a/.gitignore b/.gitignore
index a4316fe..8601830 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ bot_database.sqlite3
!.env.example
docker-compose-dev.yml
scratch_*.py
+scratch/
*.local.*
node_modules/
.git/
@@ -15,9 +16,11 @@ node_modules/
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.js
bot/app/web/templates/subscription_webapp.min.*.js
+bot/app/web/templates/subscription_webapp.*.css
backend/bot/app/web/templates/subscription_webapp.css
backend/bot/app/web/templates/subscription_webapp.js
backend/bot/app/web/templates/subscription_webapp.min.*.js
+backend/bot/app/web/templates/subscription_webapp.*.css
tmp
.claude
diff --git a/backend/bot/app/factories/build_services.py b/backend/bot/app/factories/build_services.py
index d382447..9ca23a1 100644
--- a/backend/bot/app/factories/build_services.py
+++ b/backend/bot/app/factories/build_services.py
@@ -7,12 +7,15 @@ from bot.payment_providers import (
build_provider_configs,
build_provider_services,
)
+from bot.services.email_auth_service import EmailAuthService
from bot.services.lknpd_service import LknpdService
+from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
+from bot.services.support_service import SupportService
from config.settings import Settings
@@ -27,6 +30,23 @@ def build_core_services(
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
referral_service = ReferralService(settings, subscription_service, bot, i18n)
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
+ email_auth_service = EmailAuthService(settings)
+ notification_service = NotificationService(
+ bot,
+ settings,
+ i18n,
+ session_factory=async_session_factory,
+ email_auth_service=email_auth_service,
+ bot_username=bot_username_for_default_return,
+ )
+ support_service = SupportService(
+ async_session_factory,
+ settings,
+ bot,
+ i18n,
+ notification_service,
+ email_auth_service,
+ )
panel_webhook_service = PanelWebhookService(
bot, settings, i18n, async_session_factory, panel_service
)
@@ -58,6 +78,9 @@ def build_core_services(
"subscription_service": subscription_service,
"referral_service": referral_service,
"promo_code_service": promo_code_service,
+ "notification_service": notification_service,
+ "email_auth_service": email_auth_service,
+ "support_service": support_service,
"panel_webhook_service": panel_webhook_service,
"lknpd_service": lknpd_service,
}
diff --git a/backend/bot/app/web/admin_api.py b/backend/bot/app/web/admin_api.py
index 385a0e3..6d277b5 100644
--- a/backend/bot/app/web/admin_api.py
+++ b/backend/bot/app/web/admin_api.py
@@ -15,6 +15,7 @@ from bot.app.web.admin_api_impl import (
routes as _routes,
settings as _settings,
stats as _stats,
+ support as _support,
sync as _sync,
tariffs as _tariffs,
themes as _themes,
@@ -30,6 +31,7 @@ _MODULES = (
_payments,
_promos,
_logs,
+ _support,
_broadcast,
_sync,
_ads,
diff --git a/backend/bot/app/web/admin_api_impl/routes.py b/backend/bot/app/web/admin_api_impl/routes.py
index 4f325ad..a6d9d98 100644
--- a/backend/bot/app/web/admin_api_impl/routes.py
+++ b/backend/bot/app/web/admin_api_impl/routes.py
@@ -45,6 +45,16 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_get("/api/admin/logs", admin_logs_route)
+ router.add_get("/api/admin/support/tickets", admin_support_tickets_route)
+ router.add_get("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_detail_route)
+ router.add_post(
+ "/api/admin/support/tickets/{id:\\d+}/messages",
+ admin_support_ticket_reply_route,
+ )
+ router.add_patch("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_patch_route)
+ router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route)
+ router.add_get("/api/admin/support/stats", admin_support_stats_route)
+
router.add_post("/api/admin/broadcast", admin_broadcast_route)
router.add_post("/api/admin/sync", admin_sync_route)
diff --git a/backend/bot/app/web/admin_api_impl/support.py b/backend/bot/app/web/admin_api_impl/support.py
new file mode 100644
index 0000000..14910fd
--- /dev/null
+++ b/backend/bot/app/web/admin_api_impl/support.py
@@ -0,0 +1,251 @@
+# ruff: noqa: F401,F403,F405,I001
+from ._runtime import * # noqa: F403,F405
+
+from typing import Literal, Optional
+
+from pydantic import BaseModel, ConfigDict, constr, field_validator
+
+from bot.services.support_service import TicketNotFound
+from db.dal import support_dal, user_dal
+from db.models import SupportTicket, SupportTicketMessage
+
+
+class AdminTicketReplyPayload(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+
+ body: constr(min_length=1, max_length=4000)
+ is_internal_note: bool = False
+
+ @field_validator("body")
+ @classmethod
+ def _strip_body(cls, value: str) -> str:
+ stripped = value.strip()
+ if not stripped:
+ raise ValueError("empty_text")
+ return stripped
+
+
+class AdminTicketPatchPayload(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+
+ status: Optional[Literal["open", "awaiting_user", "awaiting_admin", "resolved", "closed"]] = (
+ None
+ )
+ priority: Optional[Literal["low", "normal", "high", "urgent"]] = None
+ category: Optional[Literal["billing", "technical", "account", "other"]] = None
+ assigned_admin_id: Optional[int] = None
+
+
+def _validate_model_payload(model_cls, payload: Dict[str, Any]):
+ try:
+ return model_cls.model_validate(payload), None
+ except ValidationError:
+ return None, _error(400, "invalid_request", "Invalid request")
+
+
+def _support_ticket_payload(ticket: SupportTicket) -> Dict[str, Any]:
+ return {
+ "ticket_id": ticket.ticket_id,
+ "user_id": ticket.user_id,
+ "subject": ticket.subject,
+ "category": ticket.category,
+ "priority": ticket.priority,
+ "status": ticket.status,
+ "assigned_admin_id": ticket.assigned_admin_id,
+ "last_message_at": ticket.last_message_at.isoformat() if ticket.last_message_at else None,
+ "last_message_role": ticket.last_message_role,
+ "unread_user_count": int(ticket.unread_user_count or 0),
+ "unread_admin_count": int(ticket.unread_admin_count or 0),
+ "created_at": ticket.created_at.isoformat() if ticket.created_at else None,
+ "updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
+ "closed_at": ticket.closed_at.isoformat() if ticket.closed_at else None,
+ }
+
+
+def _user_display_name(user) -> Optional[str]:
+ if not user:
+ return None
+ name = " ".join(
+ part.strip() for part in [user.first_name, user.last_name] if part and part.strip()
+ ).strip()
+ return name or user.username or user.email or str(user.user_id)
+
+
+def _support_message_payload(
+ message: SupportTicketMessage,
+ *,
+ authors: Optional[Dict[int, Any]] = None,
+) -> Dict[str, Any]:
+ author = authors.get(message.author_user_id) if authors and message.author_user_id else None
+ return {
+ "message_id": message.message_id,
+ "ticket_id": message.ticket_id,
+ "author_role": message.author_role,
+ "author_user_id": message.author_user_id,
+ "author_name": _user_display_name(author),
+ "body": message.body,
+ "is_internal_note": bool(message.is_internal_note),
+ "created_at": message.created_at.isoformat() if message.created_at else None,
+ "read_by_user_at": message.read_by_user_at.isoformat() if message.read_by_user_at else None,
+ "read_by_admin_at": message.read_by_admin_at.isoformat()
+ if message.read_by_admin_at
+ else None,
+ }
+
+
+def _admin_support_user_payload(user) -> Dict[str, Any]:
+ if not user:
+ return {}
+ return {
+ "user_id": user.user_id,
+ "telegram_id": user.telegram_id,
+ "username": user.username,
+ "first_name": user.first_name,
+ "last_name": user.last_name,
+ "email": user.email,
+ "telegram_photo_url": user.telegram_photo_url,
+ "is_banned": bool(user.is_banned),
+ "registration_date": user.registration_date.isoformat() if user.registration_date else None,
+ }
+
+
+def _support_limit_offset(request: web.Request) -> tuple[int, int]:
+ limit = max(1, min(100, int(request.query.get("limit", 25) or 25)))
+ offset = max(0, int(request.query.get("offset", 0) or 0))
+ return limit, offset
+
+
+async def admin_support_tickets_route(request: web.Request) -> web.Response:
+ _require_admin_user_id(request)
+ limit, offset = _support_limit_offset(request)
+ assigned_raw = request.query.get("assigned")
+ assigned_admin_id = None
+ if assigned_raw and assigned_raw not in {"all", "any"}:
+ assigned_admin_id = int(assigned_raw)
+ async_session_factory: sessionmaker = request.app["async_session_factory"]
+ async with async_session_factory() as session:
+ tickets = await support_dal.list_admin_tickets(
+ session,
+ status=request.query.get("status") or None,
+ priority=request.query.get("priority") or None,
+ category=request.query.get("category") or None,
+ assigned_admin_id=assigned_admin_id,
+ search=request.query.get("search") or None,
+ sort=request.query.get("sort") or "updated_desc",
+ limit=limit,
+ offset=offset,
+ )
+ return web.json_response(
+ {
+ "ok": True,
+ "tickets": [
+ {
+ **_support_ticket_payload(ticket),
+ "user": _admin_support_user_payload(getattr(ticket, "user", None)),
+ }
+ for ticket in tickets
+ ],
+ }
+ )
+
+
+async def admin_support_ticket_detail_route(request: web.Request) -> web.Response:
+ _require_admin_user_id(request)
+ ticket_id = int(request.match_info["id"])
+ async_session_factory: sessionmaker = request.app["async_session_factory"]
+ service = request.app["support_service"]
+ async with async_session_factory() as session:
+ ticket, messages = await support_dal.get_ticket(session, ticket_id, include_internal=True)
+ if not ticket:
+ return _error(404, "not_found", "Ticket not found")
+ user = await user_dal.get_user_by_id(session, ticket.user_id)
+ snapshot = await service.build_user_snapshot(user, session=session) if user else {}
+ author_ids = {m.author_user_id for m in messages if m.author_user_id is not None}
+ authors = {}
+ for author_id in author_ids:
+ author = await user_dal.get_user_by_id(session, author_id)
+ if author:
+ authors[author_id] = author
+ return web.json_response(
+ {
+ "ok": True,
+ "ticket": {
+ **_support_ticket_payload(ticket),
+ "user": _admin_support_user_payload(user),
+ },
+ "messages": [_support_message_payload(m, authors=authors) for m in messages],
+ "user_snapshot": snapshot,
+ }
+ )
+
+
+async def admin_support_ticket_reply_route(request: web.Request) -> web.Response:
+ admin_id = _require_admin_user_id(request)
+ ticket_id = int(request.match_info["id"])
+ payload, error = _validate_model_payload(AdminTicketReplyPayload, await _read_json(request))
+ if error:
+ return error
+ try:
+ ticket, message = await request.app["support_service"].reply_as_admin(
+ admin_id,
+ ticket_id,
+ payload.body,
+ is_internal_note=payload.is_internal_note,
+ )
+ except TicketNotFound:
+ return _error(404, "not_found", "Ticket not found")
+ async_session_factory: sessionmaker = request.app["async_session_factory"]
+ async with async_session_factory() as session:
+ admin = await user_dal.get_user_by_id(session, admin_id)
+ return web.json_response(
+ {
+ "ok": True,
+ "ticket": _support_ticket_payload(ticket),
+ "message": _support_message_payload(
+ message, authors={admin_id: admin} if admin else {}
+ ),
+ }
+ )
+
+
+async def admin_support_ticket_patch_route(request: web.Request) -> web.Response:
+ admin_id = _require_admin_user_id(request)
+ ticket_id = int(request.match_info["id"])
+ payload, error = _validate_model_payload(AdminTicketPatchPayload, await _read_json(request))
+ if error:
+ return error
+ updates = payload.model_dump(exclude_unset=True)
+ try:
+ if updates.get("status") == "closed":
+ ticket = await request.app["support_service"].close_ticket(admin_id, ticket_id)
+ updates.pop("status", None)
+ if updates:
+ ticket = await request.app["support_service"]._update_and_audit(
+ admin_id,
+ ticket_id,
+ **updates,
+ )
+ else:
+ ticket = await request.app["support_service"]._update_and_audit(
+ admin_id,
+ ticket_id,
+ **updates,
+ )
+ except TicketNotFound:
+ return _error(404, "not_found", "Ticket not found")
+ return web.json_response({"ok": True, "ticket": _support_ticket_payload(ticket)})
+
+
+async def admin_support_ticket_read_route(request: web.Request) -> web.Response:
+ _require_admin_user_id(request)
+ ticket_id = int(request.match_info["id"])
+ await request.app["support_service"].mark_read_as_admin(ticket_id)
+ return web.json_response({"ok": True})
+
+
+async def admin_support_stats_route(request: web.Request) -> web.Response:
+ _require_admin_user_id(request)
+ async_session_factory: sessionmaker = request.app["async_session_factory"]
+ async with async_session_factory() as session:
+ stats = await support_dal.admin_stats(session)
+ return web.json_response({"ok": True, "stats": stats})
diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py
index 4f4471b..dcba97b 100644
--- a/backend/bot/app/web/admin_settings_manifest.py
+++ b/backend/bot/app/web/admin_settings_manifest.py
@@ -235,6 +235,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
),
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
+ SettingField("LOG_SUPPORT", "bool", "notifications", "Логировать тикеты поддержки"),
SettingField(
"LOG_PROMO_ACTIVATIONS", "bool", "notifications", "Логировать активации промокодов"
),
@@ -248,8 +249,8 @@ SETTINGS_MANIFEST: List[SettingField] = [
"notifications",
"Логировать действия администраторов",
"Если выключено, события от пользователей из ADMIN_IDS не записываются в message logs.",
- i18n_label_key="settings_field_log_admin_actions_label",
- i18n_description_key="settings_field_log_admin_actions_description",
+ i18n_label_key="admin_settings_field_log_admin_actions_label",
+ i18n_description_key="admin_settings_field_log_admin_actions_description",
),
SettingField(
"LOG_LEVEL",
@@ -260,6 +261,73 @@ SETTINGS_MANIFEST: List[SettingField] = [
),
SettingField("LOG_CHAT_ID", "int", "notifications", "ID чата для логов"),
SettingField("LOG_THREAD_ID", "int", "notifications", "ID треда (для супергрупп)"),
+ SettingField(
+ "LOG_SUPPORT_THREAD_ID",
+ "int",
+ "notifications",
+ "ID треда поддержки",
+ "Тред лог-чата для уведомлений о тикетах поддержки.",
+ ),
+ SettingField(
+ "SUPPORT_TICKETS_ENABLED",
+ "bool",
+ "support",
+ "Тикеты поддержки включены",
+ "Показывает раздел поддержки в ЛК и включает создание тикетов.",
+ ),
+ SettingField(
+ "SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED",
+ "bool",
+ "support",
+ "Email-уведомления админам",
+ (
+ "Если выключено, новые тикеты и ответы пользователей останутся "
+ "только в Telegram и лог-чате."
+ ),
+ ),
+ SettingField(
+ "SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS",
+ "int",
+ "support",
+ "Пауза Telegram-уведомлений",
+ (
+ "Минимум секунд между повторными Telegram/log уведомлениями "
+ "по одному непрочитанному тикету."
+ ),
+ min=0,
+ ),
+ SettingField(
+ "SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS",
+ "int",
+ "support",
+ "Пауза email-уведомлений",
+ "Минимум секунд между повторными email-уведомлениями по одному непрочитанному тикету.",
+ min=0,
+ ),
+ SettingField(
+ "SUPPORT_TICKET_MAX_BODY_LENGTH",
+ "int",
+ "support",
+ "Макс. длина сообщения",
+ "Максимальное количество символов в сообщении тикета.",
+ min=1,
+ ),
+ SettingField(
+ "SUPPORT_TICKET_MAX_SUBJECT_LENGTH",
+ "int",
+ "support",
+ "Макс. длина темы",
+ "Максимальное количество символов в теме тикета.",
+ min=1,
+ ),
+ SettingField(
+ "SUPPORT_TICKET_RATE_LIMIT_PER_HOUR",
+ "int",
+ "support",
+ "Лимит тикетов в час",
+ "Сколько новых тикетов пользователь может создать за час. 0 — без лимита.",
+ min=0,
+ ),
# ─── Devices ───────────────────────────────────────────────────
SettingField("MY_DEVICES_SECTION_ENABLED", "bool", "devices", "Раздел «Мои устройства»"),
SettingField(
@@ -371,12 +439,15 @@ def manifest_payload() -> List[dict]:
"trial": 5,
"referral": 6,
"notifications": 7,
- "devices": 8,
+ "support": 8,
+ "devices": 9,
}
items: List[dict] = []
for field in aggregated_manifest():
- auto_label_i18n_key = f"settings_field_{field.key.lower()}_label"
- auto_description_i18n_key = f"settings_field_{field.key.lower()}_description"
+ auto_label_i18n_key = f"admin_settings_field_{field.key.lower()}_label"
+ auto_description_i18n_key = (
+ f"admin_settings_field_{field.key.lower()}_description"
+ )
default_value: Optional[str] = None
owner = find_manifest_owner(field.key)
diff --git a/backend/bot/app/web/subscription_webapp.py b/backend/bot/app/web/subscription_webapp.py
index 60dce0f..3c2b58e 100644
--- a/backend/bot/app/web/subscription_webapp.py
+++ b/backend/bot/app/web/subscription_webapp.py
@@ -14,6 +14,7 @@ from bot.app.web.webapp import (
payloads as _payloads,
routes as _routes,
serializers as _serializers,
+ support as _support,
)
_MODULES = (
@@ -26,6 +27,7 @@ _MODULES = (
_serializers,
_billing,
_devices,
+ _support,
_routes,
_application,
)
diff --git a/backend/bot/app/web/templates/subscription_webapp.html b/backend/bot/app/web/templates/subscription_webapp.html
index 7e56ab7..1918007 100644
--- a/backend/bot/app/web/templates/subscription_webapp.html
+++ b/backend/bot/app/web/templates/subscription_webapp.html
@@ -1,25 +1,60 @@
+
+
+
+
+
+
+ /minishop
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/bot/app/web/webapp/_runtime.py b/backend/bot/app/web/webapp/_runtime.py
index 59d41f0..93c123c 100644
--- a/backend/bot/app/web/webapp/_runtime.py
+++ b/backend/bot/app/web/webapp/_runtime.py
@@ -51,7 +51,7 @@ from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import parse_ip_entries, request_client_ip
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings
-from db.dal import payment_dal, security_dal, subscription_dal, user_dal
+from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal
from db.dal.user_dal import UserMergeConflictError
from db.models import Payment, User, UserTelegramAvatar
diff --git a/backend/bot/app/web/webapp/application.py b/backend/bot/app/web/webapp/application.py
index 33b66ee..00d7a01 100644
--- a/backend/bot/app/web/webapp/application.py
+++ b/backend/bot/app/web/webapp/application.py
@@ -44,6 +44,9 @@ def create_subscription_webapp_application(
"subscription_service",
"promo_code_service",
"referral_service",
+ "support_service",
+ "notification_service",
+ "email_auth_service",
"panel_service",
*iter_service_keys(),
):
diff --git a/backend/bot/app/web/webapp/assets.py b/backend/bot/app/web/webapp/assets.py
index f23bc0e..75bfab0 100644
--- a/backend/bot/app/web/webapp/assets.py
+++ b/backend/bot/app/web/webapp/assets.py
@@ -15,7 +15,13 @@ async def health_route(request: web.Request) -> web.Response:
async def css_asset_route(request: web.Request) -> web.Response:
- return await _serve_template_asset(request, "subscription_webapp.css", "text/css")
+ asset_hash = request.match_info.get("asset_hash")
+ filename = f"subscription_webapp.{asset_hash}.css" if asset_hash else "subscription_webapp.css"
+ response = await _serve_template_asset(request, filename, "text/css")
+ response.headers["Cache-Control"] = (
+ "public, max-age=31536000, immutable" if asset_hash else "no-cache"
+ )
+ return response
def _safe_theme_css_relative_path(raw_path: str) -> Optional[Path]:
@@ -121,9 +127,7 @@ async def theme_asset_route(request: web.Request) -> web.Response:
query = getattr(request, "query", {})
response = web.Response(body=body, content_type=content_type)
response.headers["Cache-Control"] = (
- "public, max-age=31536000, immutable"
- if query.get("v")
- else "public, max-age=3600"
+ "public, max-age=31536000, immutable" if query.get("v") else "public, max-age=3600"
)
return response
@@ -887,6 +891,41 @@ async def js_asset_route(request: web.Request) -> web.Response:
return response
+WEBAPP_BOOTSTRAP_I18N_PREFIXES = ("wa_",)
+WEBAPP_BOOTSTRAP_I18N_KEYS = {"menu_support_button"}
+WEBAPP_I18N_SCOPES = {"webapp", "admin"}
+
+
+def _is_webapp_bootstrap_i18n_key(key: str) -> bool:
+ return key in WEBAPP_BOOTSTRAP_I18N_KEYS or key.startswith(WEBAPP_BOOTSTRAP_I18N_PREFIXES)
+
+
+def _normalize_i18n_scope(raw_scope: object) -> str:
+ scope = str(raw_scope or "webapp").strip().lower()
+ return scope if scope in WEBAPP_I18N_SCOPES else "webapp"
+
+
+def _filter_webapp_i18n_payload(locales_data: object, scope: str = "webapp") -> Dict[str, Any]:
+ if not isinstance(locales_data, dict):
+ return {}
+
+ normalized_scope = _normalize_i18n_scope(scope)
+ payload: Dict[str, Any] = {}
+ for lang, messages in locales_data.items():
+ if not isinstance(messages, dict):
+ continue
+ filtered: Dict[str, Any] = {}
+ for key, value in messages.items():
+ key_text = str(key)
+ is_bootstrap_key = _is_webapp_bootstrap_i18n_key(key_text)
+ if (normalized_scope == "webapp" and is_bootstrap_key) or (
+ normalized_scope == "admin" and not is_bootstrap_key
+ ):
+ filtered[key_text] = value
+ payload[str(lang)] = filtered
+ return payload
+
+
def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
settings: Settings = request.app["settings"]
cached = _get_cached_webapp_settings(request)
@@ -897,6 +936,8 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
if preview_theme is None or not preview_theme.enabled:
preview_key = ""
i18n_instance: Optional[object] = request.app.get("i18n")
+ i18n_scope = _normalize_i18n_scope(request.query.get("i18n_scope") or "webapp")
+ locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
return {
"config": {
"title": settings.WEBAPP_TITLE,
@@ -929,12 +970,29 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
"appVersion": _resolve_app_version(),
"appRepositoryUrl": APP_REPOSITORY_URL,
},
- "i18n": getattr(i18n_instance, "locales_data", {}) if i18n_instance else {},
+ "i18n": _filter_webapp_i18n_payload(locales_data, i18n_scope),
}
async def bootstrap_route(request: web.Request) -> web.Response:
- return web.json_response({"ok": True, **_build_webapp_bootstrap_payload(request)})
+ response = web.json_response({"ok": True, **_build_webapp_bootstrap_payload(request)})
+ response.headers["Cache-Control"] = "no-cache"
+ return response
+
+
+async def i18n_route(request: web.Request) -> web.Response:
+ i18n_instance: Optional[object] = request.app.get("i18n")
+ scope = _normalize_i18n_scope(request.query.get("scope") or "webapp")
+ locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
+ response = web.json_response(
+ {
+ "ok": True,
+ "scope": scope,
+ "i18n": _filter_webapp_i18n_payload(locales_data, scope),
+ }
+ )
+ response.headers["Cache-Control"] = "no-cache"
+ return response
async def index_route(request: web.Request) -> web.Response:
@@ -950,6 +1008,11 @@ async def index_route(request: web.Request) -> web.Response:
bootstrap = _build_webapp_bootstrap_payload(request)
config = bootstrap["config"]
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
+ html = html.replace(
+ 'href="/subscription_webapp.css"',
+ f'href="/{_resolve_webapp_css_asset_name()}"',
+ 1,
+ )
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
if initial_theme_markup:
html = html.replace("", f"{initial_theme_markup}\n", 1)
@@ -997,7 +1060,9 @@ async def index_route(request: web.Request) -> web.Response:
),
1,
)
- return web.Response(text=html, content_type="text/html", charset="utf-8")
+ response = web.Response(text=html, content_type="text/html", charset="utf-8")
+ response.headers["Cache-Control"] = "no-cache"
+ return response
async def _serve_template_asset(
@@ -1035,6 +1100,21 @@ def _resolve_webapp_js_asset_name() -> str:
return "subscription_webapp.js"
+def _resolve_webapp_css_asset_name() -> str:
+ hashed_assets = []
+ for path in ASSET_DIR.glob("subscription_webapp.*.css"):
+ if not re.fullmatch(r"subscription_webapp\.[0-9a-f]{8}\.css", path.name):
+ continue
+ try:
+ hashed_assets.append((path.stat().st_mtime, path.name))
+ except OSError:
+ continue
+ if hashed_assets:
+ hashed_assets.sort(reverse=True)
+ return hashed_assets[0][1]
+ return "subscription_webapp.css"
+
+
_INITIAL_THEME_TOKEN_CSS_MAP = {
"accent": "--accent",
"bg": "--bg",
diff --git a/backend/bot/app/web/webapp/payloads.py b/backend/bot/app/web/webapp/payloads.py
index 8b0b183..e03221e 100644
--- a/backend/bot/app/web/webapp/payloads.py
+++ b/backend/bot/app/web/webapp/payloads.py
@@ -1,6 +1,8 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
+from typing import Literal
+
class WebAppEmailPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
@@ -69,3 +71,52 @@ class WebAppDeviceDisconnectPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
token: constr(min_length=8, max_length=128)
+
+
+SupportCategory = Literal["billing", "technical", "account", "other"]
+SupportPriority = Literal["low", "normal", "high", "urgent"]
+SupportStatus = Literal["open", "awaiting_user", "awaiting_admin", "resolved", "closed"]
+
+
+class CreateTicketPayload(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+
+ subject: constr(min_length=1, max_length=160)
+ category: SupportCategory = "other"
+ priority: Literal["normal", "high"] = "normal"
+ body: constr(min_length=1, max_length=4000)
+
+ @field_validator("subject", "body")
+ @classmethod
+ def _strip_required_text(cls, value: str) -> str:
+ stripped = value.strip()
+ if not stripped:
+ raise ValueError("empty_text")
+ return stripped
+
+
+class TicketReplyPayload(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+
+ body: constr(min_length=1, max_length=4000)
+
+ @field_validator("body")
+ @classmethod
+ def _strip_body(cls, value: str) -> str:
+ stripped = value.strip()
+ if not stripped:
+ raise ValueError("empty_text")
+ return stripped
+
+
+class AdminTicketReplyPayload(TicketReplyPayload):
+ is_internal_note: bool = False
+
+
+class AdminTicketPatchPayload(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+
+ status: Optional[SupportStatus] = None
+ priority: Optional[SupportPriority] = None
+ category: Optional[SupportCategory] = None
+ assigned_admin_id: Optional[int] = None
diff --git a/backend/bot/app/web/webapp/routes.py b/backend/bot/app/web/webapp/routes.py
index 4ba940b..7fb0b94 100644
--- a/backend/bot/app/web/webapp/routes.py
+++ b/backend/bot/app/web/webapp/routes.py
@@ -9,15 +9,18 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/invite", index_route)
app.router.add_get("/devices", index_route)
app.router.add_get("/settings", index_route)
+ app.router.add_get("/support", index_route)
+ app.router.add_get("/support/{ticket_id:\\d+}", index_route)
app.router.add_get("/admin", index_route)
app.router.add_get(
(
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
- "appearance|settings}"
+ "appearance|settings|support}"
),
index_route,
)
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route)
+ app.router.add_get("/admin/support/{ticket_id:\\d+}", index_route)
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
app.router.add_get("/health", health_route)
@@ -34,6 +37,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
webapp_animated_emoji_route,
)
+ app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route)
app.router.add_get("/subscription_webapp.css", css_asset_route)
app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route)
app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_asset_route)
@@ -47,6 +51,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_post("/api/auth/email/password", email_password_auth_route)
app.router.add_post("/api/auth/logout", logout_route)
app.router.add_get("/api/bootstrap", bootstrap_route)
+ app.router.add_get("/api/i18n", i18n_route)
app.router.add_get("/api/me", me_route)
app.router.add_get("/api/account/avatar", account_avatar_route)
app.router.add_post("/api/account/language", account_language_route)
@@ -60,6 +65,12 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/api/devices", devices_route)
app.router.add_post("/api/devices/disconnect", disconnect_device_route)
app.router.add_get("/api/devices/topup-options", device_topup_options_route)
+ app.router.add_get("/api/support/tickets", support_tickets_route)
+ app.router.add_post("/api/support/tickets", support_create_ticket_route)
+ app.router.add_get("/api/support/tickets/{id:\\d+}", support_ticket_detail_route)
+ app.router.add_post("/api/support/tickets/{id:\\d+}/messages", support_ticket_reply_route)
+ app.router.add_post("/api/support/tickets/{id:\\d+}/read", support_ticket_read_route)
+ app.router.add_get("/api/support/unread", support_unread_route)
app.router.add_get("/api/tariffs/topup-options", tariff_topup_options_route)
app.router.add_get("/api/tariffs/change-options", tariff_change_options_route)
app.router.add_post("/api/tariffs/change", tariff_change_route)
diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py
index 855fd8b..413ca2c 100644
--- a/backend/bot/app/web/webapp/serializers.py
+++ b/backend/bot/app/web/webapp/serializers.py
@@ -38,6 +38,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
if referral_service
else {"invited_count": 0, "purchased_count": 0}
)
+ support_unread_count = (
+ await support_dal.count_user_unread(session, user_id)
+ if settings.SUPPORT_TICKETS_ENABLED
+ else 0
+ )
local_sub = (
await subscription_dal.get_active_subscription_by_user_id(
session,
@@ -106,8 +111,14 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
enabled_only=True,
),
+ "support_unread_count": int(support_unread_count or 0),
"settings": {
"support_url": settings.SUPPORT_LINK,
+ "support_tickets_enabled": bool(settings.SUPPORT_TICKETS_ENABLED),
+ "support_ticket_max_body_length": int(settings.SUPPORT_TICKET_MAX_BODY_LENGTH or 4000),
+ "support_ticket_max_subject_length": int(
+ settings.SUPPORT_TICKET_MAX_SUBJECT_LENGTH or 160
+ ),
"traffic_mode": bool(settings.traffic_sale_mode),
"my_devices_enabled": bool(settings.MY_DEVICES_SECTION_ENABLED),
"user_hwid_device_limit": (
diff --git a/backend/bot/app/web/webapp/support.py b/backend/bot/app/web/webapp/support.py
new file mode 100644
index 0000000..f031018
--- /dev/null
+++ b/backend/bot/app/web/webapp/support.py
@@ -0,0 +1,152 @@
+# ruff: noqa: F401,F403,F405,I001
+from ._runtime import * # noqa: F403,F405
+
+from bot.services.support_service import TicketForbidden, TicketNotFound, TicketRateLimited
+from db.dal import support_dal, user_dal
+from db.models import SupportTicket, SupportTicketMessage
+
+
+def _support_ticket_payload(ticket: SupportTicket) -> Dict[str, Any]:
+ return {
+ "ticket_id": ticket.ticket_id,
+ "user_id": ticket.user_id,
+ "subject": ticket.subject,
+ "category": ticket.category,
+ "priority": ticket.priority,
+ "status": ticket.status,
+ "assigned_admin_id": ticket.assigned_admin_id,
+ "last_message_at": ticket.last_message_at.isoformat() if ticket.last_message_at else None,
+ "last_message_role": ticket.last_message_role,
+ "unread_user_count": int(ticket.unread_user_count or 0),
+ "unread_admin_count": int(ticket.unread_admin_count or 0),
+ "created_at": ticket.created_at.isoformat() if ticket.created_at else None,
+ "updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
+ "closed_at": ticket.closed_at.isoformat() if ticket.closed_at else None,
+ }
+
+
+def _support_message_payload(message: SupportTicketMessage) -> Dict[str, Any]:
+ return {
+ "message_id": message.message_id,
+ "ticket_id": message.ticket_id,
+ "author_role": message.author_role,
+ "author_user_id": message.author_user_id,
+ "body": message.body,
+ "is_internal_note": bool(message.is_internal_note),
+ "created_at": message.created_at.isoformat() if message.created_at else None,
+ "read_by_user_at": message.read_by_user_at.isoformat() if message.read_by_user_at else None,
+ "read_by_admin_at": message.read_by_admin_at.isoformat()
+ if message.read_by_admin_at
+ else None,
+ }
+
+
+def _support_limit_offset(request: web.Request) -> tuple[int, int]:
+ limit = max(1, min(100, int(request.query.get("limit", 25) or 25)))
+ offset = max(0, int(request.query.get("offset", 0) or 0))
+ return limit, offset
+
+
+async def support_tickets_route(request: web.Request) -> web.Response:
+ user_id = _require_user_id(request)
+ limit, offset = _support_limit_offset(request)
+ status_filter = request.query.get("status") or None
+ async_session_factory: sessionmaker = request.app["async_session_factory"]
+ async with async_session_factory() as session:
+ tickets = await support_dal.list_user_tickets(
+ session,
+ user_id,
+ limit=limit,
+ offset=offset,
+ status_filter=status_filter,
+ )
+ counts = await support_dal.user_ticket_counts(session, user_id)
+ return web.json_response(
+ {
+ "ok": True,
+ "tickets": [_support_ticket_payload(t) for t in tickets],
+ "counts": counts,
+ }
+ )
+
+
+async def support_create_ticket_route(request: web.Request) -> web.Response:
+ user_id = _require_user_id(request)
+ payload, error = _validate_model_payload(CreateTicketPayload, await _read_json(request))
+ if error:
+ return error
+ service = request.app["support_service"]
+ try:
+ ticket = await service.create_ticket(
+ user_id,
+ payload.subject,
+ payload.category,
+ payload.priority,
+ payload.body,
+ )
+ except TicketForbidden:
+ return _json_error(403, "ticket_forbidden", "Support ticket action is forbidden")
+ except TicketRateLimited:
+ return _json_error(429, "ticket_rate_limited", "Too many support tickets")
+ return web.json_response({"ok": True, "ticket": _support_ticket_payload(ticket)})
+
+
+async def support_ticket_detail_route(request: web.Request) -> web.Response:
+ user_id = _require_user_id(request)
+ ticket_id = int(request.match_info["id"])
+ async_session_factory: sessionmaker = request.app["async_session_factory"]
+ async with async_session_factory() as session:
+ ticket, messages = await support_dal.get_ticket(session, ticket_id, include_internal=False)
+ if not ticket or ticket.user_id != user_id:
+ return _json_error(404, "not_found", "Ticket not found")
+ return web.json_response(
+ {
+ "ok": True,
+ "ticket": _support_ticket_payload(ticket),
+ "messages": [_support_message_payload(m) for m in messages],
+ }
+ )
+
+
+async def support_ticket_reply_route(request: web.Request) -> web.Response:
+ user_id = _require_user_id(request)
+ ticket_id = int(request.match_info["id"])
+ payload, error = _validate_model_payload(TicketReplyPayload, await _read_json(request))
+ if error:
+ return error
+ service = request.app["support_service"]
+ try:
+ ticket, message = await service.reply_as_user(user_id, ticket_id, payload.body)
+ except TicketForbidden:
+ return _json_error(403, "ticket_forbidden", "Support ticket action is forbidden")
+ except TicketNotFound:
+ return _json_error(404, "not_found", "Ticket not found")
+ return web.json_response(
+ {
+ "ok": True,
+ "ticket": _support_ticket_payload(ticket),
+ "message": _support_message_payload(message),
+ }
+ )
+
+
+async def support_ticket_read_route(request: web.Request) -> web.Response:
+ user_id = _require_user_id(request)
+ ticket_id = int(request.match_info["id"])
+ service = request.app["support_service"]
+ try:
+ await service.mark_read_as_user(user_id, ticket_id)
+ except TicketNotFound:
+ return _json_error(404, "not_found", "Ticket not found")
+ return web.json_response({"ok": True})
+
+
+async def support_unread_route(request: web.Request) -> web.Response:
+ user_id = _require_user_id(request)
+ async_session_factory: sessionmaker = request.app["async_session_factory"]
+ async with async_session_factory() as session:
+ user = await user_dal.get_user_by_id(session, user_id)
+ if user and user.is_banned:
+ return _json_error(403, "ticket_forbidden", "Support ticket action is forbidden")
+ unread = await support_dal.count_user_unread(session, user_id)
+ return web.json_response({"ok": True, "unread": unread})
diff --git a/backend/bot/handlers/user/start.py b/backend/bot/handlers/user/start.py
index 7918e9d..4233a07 100644
--- a/backend/bot/handlers/user/start.py
+++ b/backend/bot/handlers/user/start.py
@@ -387,11 +387,12 @@ async def ensure_required_channel_subscription(
)
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
+@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
@router.message(
CommandStart(
magic=F.args.regexp(
- r"^(?!ref_|promo_|admin_user_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
+ r"^(?!ref_|promo_|admin_user_|ticket_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
).as_("ad_param_match")
)
)
@@ -408,6 +409,7 @@ async def start_command_handler(
page_ref_match: Optional[re.Match] = None,
ad_param_match: Optional[re.Match] = None,
admin_user_match: Optional[re.Match] = None,
+ ticket_match: Optional[re.Match] = None,
):
await state.clear()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
@@ -465,6 +467,31 @@ async def start_command_handler(
await message.answer(_("admin_user_card_error"))
return
+ if ticket_match:
+ ticket_id = int(ticket_match.group(1))
+ base_url = (settings.SUBSCRIPTION_MINI_APP_URL or "").strip()
+ if base_url:
+ ticket_url = f"{base_url.rstrip('/')}/support/{ticket_id}"
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=i18n.gettext(current_lang, "wa_support_open_ticket")
+ if i18n
+ else "Открыть тикет",
+ web_app=types.WebAppInfo(url=ticket_url),
+ )
+ ]
+ ]
+ )
+ await message.answer(
+ i18n.gettext(current_lang, "wa_support_open_ticket_hint")
+ if i18n
+ else "Откройте тикет в Mini App.",
+ reply_markup=keyboard,
+ )
+ return
+
referred_by_user_id: Optional[int] = None
promo_code_to_apply: Optional[str] = None
should_open_referral_from_start = False
diff --git a/backend/bot/main_bot.py b/backend/bot/main_bot.py
index 08ce803..62c1ef4 100644
--- a/backend/bot/main_bot.py
+++ b/backend/bot/main_bot.py
@@ -196,6 +196,9 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
"promo_code_service",
"subscription_service",
"referral_service",
+ "support_service",
+ "notification_service",
+ "email_auth_service",
*iter_service_keys(),
):
await close_service(service_key)
diff --git a/backend/bot/services/email_templates.py b/backend/bot/services/email_templates.py
index afe32b8..a9cc5d9 100644
--- a/backend/bot/services/email_templates.py
+++ b/backend/bot/services/email_templates.py
@@ -492,3 +492,155 @@ def render_subscription_expiring(
footer_html=footer,
)
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
+
+
+def _support_email(
+ settings: Settings,
+ i18n: Optional[JsonI18n],
+ language: Optional[str],
+ *,
+ subject: str,
+ heading: str,
+ intro: str,
+ rows: Sequence[Tuple[str, str]],
+ body_preview: str,
+ ticket_url: Optional[str],
+ cta_label: str,
+) -> EmailContent:
+ lang = _normalize_lang(language, settings)
+ brand = _brand_title(settings)
+ accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
+ safe_url = (ticket_url or "").strip()
+ footer = _t_html(_resolve_i18n(i18n), lang, "email_footer_auto", brand=brand)
+ preview_block = (
+ f'{html.escape(body_preview or "")}
'
+ )
+ body_parts = [_info_rows_html(rows), preview_block]
+ if safe_url:
+ body_parts.append(_cta_button_html(label=cta_label, url=safe_url, accent=accent))
+ rendered = _layout(
+ settings=settings,
+ preheader=intro,
+ heading=heading,
+ intro_html=html.escape(intro),
+ body_html="".join(body_parts),
+ footer_html=footer,
+ )
+ text_lines = [intro, "", *[f"{label}: {value}" for label, value in rows], "", body_preview]
+ if safe_url:
+ text_lines.extend(["", safe_url])
+ return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
+
+
+def render_support_new_ticket_admin(
+ settings: Settings,
+ i18n: Optional[JsonI18n],
+ language: Optional[str],
+ *,
+ ticket_id: int,
+ user_display: str,
+ subject: str,
+ body_preview: str,
+ snapshot_rows: Sequence[Tuple[str, str]],
+ ticket_url: Optional[str],
+) -> EmailContent:
+ rows = [
+ ("Ticket", f"#{ticket_id}"),
+ ("User", user_display),
+ ("Subject", subject),
+ *snapshot_rows,
+ ]
+ return _support_email(
+ settings,
+ i18n,
+ language,
+ subject=f"New support ticket #{ticket_id}",
+ heading=f"New support ticket #{ticket_id}",
+ intro="A user opened a new support ticket.",
+ rows=rows,
+ body_preview=body_preview,
+ ticket_url=ticket_url,
+ cta_label="Open ticket",
+ )
+
+
+def render_support_user_reply_admin(
+ settings: Settings,
+ i18n: Optional[JsonI18n],
+ language: Optional[str],
+ *,
+ ticket_id: int,
+ user_display: str,
+ subject: str,
+ body_preview: str,
+ snapshot_rows: Sequence[Tuple[str, str]],
+ ticket_url: Optional[str],
+) -> EmailContent:
+ rows = [
+ ("Ticket", f"#{ticket_id}"),
+ ("User", user_display),
+ ("Subject", subject),
+ *snapshot_rows,
+ ]
+ return _support_email(
+ settings,
+ i18n,
+ language,
+ subject=f"New user reply in ticket #{ticket_id}",
+ heading=f"User replied in ticket #{ticket_id}",
+ intro="A user sent a new support message.",
+ rows=rows,
+ body_preview=body_preview,
+ ticket_url=ticket_url,
+ cta_label="Open ticket",
+ )
+
+
+def render_support_admin_reply_user(
+ settings: Settings,
+ i18n: Optional[JsonI18n],
+ language: Optional[str],
+ *,
+ ticket_id: int,
+ subject: str,
+ body_preview: str,
+ ticket_url: Optional[str],
+) -> EmailContent:
+ return _support_email(
+ settings,
+ i18n,
+ language,
+ subject=f"New reply for ticket #{ticket_id}",
+ heading=f"New reply for ticket #{ticket_id}",
+ intro="Support has replied to your ticket.",
+ rows=[("Ticket", f"#{ticket_id}"), ("Subject", subject)],
+ body_preview=body_preview,
+ ticket_url=ticket_url,
+ cta_label="Open in Mini App",
+ )
+
+
+def render_support_ticket_closed_user(
+ settings: Settings,
+ i18n: Optional[JsonI18n],
+ language: Optional[str],
+ *,
+ ticket_id: int,
+ subject: str,
+ body_preview: str = "",
+ ticket_url: Optional[str],
+) -> EmailContent:
+ return _support_email(
+ settings,
+ i18n,
+ language,
+ subject=f"Ticket #{ticket_id} was closed",
+ heading=f"Ticket #{ticket_id} was closed",
+ intro="Your support ticket has been closed.",
+ rows=[("Ticket", f"#{ticket_id}"), ("Subject", subject)],
+ body_preview=body_preview or "The ticket is closed.",
+ ticket_url=ticket_url,
+ cta_label="Open in Mini App",
+ )
diff --git a/backend/bot/services/notification_service.py b/backend/bot/services/notification_service.py
index ef81724..7febc58 100644
--- a/backend/bot/services/notification_service.py
+++ b/backend/bot/services/notification_service.py
@@ -4,10 +4,18 @@ from typing import Any, Callable, Dict, Optional
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest
-from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
+from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
from aiogram.utils.text_decorations import html_decoration as hd
from bot.middlewares.i18n import JsonI18n
+from bot.services.email_auth_service import EmailAuthService
+from bot.services.email_templates import (
+ render_support_admin_reply_user,
+ render_support_new_ticket_admin,
+ render_support_ticket_closed_user,
+ render_support_user_reply_admin,
+)
+from bot.utils import MessageContent, send_message_via_queue
from bot.utils.message_queue import get_queue_manager
from bot.utils.telegram_markup import (
is_profile_link_error,
@@ -18,15 +26,30 @@ from bot.utils.text_sanitizer import (
username_for_display,
)
from config.settings import Settings
+from db.dal import app_settings_dal, user_dal
+
+SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_KEY = "SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED"
class NotificationService:
"""Enhanced notification service for sending messages to admins and log channels"""
- def __init__(self, bot: Bot, settings: Settings, i18n: Optional[JsonI18n] = None):
+ def __init__(
+ self,
+ bot: Bot,
+ settings: Settings,
+ i18n: Optional[JsonI18n] = None,
+ *,
+ session_factory=None,
+ email_auth_service: Optional[EmailAuthService] = None,
+ bot_username: Optional[str] = None,
+ ):
self.bot = bot
self.settings = settings
self.i18n = i18n
+ self.session_factory = session_factory
+ self.email_auth_service = email_auth_service
+ self.bot_username = bot_username or ""
@staticmethod
def _format_user_display(
@@ -149,7 +172,11 @@ class NotificationService:
"Failed to queue notification to log channel %s.", self.settings.LOG_CHAT_ID
)
- async def _send_to_admins(self, message: str):
+ async def _send_to_admins(
+ self,
+ message: str,
+ reply_markup: Optional[InlineKeyboardMarkup] = None,
+ ):
"""Send message to all admin users using message queue"""
if not self.settings.ADMIN_IDS:
return
@@ -164,6 +191,7 @@ class NotificationService:
text=message,
parse_mode="HTML",
disable_web_page_preview=True,
+ reply_markup=reply_markup,
)
except Exception:
logging.exception("Failed to send notification to admin %s.", admin_id)
@@ -172,11 +200,343 @@ class NotificationService:
for admin_id in self.settings.ADMIN_IDS:
try:
await queue_manager.send_message(
- chat_id=admin_id, text=message, parse_mode="HTML", disable_web_page_preview=True
+ chat_id=admin_id,
+ text=message,
+ parse_mode="HTML",
+ disable_web_page_preview=True,
+ reply_markup=reply_markup,
)
except Exception:
logging.exception("Failed to queue notification to admin %s.", admin_id)
+ def _support_webapp_url(self, path: str) -> Optional[str]:
+ base_url = str(getattr(self.settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
+ if not base_url:
+ return None
+ normalized_path = f"/{str(path or '').lstrip('/')}"
+ return f"{base_url.rstrip('/')}{normalized_path}"
+
+ def _support_ticket_url(self, ticket_id: int, *, admin: bool = True) -> str:
+ path = f"/admin/support/{ticket_id}" if admin else f"/support/{ticket_id}"
+ webapp_url = self._support_webapp_url(path)
+ if webapp_url:
+ return webapp_url
+ bot_username = self.bot_username.strip().lstrip("@")
+ if bot_username:
+ return f"https://t.me/{bot_username}?startapp=ticket_{ticket_id}"
+ return "https://t.me/"
+
+ def _support_mini_app_button(
+ self,
+ *,
+ text: str,
+ path: str,
+ fallback_url: str,
+ ) -> InlineKeyboardButton:
+ webapp_url = self._support_webapp_url(path)
+ if webapp_url:
+ return InlineKeyboardButton(text=text, web_app=WebAppInfo(url=webapp_url))
+ return InlineKeyboardButton(text=text, url=fallback_url)
+
+ def _support_text(self, language: Optional[str], key: str, fallback: str) -> str:
+ if not self.i18n:
+ return fallback
+ return self.i18n.gettext(language or self.settings.DEFAULT_LANGUAGE, key) or fallback
+
+ @staticmethod
+ def _support_preview(body: str, limit: int = 700) -> str:
+ text = (body or "").strip()
+ return text if len(text) <= limit else f"{text[: limit - 1]}…"
+
+ @staticmethod
+ def _support_user_display(user) -> str:
+ name = " ".join(
+ part for part in [user.first_name, getattr(user, "last_name", None)] if part
+ )
+ if user.username:
+ return f"{name or user.username} (@{user.username})"
+ return name or getattr(user, "email", None) or f"ID {user.user_id}"
+
+ @staticmethod
+ def _support_snapshot_rows(snapshot: Optional[dict]) -> list[tuple[str, str]]:
+ if not snapshot:
+ return []
+ rows = []
+ for key, label in (
+ ("tariff", "Tariff"),
+ ("end_date", "End date"),
+ ("remaining", "Remaining"),
+ ("panel_status", "Panel status"),
+ ):
+ value = snapshot.get(key)
+ if value:
+ rows.append((label, str(value)))
+ return rows
+
+ @staticmethod
+ def _coerce_bool_setting(value: Any, default: bool) -> bool:
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, (int, float)):
+ return bool(value)
+ if isinstance(value, str):
+ normalized = value.strip().lower()
+ if normalized in {"1", "true", "yes", "on"}:
+ return True
+ if normalized in {"0", "false", "no", "off"}:
+ return False
+ return bool(value)
+
+ async def support_admin_email_notifications_enabled(self) -> bool:
+ enabled = bool(
+ getattr(self.settings, SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_KEY, False)
+ )
+ if not self.session_factory:
+ return enabled
+ try:
+ async with self.session_factory() as session:
+ found, raw_value = await app_settings_dal.get_override_value(
+ session,
+ SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_KEY,
+ )
+ except Exception:
+ logging.exception("Failed to read support admin email notification override.")
+ return enabled
+ if not found:
+ return enabled
+ return self._coerce_bool_setting(raw_value, enabled)
+
+ def _support_keyboard(self, ticket, user, *, admin: bool = True) -> InlineKeyboardMarkup:
+ ticket_path = (
+ f"/admin/support/{ticket.ticket_id}" if admin else f"/support/{ticket.ticket_id}"
+ )
+ rows = [
+ [
+ self._support_mini_app_button(
+ text="Открыть тикет",
+ path=ticket_path,
+ fallback_url=self._support_ticket_url(ticket.ticket_id, admin=admin),
+ )
+ ]
+ ]
+ if admin:
+ profile_row = []
+ if getattr(user, "user_id", 0) and int(user.user_id) > 0:
+ profile_row.append(
+ InlineKeyboardButton(text="Профиль", url=f"tg://user?id={user.user_id}")
+ )
+ user_card_path = f"/admin/users/{user.user_id}"
+ if self._support_webapp_url(user_card_path):
+ profile_row.append(
+ self._support_mini_app_button(
+ text="Карточка пользователя",
+ path=user_card_path,
+ fallback_url=self._support_ticket_url(ticket.ticket_id, admin=True),
+ )
+ )
+ if profile_row:
+ rows.append(profile_row)
+ return InlineKeyboardMarkup(inline_keyboard=rows)
+
+ def _support_user_keyboard(self, ticket, user) -> InlineKeyboardMarkup:
+ button_text = self._support_text(
+ getattr(user, "language_code", None),
+ "wa_support_open_ticket",
+ "Открыть тикет",
+ )
+ webapp_url = self._support_webapp_url(f"/support/{ticket.ticket_id}")
+ if webapp_url:
+ button = InlineKeyboardButton(text=button_text, web_app=WebAppInfo(url=webapp_url))
+ else:
+ button = InlineKeyboardButton(
+ text=button_text,
+ url=self._support_ticket_url(ticket.ticket_id, admin=False),
+ )
+ return InlineKeyboardMarkup(inline_keyboard=[[button]])
+
+ async def _admin_email_users(self):
+ if not self.session_factory:
+ return []
+ async with self.session_factory() as session:
+ users = []
+ for admin_id in self.settings.ADMIN_IDS:
+ user = await user_dal.get_user_by_id(session, int(admin_id))
+ if user and user.email:
+ users.append(user)
+ return users
+
+ async def _send_admin_support_email(self, renderer, **kwargs) -> None:
+ if not await self.support_admin_email_notifications_enabled():
+ return
+ if not self.email_auth_service:
+ return
+ for admin in await self._admin_email_users():
+ try:
+ content = renderer(
+ self.settings,
+ self.i18n,
+ getattr(admin, "language_code", None) or self.settings.DEFAULT_LANGUAGE,
+ **kwargs,
+ )
+ await self.email_auth_service.send_rendered_email(
+ email=admin.email,
+ content=content,
+ )
+ except Exception:
+ logging.exception("Failed to send support email to admin %s.", admin.user_id)
+
+ async def notify_new_support_ticket(self, ticket, user, first_message: str, snapshot: dict):
+ if not getattr(self.settings, "LOG_SUPPORT", True):
+ return
+ priority_emoji = {"low": "🟢", "normal": "🟡", "high": "🟠", "urgent": "🔴"}.get(
+ ticket.priority,
+ "🟡",
+ )
+ preview = self._support_preview(first_message)
+ user_display = self._support_user_display(user)
+ message = (
+ f"🆘 Новый тикет #{ticket.ticket_id}\n"
+ f"{priority_emoji} {hd.quote(ticket.priority)} · {hd.quote(ticket.category)}\n\n"
+ f"Пользователь\n{hd.quote(user_display)}\nID: {user.user_id}\n\n"
+ f"Подписка\n"
+ f"{hd.quote(str(snapshot.get('tariff') or '—'))}, "
+ f"до {hd.quote(str(snapshot.get('end_date') or '—'))}, "
+ f"осталось {hd.quote(str(snapshot.get('remaining') or '—'))}\n"
+ f"статус: {hd.quote(str(snapshot.get('panel_status') or '—'))}\n\n"
+ f"Текст обращения\n{hd.quote(preview)}"
+ )
+ keyboard = self._support_keyboard(ticket, user, admin=True)
+ await self._send_to_admins(message, reply_markup=keyboard)
+ await self._send_to_log_channel(
+ message,
+ thread_id=getattr(self.settings, "LOG_SUPPORT_THREAD_ID", None),
+ reply_markup=keyboard,
+ )
+ await self._send_admin_support_email(
+ render_support_new_ticket_admin,
+ ticket_id=ticket.ticket_id,
+ user_display=user_display,
+ subject=ticket.subject,
+ body_preview=preview,
+ snapshot_rows=self._support_snapshot_rows(snapshot),
+ ticket_url=self._support_ticket_url(ticket.ticket_id, admin=True),
+ )
+
+ async def notify_support_user_reply(
+ self,
+ ticket,
+ message,
+ user,
+ snapshot: dict,
+ *,
+ unread_count: Optional[int] = None,
+ send_telegram: bool = True,
+ send_email: bool = True,
+ ):
+ if not getattr(self.settings, "LOG_SUPPORT", True):
+ return
+ preview = self._support_preview(message.body)
+ user_display = self._support_user_display(user)
+ unread_line = (
+ f"\nUnread: {int(unread_count)}"
+ if unread_count is not None and int(unread_count or 0) > 1
+ else ""
+ )
+ text = (
+ f"💬 Ответ пользователя в тикете #{ticket.ticket_id}\n"
+ f"{hd.quote(user_display)}{unread_line}\n\n{hd.quote(preview)}"
+ )
+ keyboard = self._support_keyboard(ticket, user, admin=True)
+ if send_telegram and getattr(self.settings, "LOG_SUPPORT", True):
+ await self._send_to_admins(text, reply_markup=keyboard)
+ await self._send_to_log_channel(
+ text,
+ thread_id=getattr(self.settings, "LOG_SUPPORT_THREAD_ID", None),
+ reply_markup=keyboard,
+ )
+ if send_email:
+ await self._send_admin_support_email(
+ render_support_user_reply_admin,
+ ticket_id=ticket.ticket_id,
+ user_display=user_display,
+ subject=ticket.subject,
+ body_preview=preview,
+ snapshot_rows=self._support_snapshot_rows(snapshot),
+ ticket_url=self._support_ticket_url(ticket.ticket_id, admin=True),
+ )
+
+ async def notify_support_admin_reply(self, ticket, message, user):
+ preview = self._support_preview(message.body, limit=500)
+ url = self._support_ticket_url(ticket.ticket_id, admin=False)
+ text = f"💬 Новый ответ по тикету #{ticket.ticket_id}\n\n{hd.quote(preview)}"
+ keyboard = self._support_user_keyboard(ticket, user)
+ if int(user.user_id) > 0:
+ queue_manager = get_queue_manager()
+ if queue_manager:
+ await send_message_via_queue(
+ queue_manager,
+ int(user.user_id),
+ MessageContent(content_type="text", text=text),
+ parse_mode="HTML",
+ disable_web_page_preview=True,
+ reply_markup=keyboard,
+ )
+ else:
+ await self.bot.send_message(
+ chat_id=int(user.user_id),
+ text=text,
+ parse_mode="HTML",
+ disable_web_page_preview=True,
+ reply_markup=keyboard,
+ )
+ if self.email_auth_service and getattr(user, "email", None):
+ content = render_support_admin_reply_user(
+ self.settings,
+ self.i18n,
+ getattr(user, "language_code", None),
+ ticket_id=ticket.ticket_id,
+ subject=ticket.subject,
+ body_preview=preview,
+ ticket_url=url,
+ )
+ await self.email_auth_service.send_rendered_email(email=user.email, content=content)
+
+ async def notify_support_ticket_closed(self, ticket, user, closing_admin):
+ url = self._support_ticket_url(ticket.ticket_id, admin=False)
+ text = f"✅ Тикет #{ticket.ticket_id} закрыт\n\n{hd.quote(ticket.subject)}"
+ keyboard = self._support_user_keyboard(ticket, user)
+ if int(user.user_id) > 0:
+ queue_manager = get_queue_manager()
+ if queue_manager:
+ await send_message_via_queue(
+ queue_manager,
+ int(user.user_id),
+ MessageContent(content_type="text", text=text),
+ parse_mode="HTML",
+ disable_web_page_preview=True,
+ reply_markup=keyboard,
+ )
+ else:
+ await self.bot.send_message(
+ chat_id=int(user.user_id),
+ text=text,
+ parse_mode="HTML",
+ disable_web_page_preview=True,
+ reply_markup=keyboard,
+ )
+ if self.email_auth_service and getattr(user, "email", None):
+ content = render_support_ticket_closed_user(
+ self.settings,
+ self.i18n,
+ getattr(user, "language_code", None),
+ ticket_id=ticket.ticket_id,
+ subject=ticket.subject,
+ ticket_url=url,
+ )
+ await self.email_auth_service.send_rendered_email(email=user.email, content=content)
+
async def notify_new_user_registration(
self,
user_id: int,
diff --git a/backend/bot/services/support_service.py b/backend/bot/services/support_service.py
new file mode 100644
index 0000000..189d7eb
--- /dev/null
+++ b/backend/bot/services/support_service.py
@@ -0,0 +1,465 @@
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Any, Optional
+
+from aiogram import Bot
+from sqlalchemy.orm import sessionmaker
+
+from bot.middlewares.i18n import JsonI18n
+from bot.services.email_auth_service import EmailAuthService
+from bot.services.notification_service import NotificationService
+from config.settings import Settings
+from db.dal import message_log_dal, subscription_dal, support_dal, user_dal
+from db.models import SupportTicket, SupportTicketMessage, User
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class AdminNotificationDecision:
+ send_telegram: bool
+ send_email: bool
+
+
+def _as_utc(value: Optional[datetime]) -> Optional[datetime]:
+ if value is None:
+ return None
+ if value.tzinfo is None:
+ return value.replace(tzinfo=timezone.utc)
+ return value.astimezone(timezone.utc)
+
+
+def _notification_due(
+ last_sent_at: Optional[datetime],
+ *,
+ now: datetime,
+ cooldown_seconds: int,
+) -> bool:
+ last_sent_at = _as_utc(last_sent_at)
+ if last_sent_at is None:
+ return True
+ cooldown = max(0, int(cooldown_seconds or 0))
+ if cooldown <= 0:
+ return True
+ return (now - last_sent_at).total_seconds() >= cooldown
+
+
+def _support_admin_notification_decision(
+ ticket: SupportTicket,
+ settings: Settings,
+ *,
+ now: Optional[datetime] = None,
+ admin_email_notifications_enabled: Optional[bool] = None,
+) -> AdminNotificationDecision:
+ now = _as_utc(now) or datetime.now(timezone.utc)
+ unread_count = max(0, int(getattr(ticket, "unread_admin_count", 0) or 0))
+ if unread_count <= 0:
+ return AdminNotificationDecision(send_telegram=False, send_email=False)
+
+ first_unread = unread_count <= 1
+ send_telegram = first_unread or _notification_due(
+ getattr(ticket, "admin_last_notified_at", None),
+ now=now,
+ cooldown_seconds=getattr(settings, "SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS", 300),
+ )
+ if admin_email_notifications_enabled is None:
+ admin_email_notifications_enabled = bool(
+ getattr(settings, "SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED", False)
+ )
+ send_email = bool(admin_email_notifications_enabled) and (
+ first_unread
+ or _notification_due(
+ getattr(ticket, "admin_last_emailed_at", None),
+ now=now,
+ cooldown_seconds=getattr(settings, "SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS", 1800),
+ )
+ )
+ return AdminNotificationDecision(send_telegram=send_telegram, send_email=send_email)
+
+
+def _format_support_remaining(seconds: int, lang: str) -> str:
+ if seconds <= 0:
+ return "Subscription inactive" if lang == "en" else "Подписка не активна"
+ days, rem = divmod(seconds, 86400)
+ hours, rem = divmod(rem, 3600)
+ minutes = rem // 60
+ if lang == "en":
+ if days > 0:
+ return f"{days} d. {hours} h."
+ if hours > 0:
+ return f"{hours} h. {minutes} min."
+ return f"{max(1, minutes)} min."
+ if days > 0:
+ return f"{days} д. {hours} ч."
+ if hours > 0:
+ return f"{hours} ч. {minutes} мин."
+ return f"{max(1, minutes)} мин."
+
+
+class TicketForbidden(PermissionError):
+ pass
+
+
+class TicketRateLimited(RuntimeError):
+ pass
+
+
+class TicketNotFound(LookupError):
+ pass
+
+
+class SupportService:
+ def __init__(
+ self,
+ session_factory: sessionmaker,
+ settings: Settings,
+ bot: Bot,
+ i18n: Optional[JsonI18n],
+ notification_service: Optional[NotificationService] = None,
+ email_auth_service: Optional[EmailAuthService] = None,
+ ):
+ self.session_factory = session_factory
+ self.settings = settings
+ self.bot = bot
+ self.i18n = i18n
+ self.email_auth_service = email_auth_service or EmailAuthService(settings)
+ self.notification_service = notification_service or NotificationService(
+ bot,
+ settings,
+ i18n,
+ session_factory=session_factory,
+ email_auth_service=self.email_auth_service,
+ )
+
+ async def _ensure_user_allowed(self, session, user_id: int) -> User:
+ user = await user_dal.get_user_by_id(session, user_id)
+ if not user or user.is_banned or not self.settings.SUPPORT_TICKETS_ENABLED:
+ raise TicketForbidden("ticket_forbidden")
+ return user
+
+ async def create_ticket(
+ self,
+ user_id: int,
+ subject: str,
+ category: str,
+ priority: str,
+ first_message_body: str,
+ ) -> SupportTicket:
+ async with self.session_factory() as session:
+ user = await self._ensure_user_allowed(session, user_id)
+ limit = max(0, int(self.settings.SUPPORT_TICKET_RATE_LIMIT_PER_HOUR or 0))
+ if limit:
+ recent = await support_dal.count_recent_tickets_for_user(session, user_id, 3600)
+ if recent >= limit:
+ raise TicketRateLimited("ticket_rate_limited")
+ ticket = await support_dal.create_ticket(
+ session,
+ user_id,
+ subject[: self.settings.SUPPORT_TICKET_MAX_SUBJECT_LENGTH],
+ category,
+ priority,
+ first_message_body[: self.settings.SUPPORT_TICKET_MAX_BODY_LENGTH],
+ )
+ snapshot = await self.build_user_snapshot(user, session=session)
+ await session.commit()
+
+ try:
+ await self.notification_service.notify_new_support_ticket(
+ ticket,
+ user,
+ first_message_body,
+ snapshot,
+ )
+ except Exception:
+ logger.exception("Failed to notify about support ticket %s", ticket.ticket_id)
+ return ticket
+
+ async def reply_as_user(
+ self,
+ user_id: int,
+ ticket_id: int,
+ body: str,
+ ) -> tuple[SupportTicket, SupportTicketMessage]:
+ async with self.session_factory() as session:
+ user = await self._ensure_user_allowed(session, user_id)
+ ticket, _messages = await support_dal.get_ticket(session, ticket_id)
+ if not ticket or ticket.user_id != user_id:
+ raise TicketNotFound("not_found")
+ message = await support_dal.add_message(
+ session,
+ ticket_id,
+ "user",
+ user_id,
+ body[: self.settings.SUPPORT_TICKET_MAX_BODY_LENGTH],
+ )
+ if message is None:
+ raise TicketNotFound("not_found")
+ await session.refresh(ticket)
+ notification_at = datetime.now(timezone.utc)
+ admin_email_notifications_enabled = (
+ await self.notification_service.support_admin_email_notifications_enabled()
+ )
+ notification_decision = _support_admin_notification_decision(
+ ticket,
+ self.settings,
+ now=notification_at,
+ admin_email_notifications_enabled=admin_email_notifications_enabled,
+ )
+ await support_dal.record_admin_notification(
+ session,
+ ticket_id,
+ notified_at=notification_at if notification_decision.send_telegram else None,
+ emailed_at=notification_at if notification_decision.send_email else None,
+ )
+ snapshot = await self.build_user_snapshot(user, session=session)
+ await session.commit()
+
+ if notification_decision.send_telegram or notification_decision.send_email:
+ try:
+ await self.notification_service.notify_support_user_reply(
+ ticket,
+ message,
+ user,
+ snapshot,
+ unread_count=int(ticket.unread_admin_count or 0),
+ send_telegram=notification_decision.send_telegram,
+ send_email=notification_decision.send_email,
+ )
+ except Exception:
+ logger.exception("Failed to notify about support user reply %s", ticket_id)
+ return ticket, message
+
+ async def reply_as_admin(
+ self,
+ admin_id: int,
+ ticket_id: int,
+ body: str,
+ *,
+ is_internal_note: bool = False,
+ ) -> tuple[SupportTicket, SupportTicketMessage]:
+ async with self.session_factory() as session:
+ ticket, _messages = await support_dal.get_ticket(
+ session,
+ ticket_id,
+ include_internal=True,
+ )
+ if not ticket:
+ raise TicketNotFound("not_found")
+ user = await user_dal.get_user_by_id(session, ticket.user_id)
+ message = await support_dal.add_message(
+ session,
+ ticket_id,
+ "admin",
+ admin_id,
+ body[: self.settings.SUPPORT_TICKET_MAX_BODY_LENGTH],
+ is_internal_note=is_internal_note,
+ )
+ if message is None:
+ raise TicketNotFound("not_found")
+ await support_dal.mark_read(session, ticket_id, "admin")
+ if not is_internal_note:
+ await message_log_dal.create_message_log_no_commit(
+ session,
+ {
+ "user_id": admin_id,
+ "event_type": "support_admin_reply",
+ "content": f"Support ticket #{ticket_id} reply",
+ "is_admin_event": True,
+ "target_user_id": ticket.user_id,
+ },
+ )
+ await session.refresh(ticket)
+ await session.commit()
+
+ if user and not is_internal_note:
+ try:
+ await self.notification_service.notify_support_admin_reply(ticket, message, user)
+ except Exception:
+ logger.exception("Failed to notify user about support admin reply %s", ticket_id)
+ return ticket, message
+
+ async def change_status(self, admin_id: int, ticket_id: int, status: str) -> SupportTicket:
+ return await self._update_and_audit(admin_id, ticket_id, status=status)
+
+ async def change_priority(self, admin_id: int, ticket_id: int, priority: str) -> SupportTicket:
+ return await self._update_and_audit(admin_id, ticket_id, priority=priority)
+
+ async def change_category(self, admin_id: int, ticket_id: int, category: str) -> SupportTicket:
+ return await self._update_and_audit(admin_id, ticket_id, category=category)
+
+ async def assign_admin(
+ self,
+ admin_id: int,
+ ticket_id: int,
+ assigned_admin_id: Optional[int],
+ ) -> SupportTicket:
+ return await self._update_and_audit(
+ admin_id,
+ ticket_id,
+ assigned_admin_id=assigned_admin_id,
+ )
+
+ async def close_ticket(self, admin_id: int, ticket_id: int) -> SupportTicket:
+ ticket = await self._update_and_audit(
+ admin_id,
+ ticket_id,
+ status="closed",
+ closed_by_admin_id=admin_id,
+ )
+ async with self.session_factory() as session:
+ user = await user_dal.get_user_by_id(session, ticket.user_id)
+ if user:
+ try:
+ await self.notification_service.notify_support_ticket_closed(ticket, user, admin_id)
+ except Exception:
+ logger.exception("Failed to notify user about support close %s", ticket_id)
+ return ticket
+
+ async def _update_and_audit(
+ self,
+ admin_id: int,
+ ticket_id: int,
+ **updates: Any,
+ ) -> SupportTicket:
+ async with self.session_factory() as session:
+ ticket = await support_dal.update_ticket(session, ticket_id, **updates)
+ if not ticket:
+ raise TicketNotFound("not_found")
+ await message_log_dal.create_message_log_no_commit(
+ session,
+ {
+ "user_id": admin_id,
+ "event_type": "support_ticket_update",
+ "content": f"Support ticket #{ticket_id}: {updates}",
+ "is_admin_event": True,
+ "target_user_id": ticket.user_id,
+ },
+ )
+ await session.commit()
+ return ticket
+
+ async def mark_read_as_user(self, user_id: int, ticket_id: int) -> None:
+ async with self.session_factory() as session:
+ ticket, _messages = await support_dal.get_ticket(session, ticket_id)
+ if not ticket or ticket.user_id != user_id:
+ raise TicketNotFound("not_found")
+ await support_dal.mark_read(session, ticket_id, "user")
+ await session.commit()
+
+ async def mark_read_as_admin(self, ticket_id: int) -> None:
+ async with self.session_factory() as session:
+ await support_dal.mark_read(session, ticket_id, "admin")
+ await session.commit()
+
+ async def build_user_snapshot(self, user: User, *, session=None) -> dict:
+ owns_session = session is None
+ if owns_session:
+ session = self.session_factory()
+ await session.__aenter__()
+ try:
+ sub = None
+ if getattr(user, "panel_user_uuid", None):
+ sub = await subscription_dal.get_active_subscription_by_user_id(
+ session,
+ int(user.user_id),
+ user.panel_user_uuid,
+ )
+ lang = (
+ getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE or "ru"
+ ).split("-")[0]
+ tariff_name = ""
+ if sub and getattr(sub, "tariff_key", None) and self.settings.tariffs_config:
+ try:
+ tariff = self.settings.tariffs_config.require(str(sub.tariff_key))
+ tariff_name = tariff.name(lang)
+ except Exception:
+ tariff_name = str(sub.tariff_key or "")
+ end_date = getattr(sub, "end_date", None)
+ seconds_left = (
+ max(0, int((end_date - datetime.now(timezone.utc)).total_seconds()))
+ if end_date
+ else 0
+ )
+ return {
+ "user_id": int(user.user_id),
+ "name": " ".join(
+ part for part in [user.first_name, getattr(user, "last_name", None)] if part
+ )
+ or user.username
+ or str(user.user_id),
+ "username": user.username,
+ "email": user.email,
+ "telegram_id": user.telegram_id,
+ "language": lang,
+ "registration_date": (
+ user.registration_date.isoformat() if user.registration_date else None
+ ),
+ "email_login": bool(user.email and user.email_verified_at),
+ "subscription_active": bool(sub),
+ "panel_status": getattr(sub, "status_from_panel", None) if sub else None,
+ "end_date": end_date.isoformat() if end_date else None,
+ "remaining": _format_support_remaining(seconds_left, lang),
+ "tariff": tariff_name or (getattr(sub, "tariff_key", None) if sub else ""),
+ "traffic_regular": self._traffic_snapshot(
+ getattr(sub, "traffic_used_bytes", 0) if sub else 0,
+ self._regular_limit(sub),
+ ),
+ "traffic_premium": self._traffic_snapshot(
+ getattr(sub, "premium_used_bytes", 0) if sub else 0,
+ self._premium_limit(sub),
+ ),
+ "is_throttled": bool(getattr(sub, "is_throttled", False)) if sub else False,
+ "topup_balance_bytes": int(getattr(sub, "topup_balance_bytes", 0) or 0)
+ if sub
+ else 0,
+ "premium_topup_balance_bytes": int(
+ getattr(sub, "premium_topup_balance_bytes", 0) or 0
+ )
+ if sub
+ else 0,
+ "lifetime_used_traffic_bytes": int(user.lifetime_used_traffic_bytes or 0),
+ }
+ finally:
+ if owns_session:
+ await session.__aexit__(None, None, None)
+
+ @staticmethod
+ def _regular_limit(sub) -> int:
+ if not sub:
+ return 0
+ if getattr(sub, "regular_unlimited_override", False):
+ return 0
+ return int(
+ (sub.traffic_limit_bytes or 0)
+ + (sub.topup_balance_bytes or 0)
+ + (getattr(sub, "regular_bonus_bytes", 0) or 0)
+ )
+
+ @staticmethod
+ def _premium_limit(sub) -> int:
+ if not sub:
+ return 0
+ if getattr(sub, "premium_unlimited_override", False):
+ return 0
+ return int(
+ (sub.premium_baseline_bytes or 0)
+ + (sub.premium_topup_balance_bytes or 0)
+ + (getattr(sub, "premium_topup_used_bytes", 0) or 0)
+ + (getattr(sub, "premium_bonus_bytes", 0) or 0)
+ )
+
+ @staticmethod
+ def _traffic_snapshot(used: Any, limit: Any) -> dict:
+ used_value = max(0, int(used or 0))
+ limit_value = max(0, int(limit or 0))
+ percent = round((used_value / limit_value) * 100, 2) if limit_value else 0
+ left = max(0, limit_value - used_value) if limit_value else 0
+ return {
+ "used_bytes": used_value,
+ "limit_bytes": limit_value,
+ "percent": percent,
+ "left_bytes": left,
+ }
diff --git a/backend/config/settings.py b/backend/config/settings.py
index bd4c69d..5821de1 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -336,6 +336,13 @@ class Settings(BaseSettings):
description="Log updates/events triggered by users from ADMIN_IDS.",
)
+ SUPPORT_TICKETS_ENABLED: bool = Field(default=True)
+ SUPPORT_TICKET_MAX_BODY_LENGTH: int = Field(default=4000)
+ SUPPORT_TICKET_MAX_SUBJECT_LENGTH: int = Field(default=160)
+ SUPPORT_TICKET_RATE_LIMIT_PER_HOUR: int = Field(default=5)
+ SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED: bool = Field(default=False)
+ SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS: int = Field(default=5 * 60)
+ SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS: int = Field(default=30 * 60)
SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None)
START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None)
@@ -805,6 +812,9 @@ class Settings(BaseSettings):
LOG_THREAD_ID: Optional[int] = Field(
default=None, description="Thread ID for supergroup messages (optional)"
)
+ LOG_SUPPORT_THREAD_ID: Optional[int] = Field(
+ default=None, description="Thread ID for support ticket log messages"
+ )
@field_validator("LOG_LEVEL", mode="before")
@classmethod
@@ -835,7 +845,7 @@ class Settings(BaseSettings):
return v
return secrets.token_urlsafe(32)
- @field_validator("LOG_CHAT_ID", "LOG_THREAD_ID", mode="before")
+ @field_validator("LOG_CHAT_ID", "LOG_THREAD_ID", "LOG_SUPPORT_THREAD_ID", mode="before")
@classmethod
def validate_optional_int_fields(cls, v):
"""Convert empty strings to None for optional integer fields"""
@@ -890,6 +900,7 @@ class Settings(BaseSettings):
LOG_SUSPICIOUS_ACTIVITY: bool = Field(
default=True, description="Send notifications for suspicious promo attempts"
)
+ LOG_SUPPORT: bool = Field(default=True, description="Send support ticket notifications")
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore", populate_by_name=True
diff --git a/backend/db/dal/__init__.py b/backend/db/dal/__init__.py
index 5605be4..e0f74b5 100644
--- a/backend/db/dal/__init__.py
+++ b/backend/db/dal/__init__.py
@@ -7,6 +7,7 @@ from . import (
promo_code_dal,
security_dal,
subscription_dal,
+ support_dal,
user_billing_dal,
user_dal,
)
@@ -22,4 +23,5 @@ __all__ = (
"ad_dal",
"security_dal",
"app_settings_dal",
+ "support_dal",
)
diff --git a/backend/db/dal/app_settings_dal.py b/backend/db/dal/app_settings_dal.py
index a7ea1a5..98ead3c 100644
--- a/backend/db/dal/app_settings_dal.py
+++ b/backend/db/dal/app_settings_dal.py
@@ -37,6 +37,17 @@ async def get_all_overrides(session: AsyncSession) -> Dict[str, Any]:
return {row.key: _decode(row.value) for row in rows}
+async def get_override_value(session: AsyncSession, key: str) -> Tuple[bool, Any]:
+ row = (
+ await session.execute(
+ select(AppSettingOverride).where(AppSettingOverride.key == key).limit(1)
+ )
+ ).scalar_one_or_none()
+ if row is None:
+ return False, None
+ return True, _decode(row.value)
+
+
async def get_overrides_with_meta(session: AsyncSession) -> List[Dict[str, Any]]:
rows = (await session.execute(select(AppSettingOverride))).scalars().all()
items: List[Dict[str, Any]] = []
diff --git a/backend/db/dal/support_dal.py b/backend/db/dal/support_dal.py
new file mode 100644
index 0000000..0be256a
--- /dev/null
+++ b/backend/db/dal/support_dal.py
@@ -0,0 +1,375 @@
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+from typing import Optional
+
+from sqlalchemy import and_, case, desc, func, or_, update
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.future import select
+from sqlalchemy.orm import selectinload
+
+from ..models import SupportTicket, SupportTicketMessage, User
+
+ACTIVE_STATUSES = {"open", "awaiting_user", "awaiting_admin"}
+CLOSED_STATUSES = {"resolved", "closed"}
+_UNSET = object()
+
+
+def _status_condition(status: Optional[str]):
+ normalized = (status or "").strip().lower()
+ if not normalized or normalized in {"all", "any"}:
+ return None
+ if normalized == "active":
+ return SupportTicket.status.in_(ACTIVE_STATUSES)
+ if normalized == "closed":
+ return SupportTicket.status.in_(CLOSED_STATUSES)
+ return SupportTicket.status == normalized
+
+
+async def create_ticket(
+ session: AsyncSession,
+ user_id: int,
+ subject: str,
+ category: str,
+ priority: str,
+ first_message_body: str,
+) -> SupportTicket:
+ now = datetime.now(timezone.utc)
+ ticket = SupportTicket(
+ user_id=user_id,
+ subject=subject,
+ category=category,
+ priority=priority,
+ status="awaiting_admin",
+ last_message_at=now,
+ last_message_role="user",
+ unread_admin_count=1,
+ unread_user_count=0,
+ admin_last_notified_at=now,
+ admin_last_emailed_at=now,
+ )
+ session.add(ticket)
+ await session.flush()
+ message = SupportTicketMessage(
+ ticket_id=ticket.ticket_id,
+ author_role="user",
+ author_user_id=user_id,
+ body=first_message_body,
+ is_internal_note=False,
+ created_at=now,
+ )
+ session.add(message)
+ await session.flush()
+ await session.refresh(ticket)
+ return ticket
+
+
+async def add_message(
+ session: AsyncSession,
+ ticket_id: int,
+ author_role: str,
+ author_user_id: Optional[int],
+ body: str,
+ is_internal_note: bool = False,
+) -> Optional[SupportTicketMessage]:
+ stmt = select(SupportTicket).where(SupportTicket.ticket_id == ticket_id).with_for_update()
+ result = await session.execute(stmt)
+ ticket = result.scalar_one_or_none()
+ if not ticket:
+ return None
+
+ now = datetime.now(timezone.utc)
+ message = SupportTicketMessage(
+ ticket_id=ticket_id,
+ author_role=author_role,
+ author_user_id=author_user_id,
+ body=body,
+ is_internal_note=bool(is_internal_note),
+ created_at=now,
+ )
+ session.add(message)
+
+ ticket.last_message_at = now
+ ticket.last_message_role = author_role
+ ticket.updated_at = now
+ if author_role == "user":
+ ticket.unread_admin_count = int(ticket.unread_admin_count or 0) + 1
+ if ticket.status not in CLOSED_STATUSES:
+ ticket.status = "awaiting_admin"
+ elif author_role == "admin" and not is_internal_note:
+ ticket.unread_user_count = int(ticket.unread_user_count or 0) + 1
+ if ticket.status not in CLOSED_STATUSES:
+ ticket.status = "awaiting_user"
+
+ await session.flush()
+ await session.refresh(message)
+ return message
+
+
+async def record_admin_notification(
+ session: AsyncSession,
+ ticket_id: int,
+ *,
+ notified_at: Optional[datetime] = None,
+ emailed_at: Optional[datetime] = None,
+) -> None:
+ values = {}
+ if notified_at is not None:
+ values["admin_last_notified_at"] = notified_at
+ if emailed_at is not None:
+ values["admin_last_emailed_at"] = emailed_at
+ if not values:
+ return
+ await session.execute(
+ update(SupportTicket).where(SupportTicket.ticket_id == ticket_id).values(**values)
+ )
+ await session.flush()
+
+
+async def get_ticket(
+ session: AsyncSession,
+ ticket_id: int,
+ *,
+ include_internal: bool = False,
+) -> tuple[Optional[SupportTicket], list[SupportTicketMessage]]:
+ stmt = (
+ select(SupportTicket)
+ .where(SupportTicket.ticket_id == ticket_id)
+ .options(selectinload(SupportTicket.user))
+ )
+ result = await session.execute(stmt)
+ ticket = result.scalar_one_or_none()
+ if not ticket:
+ return None, []
+
+ msg_stmt = select(SupportTicketMessage).where(SupportTicketMessage.ticket_id == ticket_id)
+ if not include_internal:
+ msg_stmt = msg_stmt.where(SupportTicketMessage.is_internal_note.is_(False))
+ msg_stmt = msg_stmt.order_by(
+ SupportTicketMessage.created_at.asc(),
+ SupportTicketMessage.message_id.asc(),
+ )
+ msg_result = await session.execute(msg_stmt)
+ return ticket, list(msg_result.scalars().all())
+
+
+async def list_user_tickets(
+ session: AsyncSession,
+ user_id: int,
+ *,
+ limit: int,
+ offset: int,
+ status_filter: Optional[str] = None,
+) -> list[SupportTicket]:
+ stmt = select(SupportTicket).where(SupportTicket.user_id == user_id)
+ status_cond = _status_condition(status_filter)
+ if status_cond is not None:
+ stmt = stmt.where(status_cond)
+ stmt = (
+ stmt.order_by(desc(SupportTicket.last_message_at), desc(SupportTicket.ticket_id))
+ .limit(limit)
+ .offset(offset)
+ )
+ result = await session.execute(stmt)
+ return list(result.scalars().all())
+
+
+async def list_admin_tickets(
+ session: AsyncSession,
+ *,
+ status: Optional[str] = None,
+ priority: Optional[str] = None,
+ category: Optional[str] = None,
+ assigned_admin_id: Optional[int] = None,
+ search: Optional[str] = None,
+ sort: str = "updated_desc",
+ limit: int,
+ offset: int,
+) -> list[SupportTicket]:
+ stmt = select(SupportTicket).join(User, User.user_id == SupportTicket.user_id)
+ status_cond = _status_condition(status)
+ if status_cond is not None:
+ stmt = stmt.where(status_cond)
+ if priority:
+ stmt = stmt.where(SupportTicket.priority == priority)
+ if category:
+ stmt = stmt.where(SupportTicket.category == category)
+ if assigned_admin_id is not None:
+ stmt = stmt.where(SupportTicket.assigned_admin_id == assigned_admin_id)
+ if search:
+ pattern = f"%{search.strip().lower()}%"
+ stmt = stmt.where(
+ or_(
+ func.lower(SupportTicket.subject).like(pattern),
+ func.lower(User.username).like(pattern),
+ func.lower(User.first_name).like(pattern),
+ func.lower(User.email).like(pattern),
+ )
+ )
+ priority_rank = case(
+ (SupportTicket.priority == "urgent", 4),
+ (SupportTicket.priority == "high", 3),
+ (SupportTicket.priority == "normal", 2),
+ (SupportTicket.priority == "low", 1),
+ else_=0,
+ )
+ sort_key = (sort or "updated_desc").strip().lower()
+ sort_map = {
+ "updated_desc": (SupportTicket.last_message_at.desc().nullslast(),),
+ "updated_asc": (SupportTicket.last_message_at.asc().nullslast(),),
+ "created_desc": (SupportTicket.created_at.desc().nullslast(),),
+ "created_asc": (SupportTicket.created_at.asc().nullslast(),),
+ "importance_desc": (
+ priority_rank.desc(),
+ SupportTicket.last_message_at.desc().nullslast(),
+ ),
+ "importance_asc": (
+ priority_rank.asc(),
+ SupportTicket.last_message_at.desc().nullslast(),
+ ),
+ }
+ order_by = sort_map.get(sort_key, sort_map["updated_desc"])
+ stmt = stmt.options(selectinload(SupportTicket.user)).order_by(
+ *order_by,
+ desc(SupportTicket.ticket_id),
+ )
+ stmt = stmt.limit(limit).offset(offset)
+ result = await session.execute(stmt)
+ return list(result.scalars().unique().all())
+
+
+async def user_ticket_counts(session: AsyncSession, user_id: int) -> dict:
+ stmt = (
+ select(SupportTicket.status, func.count())
+ .where(SupportTicket.user_id == user_id)
+ .group_by(SupportTicket.status)
+ )
+ result = await session.execute(stmt)
+ by_status = {str(status): int(count or 0) for status, count in result.all()}
+ active = sum(by_status.get(status, 0) for status in ACTIVE_STATUSES)
+ closed = sum(by_status.get(status, 0) for status in CLOSED_STATUSES)
+ return {
+ **by_status,
+ "active": active,
+ "closed": closed,
+ "total": active + closed,
+ }
+
+
+async def mark_read(session: AsyncSession, ticket_id: int, role: str) -> None:
+ now = datetime.now(timezone.utc)
+ if role == "user":
+ await session.execute(
+ update(SupportTicket)
+ .where(SupportTicket.ticket_id == ticket_id)
+ .values(unread_user_count=0, updated_at=now)
+ )
+ await session.execute(
+ update(SupportTicketMessage)
+ .where(
+ and_(
+ SupportTicketMessage.ticket_id == ticket_id,
+ SupportTicketMessage.author_role == "admin",
+ SupportTicketMessage.is_internal_note.is_(False),
+ SupportTicketMessage.read_by_user_at.is_(None),
+ )
+ )
+ .values(read_by_user_at=now)
+ )
+ elif role == "admin":
+ await session.execute(
+ update(SupportTicket)
+ .where(SupportTicket.ticket_id == ticket_id)
+ .values(unread_admin_count=0, updated_at=now)
+ )
+ await session.execute(
+ update(SupportTicketMessage)
+ .where(
+ and_(
+ SupportTicketMessage.ticket_id == ticket_id,
+ SupportTicketMessage.author_role == "user",
+ SupportTicketMessage.read_by_admin_at.is_(None),
+ )
+ )
+ .values(read_by_admin_at=now)
+ )
+ await session.flush()
+
+
+async def update_ticket(
+ session: AsyncSession,
+ ticket_id: int,
+ *,
+ status: Optional[str] = None,
+ priority: Optional[str] = None,
+ category: Optional[str] = None,
+ assigned_admin_id: object = _UNSET,
+ closed_by_admin_id: Optional[int] = None,
+) -> Optional[SupportTicket]:
+ ticket = await session.get(SupportTicket, ticket_id)
+ if not ticket:
+ return None
+ now = datetime.now(timezone.utc)
+ if status is not None:
+ ticket.status = status
+ if status == "closed":
+ ticket.closed_at = now
+ ticket.closed_by_admin_id = closed_by_admin_id
+ elif status != "closed":
+ ticket.closed_at = None
+ ticket.closed_by_admin_id = None
+ if priority is not None:
+ ticket.priority = priority
+ if category is not None:
+ ticket.category = category
+ if assigned_admin_id is not _UNSET:
+ ticket.assigned_admin_id = assigned_admin_id
+ ticket.updated_at = now
+ await session.flush()
+ await session.refresh(ticket)
+ return ticket
+
+
+async def count_user_unread(session: AsyncSession, user_id: int) -> int:
+ stmt = select(func.coalesce(func.sum(SupportTicket.unread_user_count), 0)).where(
+ SupportTicket.user_id == user_id
+ )
+ result = await session.execute(stmt)
+ return int(result.scalar_one() or 0)
+
+
+async def admin_stats(session: AsyncSession) -> dict:
+ status_result = await session.execute(
+ select(SupportTicket.status, func.count()).group_by(SupportTicket.status)
+ )
+ by_status = {str(status): int(count or 0) for status, count in status_result.all()}
+ unread_result = await session.execute(
+ select(func.coalesce(func.sum(SupportTicket.unread_admin_count), 0))
+ )
+ active = sum(by_status.get(status, 0) for status in ACTIVE_STATUSES)
+ closed = sum(by_status.get(status, 0) for status in CLOSED_STATUSES)
+ return {
+ **by_status,
+ "active": active,
+ "closed": closed,
+ "total": active + closed,
+ "open": by_status.get("open", 0),
+ "awaiting_admin": by_status.get("awaiting_admin", 0),
+ "awaiting_user": by_status.get("awaiting_user", 0),
+ "total_unread_admin": int(unread_result.scalar_one() or 0),
+ }
+
+
+async def count_recent_tickets_for_user(
+ session: AsyncSession,
+ user_id: int,
+ window_seconds: int,
+) -> int:
+ cutoff = datetime.now(timezone.utc) - timedelta(seconds=max(1, int(window_seconds)))
+ stmt = (
+ select(func.count())
+ .select_from(SupportTicket)
+ .where(SupportTicket.user_id == user_id, SupportTicket.created_at >= cutoff)
+ )
+ result = await session.execute(stmt)
+ return int(result.scalar_one() or 0)
diff --git a/backend/db/migrator.py b/backend/db/migrator.py
index 1920971..eaa0938 100644
--- a/backend/db/migrator.py
+++ b/backend/db/migrator.py
@@ -754,6 +754,126 @@ def _migration_0023_add_email_password_auth_fields(connection: Connection) -> No
connection.execute(text("ALTER TABLE users ADD COLUMN password_set_at TIMESTAMPTZ"))
+def _migration_0024_add_support_tickets(connection: Connection) -> None:
+ connection.execute(
+ text(
+ """
+ CREATE TABLE IF NOT EXISTS support_tickets (
+ ticket_id SERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL REFERENCES users(user_id),
+ subject VARCHAR(160) NOT NULL,
+ category VARCHAR(32) NOT NULL DEFAULT 'other',
+ priority VARCHAR(16) NOT NULL DEFAULT 'normal',
+ status VARCHAR(24) NOT NULL DEFAULT 'open',
+ assigned_admin_id BIGINT NULL,
+ last_message_at TIMESTAMPTZ DEFAULT NOW(),
+ last_message_role VARCHAR(16) NULL,
+ unread_user_count INTEGER NOT NULL DEFAULT 0,
+ unread_admin_count INTEGER NOT NULL DEFAULT 0,
+ admin_last_notified_at TIMESTAMPTZ NULL,
+ admin_last_emailed_at TIMESTAMPTZ NULL,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NULL,
+ closed_at TIMESTAMPTZ NULL,
+ closed_by_admin_id BIGINT NULL
+ )
+ """
+ )
+ )
+ connection.execute(
+ text(
+ """
+ CREATE TABLE IF NOT EXISTS support_ticket_messages (
+ message_id SERIAL PRIMARY KEY,
+ ticket_id INTEGER NOT NULL REFERENCES support_tickets(ticket_id) ON DELETE CASCADE,
+ author_role VARCHAR(16) NOT NULL,
+ author_user_id BIGINT NULL REFERENCES users(user_id),
+ body TEXT NOT NULL,
+ is_internal_note BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ read_by_user_at TIMESTAMPTZ NULL,
+ read_by_admin_at TIMESTAMPTZ NULL
+ )
+ """
+ )
+ )
+
+ inspector = inspect(connection)
+ ticket_columns: Set[str] = {col["name"] for col in inspector.get_columns("support_tickets")}
+ ticket_column_sql = {
+ "user_id": "BIGINT NOT NULL REFERENCES users(user_id)",
+ "subject": "VARCHAR(160) NOT NULL DEFAULT ''",
+ "category": "VARCHAR(32) NOT NULL DEFAULT 'other'",
+ "priority": "VARCHAR(16) NOT NULL DEFAULT 'normal'",
+ "status": "VARCHAR(24) NOT NULL DEFAULT 'open'",
+ "assigned_admin_id": "BIGINT NULL",
+ "last_message_at": "TIMESTAMPTZ DEFAULT NOW()",
+ "last_message_role": "VARCHAR(16) NULL",
+ "unread_user_count": "INTEGER NOT NULL DEFAULT 0",
+ "unread_admin_count": "INTEGER NOT NULL DEFAULT 0",
+ "admin_last_notified_at": "TIMESTAMPTZ NULL",
+ "admin_last_emailed_at": "TIMESTAMPTZ NULL",
+ "created_at": "TIMESTAMPTZ DEFAULT NOW()",
+ "updated_at": "TIMESTAMPTZ NULL",
+ "closed_at": "TIMESTAMPTZ NULL",
+ "closed_by_admin_id": "BIGINT NULL",
+ }
+ for column, definition in ticket_column_sql.items():
+ if column not in ticket_columns:
+ connection.execute(
+ text(f"ALTER TABLE support_tickets ADD COLUMN {column} {definition}")
+ )
+
+ message_columns: Set[str] = {
+ col["name"] for col in inspector.get_columns("support_ticket_messages")
+ }
+ message_column_sql = {
+ "ticket_id": "INTEGER NOT NULL REFERENCES support_tickets(ticket_id) ON DELETE CASCADE",
+ "author_role": "VARCHAR(16) NOT NULL DEFAULT 'user'",
+ "author_user_id": "BIGINT NULL REFERENCES users(user_id)",
+ "body": "TEXT NOT NULL DEFAULT ''",
+ "is_internal_note": "BOOLEAN NOT NULL DEFAULT FALSE",
+ "created_at": "TIMESTAMPTZ DEFAULT NOW()",
+ "read_by_user_at": "TIMESTAMPTZ NULL",
+ "read_by_admin_at": "TIMESTAMPTZ NULL",
+ }
+ for column, definition in message_column_sql.items():
+ if column not in message_columns:
+ connection.execute(
+ text(f"ALTER TABLE support_ticket_messages ADD COLUMN {column} {definition}")
+ )
+
+ index_statements = [
+ "CREATE INDEX IF NOT EXISTS ix_support_tickets_user_id ON support_tickets (user_id)",
+ "CREATE INDEX IF NOT EXISTS ix_support_tickets_category ON support_tickets (category)",
+ "CREATE INDEX IF NOT EXISTS ix_support_tickets_priority ON support_tickets (priority)",
+ "CREATE INDEX IF NOT EXISTS ix_support_tickets_status ON support_tickets (status)",
+ "CREATE INDEX IF NOT EXISTS ix_support_tickets_assigned_admin_id ON support_tickets (assigned_admin_id)", # noqa: E501
+ "CREATE INDEX IF NOT EXISTS ix_support_tickets_last_message_at ON support_tickets (last_message_at)", # noqa: E501
+ "CREATE INDEX IF NOT EXISTS ix_support_tickets_status_last_msg ON support_tickets (status, last_message_at)", # noqa: E501
+ "CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_ticket_id ON support_ticket_messages (ticket_id)", # noqa: E501
+ "CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_author_user_id ON support_ticket_messages (author_user_id)", # noqa: E501
+ "CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_is_internal_note ON support_ticket_messages (is_internal_note)", # noqa: E501
+ "CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_created_at ON support_ticket_messages (created_at)", # noqa: E501
+ ]
+ for stmt in index_statements:
+ connection.execute(text(stmt))
+
+
+def _migration_0025_add_support_notification_timestamps(connection: Connection) -> None:
+ inspector = inspect(connection)
+ ticket_columns: Set[str] = {col["name"] for col in inspector.get_columns("support_tickets")}
+ column_sql = {
+ "admin_last_notified_at": "TIMESTAMPTZ NULL",
+ "admin_last_emailed_at": "TIMESTAMPTZ NULL",
+ }
+ for column, definition in column_sql.items():
+ if column not in ticket_columns:
+ connection.execute(
+ text(f"ALTER TABLE support_tickets ADD COLUMN {column} {definition}")
+ )
+
+
MIGRATIONS: List[Migration] = [
Migration(
id="0001_add_channel_subscription_fields",
@@ -881,6 +1001,16 @@ MIGRATIONS: List[Migration] = [
description="Store hashed passwords for optional email password login",
upgrade=_migration_0023_add_email_password_auth_fields,
),
+ Migration(
+ id="0024_add_support_tickets",
+ description="Add support ticket inbox and messages",
+ upgrade=_migration_0024_add_support_tickets,
+ ),
+ Migration(
+ id="0025_add_support_notification_timestamps",
+ description="Track support ticket admin notification cooldown timestamps",
+ upgrade=_migration_0025_add_support_notification_timestamps,
+ ),
]
diff --git a/backend/db/models.py b/backend/db/models.py
index 97b9438..4366eac 100644
--- a/backend/db/models.py
+++ b/backend/db/models.py
@@ -374,6 +374,62 @@ class MessageLog(Base):
)
+class SupportTicket(Base):
+ __tablename__ = "support_tickets"
+
+ ticket_id = Column(Integer, primary_key=True, autoincrement=True)
+ user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
+ subject = Column(String(160), nullable=False)
+ category = Column(String(32), nullable=False, default="other", index=True)
+ priority = Column(String(16), nullable=False, default="normal", index=True)
+ status = Column(String(24), nullable=False, default="open", index=True)
+ assigned_admin_id = Column(BigInteger, nullable=True, index=True)
+ last_message_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
+ last_message_role = Column(String(16), nullable=True)
+ unread_user_count = Column(Integer, nullable=False, default=0)
+ unread_admin_count = Column(Integer, nullable=False, default=0)
+ admin_last_notified_at = Column(DateTime(timezone=True), nullable=True)
+ admin_last_emailed_at = Column(DateTime(timezone=True), nullable=True)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ updated_at = Column(DateTime(timezone=True), onupdate=func.now())
+ closed_at = Column(DateTime(timezone=True), nullable=True)
+ closed_by_admin_id = Column(BigInteger, nullable=True)
+
+ user = relationship("User")
+ messages = relationship(
+ "SupportTicketMessage",
+ back_populates="ticket",
+ cascade="all, delete-orphan",
+ passive_deletes=True,
+ )
+
+ __table_args__ = (
+ Index("ix_support_tickets_status_last_msg", "status", "last_message_at"),
+ )
+
+
+class SupportTicketMessage(Base):
+ __tablename__ = "support_ticket_messages"
+
+ message_id = Column(Integer, primary_key=True, autoincrement=True)
+ ticket_id = Column(
+ Integer,
+ ForeignKey("support_tickets.ticket_id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+ author_role = Column(String(16), nullable=False)
+ author_user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True, index=True)
+ body = Column(Text, nullable=False)
+ is_internal_note = Column(Boolean, nullable=False, default=False, index=True)
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
+ read_by_user_at = Column(DateTime(timezone=True), nullable=True)
+ read_by_admin_at = Column(DateTime(timezone=True), nullable=True)
+
+ ticket = relationship("SupportTicket", back_populates="messages")
+ author_user = relationship("User")
+
+
class PanelSyncStatus(Base):
__tablename__ = "panel_sync_status"
diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile
index 090bb62..0de24ea 100644
--- a/deploy/docker/Dockerfile
+++ b/deploy/docker/Dockerfile
@@ -105,6 +105,7 @@ COPY --from=version-builder /build-tag /build-tag
COPY --from=version-builder /build-commit /build-commit
COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css
+COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.js /usr/share/nginx/html/subscription_webapp.js
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.min.*.js /usr/share/nginx/html/
@@ -112,9 +113,17 @@ RUN set -eu; \
find /docker-entrypoint.d -type f -name '*.sh' -exec sed -i 's/\r$//' {} +; \
HASHED=$(ls /usr/share/nginx/html/subscription_webapp.min.*.js 2>/dev/null | sort | tail -n1 | xargs -n1 basename || true); \
JS_NAME="${HASHED:-subscription_webapp.js}"; \
+ CSS_NAME="subscription_webapp.css"; \
+ for candidate in /usr/share/nginx/html/subscription_webapp.*.css; do \
+ name="$(basename "$candidate")"; \
+ case "$name" in \
+ subscription_webapp.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].css) CSS_NAME="$name" ;; \
+ esac; \
+ done; \
sed -i \
-e '/WEBAPP_I18N_SCRIPT/d' \
-e '/WEBAPP_CONFIG_SCRIPT/d' \
+ -e "s|href=\"/subscription_webapp.css\"|href=\"/${CSS_NAME}\"|" \
-e "/WEBAPP_JS_SCRIPT/c\\ " \
-e '/WEBAPP_DEV_MOCK_START/d' \
-e '/WEBAPP_DEV_MOCK_END/d' \
diff --git a/deploy/docker/frontend/nginx.conf b/deploy/docker/frontend/nginx.conf
index 8805f8f..bc6c684 100644
--- a/deploy/docker/frontend/nginx.conf
+++ b/deploy/docker/frontend/nginx.conf
@@ -4,6 +4,19 @@ server {
root /usr/share/nginx/html;
index index.html;
+ gzip on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_vary on;
+ gzip_proxied any;
+ gzip_types
+ application/javascript
+ application/json
+ image/svg+xml
+ text/css
+ text/javascript
+ text/plain;
+
location = /health {
access_log off;
return 200 "ok\n";
@@ -45,6 +58,18 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
+ location ~* ^/subscription_webapp\.(min\.)?[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]\.(css|js)$ {
+ expires off;
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ try_files $uri =404;
+ }
+
+ location ~* ^/subscription_webapp\.(css|js)$ {
+ expires off;
+ add_header Cache-Control "no-cache";
+ try_files $uri =404;
+ }
+
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|webp)$ {
expires 30d;
add_header Cache-Control "public";
@@ -52,6 +77,7 @@ server {
}
location / {
+ add_header Cache-Control "no-cache";
try_files $uri /index.html;
}
}
diff --git a/frontend/scripts/build_subscription_webapp_js.mjs b/frontend/scripts/build_subscription_webapp_js.mjs
index 6bcd671..451f2b8 100644
--- a/frontend/scripts/build_subscription_webapp_js.mjs
+++ b/frontend/scripts/build_subscription_webapp_js.mjs
@@ -17,6 +17,15 @@ const sourcePath = path.join(
"templates",
"subscription_webapp.js"
);
+const sourceCssPath = path.join(
+ repoRoot,
+ "backend",
+ "bot",
+ "app",
+ "web",
+ "templates",
+ "subscription_webapp.css"
+);
function normalizeLineEndings(value) {
return value.replace(/\r\n/g, "\n");
@@ -50,16 +59,11 @@ function stripFallbackI18n(source) {
);
}
-async function removeOldMinifiedAssets(assetDir, keepName) {
+async function removeOldHashedAssets(assetDir, pattern, keepName) {
const entries = await readdir(assetDir, { withFileTypes: true });
await Promise.all(
entries
- .filter(
- (entry) =>
- entry.isFile() &&
- /^subscription_webapp\.min\.[0-9a-f]{8}\.js$/.test(entry.name) &&
- entry.name !== keepName
- )
+ .filter((entry) => entry.isFile() && pattern.test(entry.name) && entry.name !== keepName)
.map((entry) => unlink(path.join(assetDir, entry.name)))
);
}
@@ -84,11 +88,31 @@ async function main() {
const hash = createHash("sha256").update(code, "utf8").digest("hex").slice(0, 8);
const outputPath = path.join(path.dirname(sourcePath), `subscription_webapp.min.${hash}.js`);
- await removeOldMinifiedAssets(path.dirname(sourcePath), path.basename(outputPath));
+ await removeOldHashedAssets(
+ path.dirname(sourcePath),
+ /^subscription_webapp\.min\.[0-9a-f]{8}\.js$/,
+ path.basename(outputPath)
+ );
await writeFile(outputPath, code, "utf8");
console.log(
`Wrote ${path.relative(repoRoot, outputPath)} (${Buffer.byteLength(code, "utf8")} bytes)`
);
+
+ const css = await readFile(sourceCssPath, "utf8");
+ const cssHash = createHash("sha256").update(css, "utf8").digest("hex").slice(0, 8);
+ const cssOutputPath = path.join(
+ path.dirname(sourceCssPath),
+ `subscription_webapp.${cssHash}.css`
+ );
+ await removeOldHashedAssets(
+ path.dirname(sourceCssPath),
+ /^subscription_webapp\.[0-9a-f]{8}\.css$/,
+ path.basename(cssOutputPath)
+ );
+ await writeFile(cssOutputPath, css, "utf8");
+ console.log(
+ `Wrote ${path.relative(repoRoot, cssOutputPath)} (${Buffer.byteLength(css, "utf8")} bytes)`
+ );
}
await main();
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 5ec95ac..f660c63 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -3,6 +3,7 @@
import { createAuthStore } from "./lib/webapp/stores/authStore.js";
import { createBillingStore } from "./lib/webapp/stores/billingStore.js";
import { createDevicesStore } from "./lib/webapp/stores/devicesStore.js";
+ import { createSupportStore } from "./lib/webapp/stores/supportStore.js";
import { createAccountStore } from "./lib/webapp/stores/accountStore.js";
import { Tooltip } from "$components/ui/primitives.js";
@@ -17,6 +18,8 @@
import HomeScreen from "./webapp/screens/HomeScreen.svelte";
import InviteScreen from "./webapp/screens/InviteScreen.svelte";
import SettingsScreen from "./webapp/screens/SettingsScreen.svelte";
+ import SupportScreen from "./webapp/screens/SupportScreen.svelte";
+ import SupportTicketScreen from "./webapp/screens/SupportTicketScreen.svelte";
import {
LANGUAGE_FLAGS,
@@ -70,6 +73,7 @@
adminUserIdFromPath,
normalizeSection,
sectionFromPath,
+ supportTicketIdFromPath,
syncSectionPath,
} from "./lib/webapp/routes.js";
@@ -114,6 +118,8 @@
let token = MOCK ? "local-preview" : "";
let csrfToken = MOCK ? "" : readCookie(CSRF_COOKIE_NAME) || "";
let scrollLockApplied = false;
+ let adminI18nLoaded = false;
+ let adminI18nPromise = null;
let tg = null;
const telegramSdk = createTelegramSdk({
scriptUrl: TELEGRAM_WEBAPP_SCRIPT_URL,
@@ -170,6 +176,7 @@
tg,
});
const devicesStore = createDevicesStore({ api, t, showToast });
+ const supportStore = createSupportStore({ api, t, showToast });
const accountStore = createAccountStore({
api,
publicApi,
@@ -194,6 +201,7 @@
setContext("authStore", authStore);
setContext("billingStore", billingStore);
setContext("devicesStore", devicesStore);
+ setContext("supportStore", supportStore);
setContext("accountStore", accountStore);
$: ({
@@ -233,6 +241,11 @@
deviceToDisconnect,
deviceDisconnectBusy,
} = $devicesStore);
+ $: ({
+ unreadCount: supportUnreadCount,
+ unreadLoading: supportUnreadLoading,
+ unreadLoaded: supportUnreadLoaded,
+ } = $supportStore);
$: ({
linkEmailOpen,
linkEmailBusy,
@@ -278,6 +291,7 @@
: []
: plans;
$: devicesEnabled = Boolean(appSettings?.my_devices_enabled);
+ $: supportEnabled = Boolean(appSettings?.support_tickets_enabled ?? true);
$: subscription = data?.subscription || DEV_MOCK.data.subscription;
$: hasActiveTariffSubscription = Boolean(
tariffMode && subscription?.active && subscription?.tariff_key
@@ -467,13 +481,28 @@
}
if (mode === "app") {
if (section === "admin" && isAdmin) {
- screen = "admin";
+ const pathAtStart = window.location.pathname;
+ void ensureI18nScope("admin").finally(() => {
+ if (sectionFromPath(window.location.pathname) !== "admin") return;
+ if (window.location.pathname !== pathAtStart) return;
+ activeTab = "settings";
+ screen = "admin";
+ });
return;
}
- const nextSection = section === "devices" && !devicesEnabled ? "home" : section;
+ const nextSection =
+ section === "devices" && !devicesEnabled
+ ? "home"
+ : section === "support" && !supportEnabled
+ ? "home"
+ : section;
activeTab = nextSection;
screen = nextSection;
if (nextSection === "devices") devicesStore.loadDevices(devicesEnabled);
+ if (nextSection === "support") {
+ supportStore.loadList();
+ supportStore.startPolling({ includeList: true });
+ }
}
};
window.addEventListener("popstate", onPopState);
@@ -486,6 +515,7 @@
authStore.clearCooldownTimer();
accountStore.clearLinkEmailResendTimer();
accountStore.clearSetPasswordResendTimer();
+ supportStore.closePolling();
clearLanguageClickGuard();
syncBodyScrollLock(false);
};
@@ -552,6 +582,29 @@
});
}
+ async function ensureI18nScope(scope) {
+ if (MOCK || scope !== "admin" || adminI18nLoaded) return;
+ if (adminI18nPromise) return adminI18nPromise;
+ const apiBase = String(CFG.apiBase || "/api").replace(/\/+$/, "");
+ adminI18nPromise = fetch(`${apiBase}/i18n?scope=admin`, {
+ credentials: "same-origin",
+ headers: { Accept: "application/json" },
+ })
+ .then((response) => (response.ok ? response.json() : null))
+ .then((payload) => {
+ if (!payload?.ok || !payload.i18n) return;
+ i18n.mergeMessages(payload.i18n);
+ adminI18nLoaded = true;
+ })
+ .catch((_error) => {
+ void _error;
+ })
+ .finally(() => {
+ adminI18nPromise = null;
+ });
+ return adminI18nPromise;
+ }
+
async function boot() {
await runWebappBoot({
MOCK,
@@ -640,17 +693,47 @@
: sectionFromPath(window.location.pathname);
if (section === "admin" && !payload.user?.is_admin) section = "settings";
if (section === "devices" && !payload.settings?.my_devices_enabled) section = "home";
+ if (section === "support" && payload.settings?.support_tickets_enabled === false) {
+ section = "home";
+ }
+ const initialAdminSection =
+ section === "admin" ? adminSectionFromPath(window.location.pathname) : null;
+ if (section === "admin" && payload.user?.is_admin) {
+ await ensureI18nScope("admin");
+ }
+ const initialSupportTicketId =
+ section === "support" ? supportTicketIdFromPath(window.location.pathname) : null;
activeTab = section === "admin" ? "settings" : section;
screen = section;
mode = "app";
- syncSectionPath(
- section,
- true,
- section === "admin" ? adminSectionFromPath(window.location.pathname) : null
- );
+ if (payload.settings?.support_tickets_enabled !== false) {
+ if (typeof payload.support_unread_count !== "undefined") {
+ supportStore.hydrateUnread(payload.support_unread_count);
+ }
+ void supportStore.refreshUnread();
+ supportStore.startPolling({ includeList: false });
+ }
+ if (section === "support" && initialSupportTicketId) {
+ const targetPath = `/support/${initialSupportTicketId}`;
+ if (window.location.protocol !== "file:" && window.location.pathname !== targetPath) {
+ window.history.replaceState(
+ null,
+ "",
+ `${targetPath}${window.location.search}${window.location.hash}`
+ );
+ }
+ } else {
+ syncSectionPath(section, true, initialAdminSection);
+ }
if (section === "devices" && payload.settings?.my_devices_enabled) {
await devicesStore.loadDevices(true);
}
+ if (section === "support") {
+ if (initialSupportTicketId)
+ await supportStore.openTicket(initialSupportTicketId, { skipPush: true });
+ else await supportStore.loadList();
+ supportStore.startPolling({ includeList: true });
+ }
if (topupModalOpen) await billingStore.loadTopupOptions(topupKind);
if (deviceTopupModalOpen) await billingStore.loadDeviceTopupOptions();
if (changeModalOpen) await billingStore.loadTariffChangeOptions();
@@ -846,6 +929,16 @@
devicesStore.loadDevices(devicesEnabled);
}
+ function goSupport() {
+ if (!supportEnabled) return;
+ billingStore.closePaymentModal();
+ activeTab = "support";
+ screen = "support";
+ syncSectionPath("support");
+ supportStore.loadList();
+ supportStore.startPolling({ includeList: true });
+ }
+
function defaultPaymentMethod() {
return methods[0]?.id || "";
}
@@ -900,10 +993,11 @@
syncSectionPath("settings");
}
- function openAdminPanel() {
+ async function openAdminPanel() {
if (!isAdmin) return;
clearLanguageClickGuard();
billingStore.closePaymentModal();
+ await ensureI18nScope("admin");
activeTab = "settings";
screen = "admin";
syncSectionPath("admin", false, adminSectionFromPath(window.location.pathname));
@@ -1067,12 +1161,17 @@
{brandTitle}
{brand}
{devicesEnabled}
+ {supportEnabled}
+ {supportUnreadCount}
+ {supportUnreadLoading}
+ {supportUnreadLoaded}
{hasUnlinkedIdentity}
{isAdmin}
{openAdminPanel}
{goDevices}
{goHome}
{goInvite}
+ {goSupport}
{goSettings}
{t}
>
@@ -1131,6 +1230,24 @@
{openDeviceTopupModal}
{t}
/>
+ {:else if screen === "support"}
+ {#if $supportStore.openedTicketId}
+
+ {:else}
+
+ {/if}
{:else if screen === "settings"}
{item.label}
-
+
+ {#if item.id === "support" && $supportStore.stats?.total_unread_admin}
+
+ {$supportStore.stats.total_unread_admin}
+
+ {/if}
+
{/each}
@@ -647,6 +676,15 @@
{/if}
+ {#if active === "support"}
+
+ {/if}
+
{#if active === "tariffs"}
{/if}
diff --git a/frontend/src/admin/sections/AppearanceSection.svelte b/frontend/src/admin/sections/AppearanceSection.svelte
index b856842..6d89201 100644
--- a/frontend/src/admin/sections/AppearanceSection.svelte
+++ b/frontend/src/admin/sections/AppearanceSection.svelte
@@ -532,7 +532,9 @@
>
- {at("appearance_use_custom_favicon", {}, "Использовать отдельную favicon")}
+ {at("appearance_use_custom_favicon", {}, "Использовать отдельную favicon")}
- setThemeHomeLogoScale(theme, event.currentTarget.value)}
+ oninput={(event) => setThemeHomeLogoScale(theme, event.currentTarget.value)}
/>
%
diff --git a/frontend/src/admin/sections/SettingsSection.svelte b/frontend/src/admin/sections/SettingsSection.svelte
index 2a9e8fd..dd14e26 100644
--- a/frontend/src/admin/sections/SettingsSection.svelte
+++ b/frontend/src/admin/sections/SettingsSection.svelte
@@ -139,14 +139,15 @@
function sectionTitle(id) {
const map = {
- general: at("settings_section_general", {}, "Общие"),
- appearance: at("settings_section_appearance", {}, "Внешний вид"),
- pricing: at("settings_section_pricing", {}, "Тарифы и цены"),
- payments: at("settings_section_payments", {}, "Платёжные системы"),
- trial: at("settings_section_trial", {}, "Триал"),
- referral: at("settings_section_referral", {}, "Реферальная программа"),
- notifications: at("settings_section_notifications", {}, "Уведомления"),
- devices: at("settings_section_devices", {}, "Устройства"),
+ general: at("admin_settings_section_general", {}, "Общие"),
+ appearance: at("admin_settings_section_appearance", {}, "Внешний вид"),
+ pricing: at("admin_settings_section_pricing", {}, "Тарифы и цены"),
+ payments: at("admin_settings_section_payments", {}, "Платёжные системы"),
+ trial: at("admin_settings_section_trial", {}, "Триал"),
+ referral: at("admin_settings_section_referral", {}, "Реферальная программа"),
+ notifications: at("admin_settings_section_notifications", {}, "Уведомления"),
+ support: at("admin_settings_section_support", {}, "Поддержка"),
+ devices: at("admin_settings_section_devices", {}, "Устройства"),
};
return map[id] || id;
}
diff --git a/frontend/src/admin/sections/SupportSection.svelte b/frontend/src/admin/sections/SupportSection.svelte
new file mode 100644
index 0000000..ef6ac23
--- /dev/null
+++ b/frontend/src/admin/sections/SupportSection.svelte
@@ -0,0 +1,347 @@
+
+
+
+
+
+ {stats?.open || 0}
+ {at("support_status_open", {}, "Открыт")}
+
+
+ {stats?.awaiting_admin || 0}
+ {at("support_status_awaiting_admin", {}, "Ждет админа")}
+
+
+ {stats?.total_unread_admin || 0}
+ {at("support_unread", {}, "Непрочитано")}
+
+
+
+
+
+ {#each statusTabs as tab (tab.value)}
+
+ {/each}
+
+
+
+
+ {#if loading}
+
+ {#each Array(6) as _, index (index)}
+
+
+
+
+
+
+
+
+
+
+
+ {/each}
+
+ {:else if !tickets.length}
+ {at("support_empty", {}, "Тикетов пока нет")}
+ {:else}
+
+ {#each tickets as ticket}
+ supportStore.openTicket(item.ticket_id)}
+ />
+ {/each}
+
+ {/if}
+
+
+
+
diff --git a/frontend/src/lib/admin/stores/supportStore.js b/frontend/src/lib/admin/stores/supportStore.js
new file mode 100644
index 0000000..f1925ea
--- /dev/null
+++ b/frontend/src/lib/admin/stores/supportStore.js
@@ -0,0 +1,216 @@
+import { writable } from "svelte/store";
+
+export function createAdminSupportStore({ api, onToast, at }) {
+ const state = writable({
+ tickets: [],
+ stats: { active: 0, closed: 0, open: 0, awaiting_admin: 0, total_unread_admin: 0 },
+ filters: {
+ status: "active",
+ priority: "",
+ category: "",
+ search: "",
+ sort: "importance_desc",
+ },
+ loading: false,
+ openedTicketId: null,
+ openedTicket: null,
+ messages: [],
+ userSnapshot: null,
+ detailLoading: false,
+ sending: false,
+ composerInternalNote: false,
+ });
+
+ let pollTimer = null;
+ let active = "stats";
+
+ function setActive(section) {
+ active = section;
+ }
+
+ function pushTicketPath(ticketId) {
+ if (typeof window === "undefined" || window.location.protocol === "file:") return;
+ if (active !== "support") return;
+ const target = ticketId ? `/admin/support/${ticketId}` : "/admin/support";
+ if (window.location.pathname !== target) {
+ window.history.pushState(
+ null,
+ "",
+ `${target}${window.location.search}${window.location.hash}`
+ );
+ }
+ }
+
+ async function loadStats() {
+ const res = await api("/admin/support/stats");
+ if (res?.ok) state.update((s) => ({ ...s, stats: res.stats || s.stats }));
+ }
+
+ async function loadList() {
+ state.update((s) => ({ ...s, loading: true }));
+ let filters;
+ state.update((s) => {
+ filters = s.filters;
+ return s;
+ });
+ try {
+ const params = new URLSearchParams({ limit: "50", offset: "0" });
+ for (const [key, value] of Object.entries(filters || {})) {
+ if (value) params.set(key, value);
+ }
+ const res = await api(`/admin/support/tickets?${params.toString()}`);
+ if (res?.ok) state.update((s) => ({ ...s, tickets: res.tickets || [] }));
+ else if (res?.error) onToast(res.message || res.error);
+ } finally {
+ state.update((s) => ({ ...s, loading: false }));
+ }
+ }
+
+ async function openTicket(ticketId, opts = {}) {
+ const id = Number(ticketId);
+ if (!id) return;
+ state.update((s) => ({
+ ...s,
+ openedTicketId: id,
+ openedTicket: s.openedTicket?.ticket_id === id ? s.openedTicket : null,
+ messages: s.openedTicket?.ticket_id === id ? s.messages : [],
+ userSnapshot: s.openedTicket?.ticket_id === id ? s.userSnapshot : null,
+ detailLoading: true,
+ }));
+ if (!opts.skipPush) pushTicketPath(id);
+ try {
+ const res = await api(`/admin/support/tickets/${id}`);
+ if (res?.ok) {
+ state.update((s) => ({
+ ...s,
+ openedTicket: res.ticket,
+ messages: res.messages || [],
+ userSnapshot: res.user_snapshot || null,
+ }));
+ await api(`/admin/support/tickets/${id}/read`, { method: "POST", body: "{}" });
+ await loadStats();
+ } else onToast(res?.message || res?.error || "not_found");
+ } finally {
+ state.update((s) => ({ ...s, detailLoading: false }));
+ }
+ }
+
+ function closeTicketView(opts = {}) {
+ state.update((s) => ({
+ ...s,
+ openedTicketId: null,
+ openedTicket: null,
+ messages: [],
+ userSnapshot: null,
+ }));
+ if (!opts.skipPush) pushTicketPath(null);
+ }
+
+ async function sendReply(body) {
+ let current;
+ let internal;
+ state.update((s) => {
+ current = s.openedTicketId;
+ internal = s.composerInternalNote;
+ return { ...s, sending: true };
+ });
+ if (!current) return;
+ try {
+ const res = await api(`/admin/support/tickets/${current}/messages`, {
+ method: "POST",
+ body: JSON.stringify({ body, is_internal_note: internal }),
+ });
+ if (!res?.ok) throw res;
+ state.update((s) => ({
+ ...s,
+ openedTicket: res.ticket
+ ? { ...s.openedTicket, ...res.ticket, user: res.ticket.user || s.openedTicket?.user }
+ : s.openedTicket,
+ messages: [...s.messages, res.message],
+ }));
+ await loadList();
+ await loadStats();
+ } catch (error) {
+ onToast(error?.message || at("support_send_failed", {}, "Send failed"));
+ } finally {
+ state.update((s) => ({ ...s, sending: false }));
+ }
+ }
+
+ async function patchTicket(updates) {
+ let current;
+ state.update((s) => {
+ current = s.openedTicketId;
+ return s;
+ });
+ if (!current) return;
+ const res = await api(`/admin/support/tickets/${current}`, {
+ method: "PATCH",
+ body: JSON.stringify(updates),
+ });
+ if (res?.ok) {
+ state.update((s) => ({
+ ...s,
+ openedTicket: res.ticket
+ ? { ...s.openedTicket, ...res.ticket, user: res.ticket.user || s.openedTicket?.user }
+ : s.openedTicket,
+ }));
+ await loadList();
+ await loadStats();
+ } else onToast(res?.message || res?.error || "update_failed");
+ }
+
+ function closeTicket() {
+ patchTicket({ status: "closed" });
+ }
+
+ function toggleInternalNote() {
+ state.update((s) => ({ ...s, composerInternalNote: !s.composerInternalNote }));
+ }
+
+ function setFilter(key, value) {
+ state.update((s) => ({ ...s, filters: { ...s.filters, [key]: value } }));
+ }
+
+ function setStatusView(status) {
+ state.update((s) => ({
+ ...s,
+ filters: {
+ ...s.filters,
+ status: status === "closed" ? "closed" : "active",
+ },
+ }));
+ loadList();
+ }
+
+ function startStatsPolling() {
+ if (pollTimer || typeof window === "undefined") return;
+ loadStats();
+ pollTimer = window.setInterval(() => {
+ if (document.visibilityState === "visible") loadStats();
+ }, 30000);
+ }
+
+ function stopStatsPolling() {
+ if (pollTimer) window.clearInterval(pollTimer);
+ pollTimer = null;
+ }
+
+ return {
+ subscribe: state.subscribe,
+ update: state.update,
+ setActive,
+ loadStats,
+ loadList,
+ openTicket,
+ closeTicketView,
+ sendReply,
+ patchTicket,
+ closeTicket,
+ toggleInternalNote,
+ setFilter,
+ setStatusView,
+ startStatsPolling,
+ stopStatsPolling,
+ };
+}
diff --git a/frontend/src/lib/admin/stores/themesStore.js b/frontend/src/lib/admin/stores/themesStore.js
index 25b7b41..6e8acfc 100644
--- a/frontend/src/lib/admin/stores/themesStore.js
+++ b/frontend/src/lib/admin/stores/themesStore.js
@@ -69,13 +69,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
body,
});
if (data?.ok) {
- flash(
- at(
- "appearance_logo_uploaded_pending",
- {},
- "Логотип загружен и применен."
- )
- );
+ flash(at("appearance_logo_uploaded_pending", {}, "Логотип загружен и применен."));
return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" };
}
flash(
@@ -99,13 +93,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
body: JSON.stringify({ url: sourceUrl }),
});
if (data?.ok) {
- flash(
- at(
- "appearance_logo_uploaded_pending",
- {},
- "Логотип загружен и применен."
- )
- );
+ flash(at("appearance_logo_uploaded_pending", {}, "Логотип загружен и применен."));
return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" };
}
flash(
@@ -130,13 +118,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
body,
});
if (data?.ok) {
- flash(
- at(
- "appearance_favicon_uploaded_pending",
- {},
- "Favicon загружена и применена."
- )
- );
+ flash(at("appearance_favicon_uploaded_pending", {}, "Favicon загружена и применена."));
return { faviconUrl: data.favicon_url || "", variants: data.variants || {} };
}
flash(
@@ -160,13 +142,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
body: JSON.stringify({ url: sourceUrl }),
});
if (data?.ok) {
- flash(
- at(
- "appearance_favicon_uploaded_pending",
- {},
- "Favicon загружена и применена."
- )
- );
+ flash(at("appearance_favicon_uploaded_pending", {}, "Favicon загружена и применена."));
return { faviconUrl: data.favicon_url || "", variants: data.variants || {} };
}
flash(
diff --git a/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte b/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte
index 1f50900..d2d71d7 100644
--- a/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte
+++ b/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte
@@ -68,7 +68,7 @@
const accent = readCssColor("--accent", "#00fe7a");
const lineStroke = readCssColor(
"--admin-chart-stroke",
- readCssColor("--admin-text", "#e8f0ec"),
+ readCssColor("--admin-text", "#e8f0ec")
);
const lineFill = readCssColor("--admin-chart-fill", "rgba(120, 140, 132, 0.14)");
diff --git a/frontend/src/lib/components/patterns/admin/SupportComposer.svelte b/frontend/src/lib/components/patterns/admin/SupportComposer.svelte
new file mode 100644
index 0000000..dab1191
--- /dev/null
+++ b/frontend/src/lib/components/patterns/admin/SupportComposer.svelte
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if sending}{:else}{/if}
+ {at("send", {}, "Отправить")}
+
+
+
diff --git a/frontend/src/lib/components/patterns/admin/SupportInboxRow.svelte b/frontend/src/lib/components/patterns/admin/SupportInboxRow.svelte
new file mode 100644
index 0000000..cf044be
--- /dev/null
+++ b/frontend/src/lib/components/patterns/admin/SupportInboxRow.svelte
@@ -0,0 +1,84 @@
+
+
+
diff --git a/frontend/src/lib/components/patterns/admin/SupportTicketHeader.svelte b/frontend/src/lib/components/patterns/admin/SupportTicketHeader.svelte
new file mode 100644
index 0000000..347a9cc
--- /dev/null
+++ b/frontend/src/lib/components/patterns/admin/SupportTicketHeader.svelte
@@ -0,0 +1,79 @@
+
+
+{#if ticket}
+
+{/if}
diff --git a/frontend/src/lib/components/patterns/admin/SupportUserContextPanel.svelte b/frontend/src/lib/components/patterns/admin/SupportUserContextPanel.svelte
new file mode 100644
index 0000000..43927d2
--- /dev/null
+++ b/frontend/src/lib/components/patterns/admin/SupportUserContextPanel.svelte
@@ -0,0 +1,75 @@
+
+
+
+
+
+ {#if avatarUrl}
+
+ {:else}
+ {avatarInitials}
+ {/if}
+
+
+
{displayName}
+
{identityMeta}
+ {#if user.is_banned}
+
{at("status_banned", {}, "Бан")}
+ {/if}
+
+
+
+
+ {#each contextItems as item (item.label)}
+
+ {item.label}
+ {item.value}
+
+ {/each}
+
+
+
+
(window.location.href = `/admin/users/${user.user_id}`)}
+ aria-label={at("support_open_user", {}, "Карточка")}
+ title={at("support_open_user", {}, "Карточка")}
+ >
+
+
+
+
diff --git a/frontend/src/lib/components/patterns/admin/index.js b/frontend/src/lib/components/patterns/admin/index.js
index 60836e5..b7bfe8b 100644
--- a/frontend/src/lib/components/patterns/admin/index.js
+++ b/frontend/src/lib/components/patterns/admin/index.js
@@ -12,3 +12,7 @@ export { default as AdminSectionHeader } from "./AdminSectionHeader.svelte";
export { default as AdminTable } from "./AdminTable.svelte";
export { default as AdminTableSkeleton } from "./AdminTableSkeleton.svelte";
export { default as AdminTrafficCard } from "./AdminTrafficCard.svelte";
+export { default as SupportComposer } from "./SupportComposer.svelte";
+export { default as SupportInboxRow } from "./SupportInboxRow.svelte";
+export { default as SupportTicketHeader } from "./SupportTicketHeader.svelte";
+export { default as SupportUserContextPanel } from "./SupportUserContextPanel.svelte";
diff --git a/frontend/src/lib/components/patterns/webapp/TicketCard.svelte b/frontend/src/lib/components/patterns/webapp/TicketCard.svelte
new file mode 100644
index 0000000..a981a9c
--- /dev/null
+++ b/frontend/src/lib/components/patterns/webapp/TicketCard.svelte
@@ -0,0 +1,59 @@
+
+
+
diff --git a/frontend/src/lib/components/patterns/webapp/TicketComposer.svelte b/frontend/src/lib/components/patterns/webapp/TicketComposer.svelte
new file mode 100644
index 0000000..edfe81d
--- /dev/null
+++ b/frontend/src/lib/components/patterns/webapp/TicketComposer.svelte
@@ -0,0 +1,41 @@
+
+
+
+
+
+ {value.length}/{maxLength}
+
+
+
diff --git a/frontend/src/lib/components/patterns/webapp/TicketMessageBubble.svelte b/frontend/src/lib/components/patterns/webapp/TicketMessageBubble.svelte
new file mode 100644
index 0000000..0781f44
--- /dev/null
+++ b/frontend/src/lib/components/patterns/webapp/TicketMessageBubble.svelte
@@ -0,0 +1,79 @@
+
+
+
+
+ {#if isInternalNote}
+
+ {:else if showSupportAvatar}
+
+ {:else if showUserAvatar && userAvatarUrl}
+
+ {:else if showUserAvatar && userInitials}
+ {userInitials}
+ {:else if messageRole === "admin"}
+
+ {:else if messageRole === "user"}
+
+ {:else}
+
+ {/if}
+
+
+
+
+ {roleLabel}
+ {#if timeLabel}
+
+ {/if}
+
+
+
+
+
diff --git a/frontend/src/lib/components/patterns/webapp/index.js b/frontend/src/lib/components/patterns/webapp/index.js
index 564a40e..39b9439 100644
--- a/frontend/src/lib/components/patterns/webapp/index.js
+++ b/frontend/src/lib/components/patterns/webapp/index.js
@@ -4,3 +4,6 @@ export { default as LinearProgress } from "./LinearProgress.svelte";
export { default as LanguageSelect } from "./LanguageSelect.svelte";
export { default as PaymentMethodGrid } from "./PaymentMethodGrid.svelte";
export { default as StatusMessage } from "./StatusMessage.svelte";
+export { default as TicketCard } from "./TicketCard.svelte";
+export { default as TicketComposer } from "./TicketComposer.svelte";
+export { default as TicketMessageBubble } from "./TicketMessageBubble.svelte";
diff --git a/frontend/src/lib/components/ui/attention-dot.svelte b/frontend/src/lib/components/ui/attention-dot.svelte
new file mode 100644
index 0000000..4a5736b
--- /dev/null
+++ b/frontend/src/lib/components/ui/attention-dot.svelte
@@ -0,0 +1,14 @@
+
+
+
diff --git a/frontend/src/lib/components/ui/icons.js b/frontend/src/lib/components/ui/icons.js
index 18ea026..2c509e6 100644
--- a/frontend/src/lib/components/ui/icons.js
+++ b/frontend/src/lib/components/ui/icons.js
@@ -5,6 +5,7 @@ export {
Bitcoin,
CalendarDays,
Check,
+ CheckCheck,
CheckCircle2,
ChevronDown,
ChevronLeft,
@@ -28,12 +29,15 @@ export {
Info,
Key,
LayoutDashboard,
+ LifeBuoy,
+ Lock,
LockKeyhole,
Mail,
Map,
Megaphone,
Menu,
MessageSquare,
+ MessageSquarePlus,
MousePointerClick,
Paintbrush,
Plus,
diff --git a/frontend/src/lib/components/ui/index.js b/frontend/src/lib/components/ui/index.js
index 1ddf776..10341cc 100644
--- a/frontend/src/lib/components/ui/index.js
+++ b/frontend/src/lib/components/ui/index.js
@@ -1,3 +1,4 @@
+export { default as AttentionDot } from "./attention-dot.svelte";
export { default as Badge } from "./badge.svelte";
export { default as Button } from "./button.svelte";
export { default as Dialog } from "./dialog.svelte";
@@ -5,6 +6,8 @@ export { default as Input } from "./input.svelte";
export { default as LegacyCard } from "./card.svelte";
export { default as Skeleton } from "./skeleton.svelte";
export { default as Spinner } from "./spinner.svelte";
+export { default as ScrollArea } from "./scroll-area.svelte";
+export { default as Textarea } from "./textarea.svelte";
export * as Icons from "./icons.js";
export * as Card from "./card/index.js";
export { Accordion, Label, Select, Separator, Switch, Tabs, Tooltip } from "./primitives.js";
diff --git a/frontend/src/lib/components/ui/scroll-area.svelte b/frontend/src/lib/components/ui/scroll-area.svelte
new file mode 100644
index 0000000..18d62a3
--- /dev/null
+++ b/frontend/src/lib/components/ui/scroll-area.svelte
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/ui/textarea.svelte b/frontend/src/lib/components/ui/textarea.svelte
new file mode 100644
index 0000000..18a12fd
--- /dev/null
+++ b/frontend/src/lib/components/ui/textarea.svelte
@@ -0,0 +1,23 @@
+
+
+
diff --git a/frontend/src/lib/webapp/constants.js b/frontend/src/lib/webapp/constants.js
index 70664b6..f85a061 100644
--- a/frontend/src/lib/webapp/constants.js
+++ b/frontend/src/lib/webapp/constants.js
@@ -22,6 +22,7 @@ export const APP_SECTION_PATHS = {
home: "/home",
invite: "/invite",
devices: "/devices",
+ support: "/support",
settings: "/settings",
admin: "/admin",
};
@@ -33,6 +34,7 @@ export const ADMIN_SECTIONS = new Set([
"ads",
"broadcast",
"logs",
+ "support",
"tariffs",
"appearance",
"settings",
diff --git a/frontend/src/lib/webapp/i18n.js b/frontend/src/lib/webapp/i18n.js
index 685c9a6..192b74d 100644
--- a/frontend/src/lib/webapp/i18n.js
+++ b/frontend/src/lib/webapp/i18n.js
@@ -2,7 +2,24 @@ import { LANGUAGE_LABELS } from "./constants.js";
import { formatTemplate, formatFraction, roundToHalf } from "./formatters.js";
import { unitPluralBucket } from "./plurals.js";
-export function createI18n({ messages = {}, defaultLang = "ru", getLang = null } = {}) {
+export function createI18n({
+ messages: initialMessages = {},
+ defaultLang = "ru",
+ getLang = null,
+} = {}) {
+ const messages = {};
+
+ function mergeMessages(nextMessages = {}) {
+ if (!nextMessages || typeof nextMessages !== "object") return messages;
+ for (const [lang, bucket] of Object.entries(nextMessages)) {
+ if (!bucket || typeof bucket !== "object") continue;
+ messages[lang] = { ...(messages[lang] || {}), ...bucket };
+ }
+ return messages;
+ }
+
+ mergeMessages(initialMessages);
+
function normalizeLangCode(lang) {
const key = String(lang || "")
.trim()
@@ -45,7 +62,7 @@ export function createI18n({ messages = {}, defaultLang = "ru", getLang = null }
return t(`wa_sub_term_${unit}_${bucket}`);
}
- return { normalizeLangCode, t, currentLang, languageName, termUnitLabel };
+ return { normalizeLangCode, t, currentLang, languageName, termUnitLabel, mergeMessages };
}
export { formatFraction, roundToHalf };
diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js
index 4e4e513..21d575d 100644
--- a/frontend/src/lib/webapp/mockApi.js
+++ b/frontend/src/lib/webapp/mockApi.js
@@ -66,6 +66,150 @@ export async function mockApi(path, options = {}, context = {}) {
premium_traffic: { state: "none" },
},
];
+ const supportTickets = [
+ {
+ ticket_id: 42,
+ user_id: 100200300,
+ subject: "Не подключается профиль на телефоне",
+ category: "technical",
+ priority: "high",
+ status: "awaiting_admin",
+ unread_user_count: 0,
+ unread_admin_count: 2,
+ last_message_at: new Date(Date.now() - 18 * 60000).toISOString(),
+ created_at: new Date(Date.now() - 2 * 3600000).toISOString(),
+ user: adminUsers[0],
+ },
+ {
+ ticket_id: 43,
+ user_id: 100200300,
+ subject: "Вопрос по оплате подписки",
+ category: "billing",
+ priority: "normal",
+ status: "awaiting_user",
+ unread_user_count: 1,
+ unread_admin_count: 0,
+ last_message_at: new Date(Date.now() - 4 * 3600000).toISOString(),
+ created_at: new Date(Date.now() - 6 * 3600000).toISOString(),
+ user: adminUsers[0],
+ },
+ {
+ ticket_id: 41,
+ user_id: 100200300,
+ subject: "Закрытый вопрос по старому профилю",
+ category: "technical",
+ priority: "low",
+ status: "closed",
+ unread_user_count: 0,
+ unread_admin_count: 0,
+ last_message_at: new Date(Date.now() - 4 * 86400000).toISOString(),
+ created_at: new Date(Date.now() - 6 * 86400000).toISOString(),
+ closed_at: new Date(Date.now() - 4 * 86400000).toISOString(),
+ user: adminUsers[0],
+ },
+ ];
+ function supportCounts(items = supportTickets) {
+ const byStatus = { open: 0, awaiting_admin: 0, awaiting_user: 0, resolved: 0 };
+ for (const item of items) {
+ byStatus[item.status] = (byStatus[item.status] || 0) + 1;
+ }
+ const closed = (byStatus.closed || 0) + (byStatus.resolved || 0);
+ const active = items.length - closed;
+ return { ...byStatus, active, closed, total: items.length };
+ }
+ function filterSupportTickets(items, params) {
+ let out = [...items];
+ const status = params.get("status");
+ if (status === "active")
+ out = out.filter((item) => !["closed", "resolved"].includes(item.status));
+ else if (status === "closed")
+ out = out.filter((item) => ["closed", "resolved"].includes(item.status));
+ else if (status) out = out.filter((item) => item.status === status);
+ const priority = params.get("priority");
+ if (priority) out = out.filter((item) => item.priority === priority);
+ const category = params.get("category");
+ if (category) out = out.filter((item) => item.category === category);
+ const search = (params.get("search") || "").trim().toLowerCase();
+ if (search) {
+ out = out.filter((item) =>
+ [item.subject, item.user?.username, item.user?.email, String(item.ticket_id)]
+ .filter(Boolean)
+ .some((value) => String(value).toLowerCase().includes(search))
+ );
+ }
+ const sort = params.get("sort") || "updated_desc";
+ const priorityRank = { urgent: 4, high: 3, normal: 2, low: 1 };
+ out.sort((a, b) => {
+ if (sort === "importance_desc") {
+ return (
+ (priorityRank[b.priority] || 0) - (priorityRank[a.priority] || 0) ||
+ new Date(b.last_message_at || b.created_at) - new Date(a.last_message_at || a.created_at)
+ );
+ }
+ if (sort === "updated_asc") {
+ return (
+ new Date(a.last_message_at || a.created_at) - new Date(b.last_message_at || b.created_at)
+ );
+ }
+ if (sort === "created_desc") return new Date(b.created_at) - new Date(a.created_at);
+ if (sort === "created_asc") return new Date(a.created_at) - new Date(b.created_at);
+ return (
+ new Date(b.last_message_at || b.created_at) - new Date(a.last_message_at || a.created_at)
+ );
+ });
+ return out;
+ }
+ const supportMessages = {
+ 42: [
+ {
+ message_id: 1,
+ ticket_id: 42,
+ author_role: "user",
+ author_user_id: 100200300,
+ author_name: "Анна Смирнова",
+ body: "После обновления приложения профиль перестал подключаться. Ошибка появляется сразу после импорта ссылки.",
+ created_at: new Date(Date.now() - 2 * 3600000).toISOString(),
+ },
+ {
+ message_id: 2,
+ ticket_id: 42,
+ author_role: "admin",
+ author_user_id: 1,
+ author_name: "Мария, поддержка",
+ body: "Проверили подписку, она активна. Попробуйте удалить старый профиль и импортировать ссылку ещё раз.",
+ created_at: new Date(Date.now() - 90 * 60000).toISOString(),
+ },
+ {
+ message_id: 3,
+ ticket_id: 42,
+ author_role: "user",
+ author_user_id: 100200300,
+ author_name: "Анна Смирнова",
+ body: "Сделал так, но теперь вижу timeout. Телефон iPhone, сеть домашний Wi‑Fi.",
+ created_at: new Date(Date.now() - 18 * 60000).toISOString(),
+ },
+ ],
+ 43: [
+ {
+ message_id: 4,
+ ticket_id: 43,
+ author_role: "user",
+ author_user_id: 100200300,
+ author_name: "Анна Смирнова",
+ body: "Оплата прошла, но срок подписки не изменился.",
+ created_at: new Date(Date.now() - 6 * 3600000).toISOString(),
+ },
+ {
+ message_id: 5,
+ ticket_id: 43,
+ author_role: "admin",
+ author_user_id: 2,
+ author_name: "Иван, поддержка",
+ body: "Платёж нашли и применили вручную. Проверьте, пожалуйста, дату окончания подписки.",
+ created_at: new Date(Date.now() - 4 * 3600000).toISOString(),
+ },
+ ],
+ };
const mockAdminDailySeries = (() => {
const days = 730;
const out = [];
@@ -331,8 +475,134 @@ export async function mockApi(path, options = {}, context = {}) {
},
],
};
+ if (cleanPath === "/admin/support/stats") {
+ return {
+ ok: true,
+ stats: { ...supportCounts(), total_unread_admin: 2 },
+ };
+ }
+ if (cleanPath === "/admin/support/tickets") {
+ const params = new URLSearchParams(String(path || "").split("?")[1] || "");
+ const tickets = filterSupportTickets(supportTickets, params);
+ return { ok: true, tickets: clone(tickets), total: tickets.length };
+ }
+ if (cleanPath.startsWith("/admin/support/tickets/")) {
+ const parts = cleanPath.split("/");
+ const ticketId = Number(parts[4]);
+ const ticket = clone(
+ supportTickets.find((item) => item.ticket_id === ticketId) || supportTickets[0]
+ );
+ if (parts[5] === "messages") {
+ return {
+ ok: true,
+ ticket,
+ message: {
+ message_id: Date.now(),
+ ticket_id: ticket.ticket_id,
+ author_role: "admin",
+ author_user_id: 1,
+ author_name: "Мария, поддержка",
+ body: JSON.parse(options?.body || "{}")?.body || "",
+ is_internal_note: Boolean(JSON.parse(options?.body || "{}")?.is_internal_note),
+ created_at: new Date().toISOString(),
+ },
+ };
+ }
+ if (String(options.method || "GET").toUpperCase() === "PATCH") {
+ return { ok: true, ticket: { ...ticket, ...(JSON.parse(options?.body || "{}") || {}) } };
+ }
+ return {
+ ok: true,
+ ticket,
+ messages: clone([
+ ...(supportMessages[ticket.ticket_id] || []),
+ {
+ message_id: 99,
+ ticket_id: ticket.ticket_id,
+ author_role: "admin",
+ author_user_id: 1,
+ author_name: "Мария, поддержка",
+ body: "Внутренняя заметка для команды: проверить последние логи панели перед ответом.",
+ is_internal_note: true,
+ created_at: new Date(Date.now() - 12 * 60000).toISOString(),
+ },
+ ]),
+ user_snapshot: {
+ user_id: ticket.user_id,
+ name: "Анна Смирнова",
+ username: "anna_ops",
+ email: "anna@example.com",
+ tariff: "Standard",
+ panel_status: "ACTIVE",
+ remaining: "20 д. 4 ч.",
+ regular_traffic: "12 GB / 500 GB",
+ premium_traffic: "4 GB / 25 GB",
+ },
+ };
+ }
if (cleanPath.startsWith("/admin/"))
return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 };
+ if (
+ cleanPath === "/support/tickets" &&
+ String(options.method || "GET").toUpperCase() === "POST"
+ ) {
+ let payload = {};
+ try {
+ payload = JSON.parse(options?.body || "{}");
+ } catch (_error) {
+ void _error;
+ }
+ return {
+ ok: true,
+ ticket: {
+ ticket_id: 44,
+ user_id: 100200300,
+ subject: payload.subject || "Новое обращение",
+ category: payload.category || "other",
+ priority: payload.priority || "normal",
+ status: "awaiting_admin",
+ unread_user_count: 0,
+ unread_admin_count: 1,
+ last_message_at: new Date().toISOString(),
+ created_at: new Date().toISOString(),
+ },
+ };
+ }
+ if (cleanPath === "/support/tickets") {
+ const params = new URLSearchParams(String(path || "").split("?")[1] || "");
+ const tickets = filterSupportTickets(supportTickets, params);
+ return {
+ ok: true,
+ tickets: clone(tickets),
+ total: tickets.length,
+ counts: supportCounts(),
+ };
+ }
+ if (cleanPath.startsWith("/support/tickets/")) {
+ const parts = cleanPath.split("/");
+ const ticketId = Number(parts[3]);
+ const ticket = clone(
+ supportTickets.find((item) => item.ticket_id === ticketId) || supportTickets[0]
+ );
+ if (parts[4] === "read") return { ok: true };
+ if (parts[4] === "messages") {
+ return {
+ ok: true,
+ ticket,
+ message: {
+ message_id: Date.now(),
+ ticket_id: ticket.ticket_id,
+ author_role: "user",
+ author_user_id: 100200300,
+ author_name: "Анна Смирнова",
+ body: JSON.parse(options?.body || "{}")?.body || "",
+ created_at: new Date().toISOString(),
+ },
+ };
+ }
+ return { ok: true, ticket, messages: clone(supportMessages[ticket.ticket_id] || []) };
+ }
+ if (cleanPath === "/support/unread") return { ok: true, unread: 1 };
if (path === "/me") return clone(DEV_MOCK.data);
if (path === "/auth/email/request") return { ok: true };
if (path === "/auth/email/verify" || path === "/auth/email/magic") {
diff --git a/frontend/src/lib/webapp/routes.js b/frontend/src/lib/webapp/routes.js
index df1299a..ed0647b 100644
--- a/frontend/src/lib/webapp/routes.js
+++ b/frontend/src/lib/webapp/routes.js
@@ -7,6 +7,7 @@ export function normalizeSection(value) {
if (
section === "invite" ||
section === "devices" ||
+ section === "support" ||
section === "settings" ||
section === "admin"
) {
@@ -22,6 +23,7 @@ export function sectionFromPath(pathname) {
.replace(/\/+$/, "");
if (!normalizedPath || normalizedPath === "/") return "home";
if (normalizedPath === "/admin" || normalizedPath.startsWith("/admin/")) return "admin";
+ if (normalizedPath === "/support" || normalizedPath.startsWith("/support/")) return "support";
const section = normalizedPath.startsWith("/") ? normalizedPath.slice(1) : normalizedPath;
return normalizeSection(section);
}
@@ -43,6 +45,22 @@ export function adminUserIdFromPath(pathname) {
return m ? Number(m[1]) : null;
}
+export function supportTicketIdFromPath(pathname) {
+ const normalized = String(pathname || "")
+ .toLowerCase()
+ .replace(/\/+$/, "");
+ const m = normalized.match(/^\/support\/(\d+)$/);
+ return m ? Number(m[1]) : null;
+}
+
+export function adminSupportTicketIdFromPath(pathname) {
+ const normalized = String(pathname || "")
+ .toLowerCase()
+ .replace(/\/+$/, "");
+ const m = normalized.match(/^\/admin\/support\/(\d+)$/);
+ return m ? Number(m[1]) : null;
+}
+
export function syncSectionPath(section, replace = false, adminSection = null, adminUserId = null) {
if (window.location.protocol === "file:") return;
const normalized = normalizeSection(section);
@@ -51,7 +69,11 @@ export function syncSectionPath(section, replace = false, adminSection = null, a
const adm = adminSection || adminSectionFromPath(window.location.pathname) || "stats";
const uid =
adminUserId ?? (adm === "users" ? adminUserIdFromPath(window.location.pathname) : null);
- targetPath = adm === "users" && uid ? `/admin/users/${uid}` : `/admin/${adm}`;
+ const supportTicketId =
+ adm === "support" ? adminSupportTicketIdFromPath(window.location.pathname) : null;
+ if (adm === "users" && uid) targetPath = `/admin/users/${uid}`;
+ else if (adm === "support" && supportTicketId) targetPath = `/admin/support/${supportTicketId}`;
+ else targetPath = `/admin/${adm}`;
}
if (window.location.pathname === targetPath) return;
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
diff --git a/frontend/src/lib/webapp/stores/supportStore.js b/frontend/src/lib/webapp/stores/supportStore.js
new file mode 100644
index 0000000..ee6cd19
--- /dev/null
+++ b/frontend/src/lib/webapp/stores/supportStore.js
@@ -0,0 +1,287 @@
+import { writable } from "svelte/store";
+
+export function createSupportStore({ api, t, showToast }) {
+ const state = writable({
+ tickets: [],
+ openedTicketId: null,
+ openedTicket: null,
+ messages: [],
+ unreadCount: 0,
+ unreadLoaded: false,
+ unreadLoading: false,
+ counts: { active: 0, closed: 0, awaiting_admin: 0, awaiting_user: 0, open: 0, total: 0 },
+ loading: false,
+ detailLoading: false,
+ sending: false,
+ creating: false,
+ statusFilter: "active",
+ polling: false,
+ });
+
+ let pollTimer = null;
+ let pollIncludeList = false;
+ let listRequestSeq = 0;
+ let listPromise = null;
+ let listPromiseKey = "";
+ let unreadPromise = null;
+
+ function hydrateUnread(value) {
+ const next = Math.max(0, Number(value || 0));
+ state.update((s) => ({
+ ...s,
+ unreadCount: next,
+ unreadLoaded: true,
+ unreadLoading: false,
+ }));
+ }
+
+ async function loadList(options = {}) {
+ let filter = "all";
+ let hasTickets = false;
+ state.update((s) => {
+ filter = s.statusFilter;
+ hasTickets = Boolean(s.tickets?.length);
+ return s;
+ });
+ const requestKey = filter || "all";
+ if (!options.force && listPromise && listPromiseKey === requestKey) return listPromise;
+
+ const requestId = ++listRequestSeq;
+ const showLoading = !options.silent && (options.showLoading || !hasTickets);
+ if (showLoading) state.update((s) => ({ ...s, loading: true }));
+
+ let promise;
+ promise = (async () => {
+ try {
+ const params = new URLSearchParams({ limit: "50", offset: "0" });
+ if (filter && filter !== "all") params.set("status", filter);
+ const res = await api(`/support/tickets?${params.toString()}`);
+ if (requestId !== listRequestSeq) return res;
+ if (res?.ok)
+ state.update((s) => ({
+ ...s,
+ tickets: res.tickets || [],
+ counts: res.counts || s.counts,
+ }));
+ else if (res?.error) showToast(res.message || res.error);
+ return res;
+ } finally {
+ if (requestId === listRequestSeq) {
+ state.update((s) => (s.loading ? { ...s, loading: false } : s));
+ }
+ if (listPromise === promise) {
+ listPromise = null;
+ listPromiseKey = "";
+ }
+ }
+ })();
+
+ listPromise = promise;
+ listPromiseKey = requestKey;
+ return promise;
+ }
+
+ async function refreshCurrentTicket(ticketId) {
+ const id = Number(ticketId);
+ if (!id) return;
+ try {
+ const res = await api(`/support/tickets/${id}`);
+ if (res?.ok) {
+ state.update((s) => ({
+ ...s,
+ openedTicket: res.ticket,
+ messages: res.messages || [],
+ }));
+ await markRead(id);
+ }
+ return res;
+ } catch {
+ return null;
+ }
+ }
+
+ async function createTicket(payload) {
+ state.update((s) => ({ ...s, creating: true }));
+ try {
+ const res = await api("/support/tickets", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+ if (!res?.ok) throw res;
+ state.update((s) => ({ ...s, statusFilter: "active" }));
+ await loadList({ silent: true, force: true });
+ await openTicket(res.ticket.ticket_id);
+ return res.ticket;
+ } catch (error) {
+ showToast(error?.message || t("wa_support_create_failed"));
+ return null;
+ } finally {
+ state.update((s) => ({ ...s, creating: false }));
+ }
+ }
+
+ async function openTicket(ticketId, opts = {}) {
+ const id = Number(ticketId);
+ if (!id) return;
+ state.update((s) => ({
+ ...s,
+ openedTicketId: id,
+ openedTicket: s.openedTicket?.ticket_id === id ? s.openedTicket : null,
+ messages: s.openedTicket?.ticket_id === id ? s.messages : [],
+ detailLoading: true,
+ }));
+ if (!opts.skipPush && typeof window !== "undefined" && window.location.protocol !== "file:") {
+ const target = `/support/${id}`;
+ if (window.location.pathname !== target) {
+ window.history.pushState(
+ null,
+ "",
+ `${target}${window.location.search}${window.location.hash}`
+ );
+ }
+ }
+ try {
+ const res = await api(`/support/tickets/${id}`);
+ if (res?.ok) {
+ state.update((s) => ({
+ ...s,
+ openedTicket: res.ticket,
+ messages: res.messages || [],
+ }));
+ await markRead(id);
+ } else {
+ showToast(res?.message || res?.error || "not_found");
+ }
+ } finally {
+ state.update((s) => ({ ...s, detailLoading: false }));
+ }
+ }
+
+ function closeTicketView(opts = {}) {
+ state.update((s) => ({ ...s, openedTicketId: null, openedTicket: null, messages: [] }));
+ if (!opts.skipPush && typeof window !== "undefined" && window.location.protocol !== "file:") {
+ if (window.location.pathname.startsWith("/support/")) {
+ window.history.pushState(
+ null,
+ "",
+ `/support${window.location.search}${window.location.hash}`
+ );
+ }
+ }
+ }
+
+ async function sendReply(body) {
+ let ticketId = null;
+ state.update((s) => {
+ ticketId = s.openedTicketId;
+ return { ...s, sending: true };
+ });
+ if (!ticketId) return;
+ try {
+ const res = await api(`/support/tickets/${ticketId}/messages`, {
+ method: "POST",
+ body: JSON.stringify({ body }),
+ });
+ if (!res?.ok) throw res;
+ state.update((s) => ({
+ ...s,
+ openedTicket: res.ticket || s.openedTicket,
+ messages: [...s.messages, res.message],
+ }));
+ await refreshUnread();
+ await loadList({ silent: true, force: true });
+ } catch (error) {
+ showToast(error?.message || t("wa_support_send_failed"));
+ } finally {
+ state.update((s) => ({ ...s, sending: false }));
+ }
+ }
+
+ async function markRead(ticketId = null) {
+ const id =
+ ticketId ||
+ (() => {
+ let current = null;
+ state.update((s) => {
+ current = s.openedTicketId;
+ return s;
+ });
+ return current;
+ })();
+ if (!id) return;
+ await api(`/support/tickets/${id}/read`, { method: "POST", body: "{}" });
+ await refreshUnread();
+ }
+
+ async function refreshUnread() {
+ if (unreadPromise) return unreadPromise;
+ state.update((s) => ({ ...s, unreadLoading: true }));
+ unreadPromise = (async () => {
+ try {
+ const res = await api("/support/unread");
+ if (res?.ok) {
+ state.update((s) => ({
+ ...s,
+ unreadCount: Math.max(0, Number(res.unread || 0)),
+ unreadLoaded: true,
+ }));
+ }
+ return res;
+ } finally {
+ state.update((s) => ({ ...s, unreadLoading: false }));
+ unreadPromise = null;
+ }
+ })();
+ return unreadPromise;
+ }
+
+ function setStatusFilter(status) {
+ state.update((s) => ({ ...s, statusFilter: status || "all" }));
+ loadList({ force: true, showLoading: true });
+ }
+
+ function startPolling(options = {}) {
+ const includeList = options.includeList !== false;
+ pollIncludeList = pollIncludeList || includeList;
+ if (pollTimer || typeof window === "undefined") return;
+ state.update((s) => ({ ...s, polling: true }));
+ const tick = async () => {
+ if (document.visibilityState === "visible") {
+ await refreshUnread();
+ if (!pollIncludeList) return;
+ let opened = null;
+ state.update((s) => {
+ opened = s.openedTicketId;
+ return s;
+ });
+ if (opened) await refreshCurrentTicket(opened);
+ else await loadList({ silent: true });
+ }
+ };
+ pollTimer = window.setInterval(tick, 15000);
+ document.addEventListener("visibilitychange", tick);
+ }
+
+ function closePolling() {
+ if (pollTimer) window.clearInterval(pollTimer);
+ pollTimer = null;
+ pollIncludeList = false;
+ state.update((s) => ({ ...s, polling: false }));
+ }
+
+ return {
+ subscribe: state.subscribe,
+ update: state.update,
+ loadList,
+ hydrateUnread,
+ createTicket,
+ openTicket,
+ closeTicketView,
+ sendReply,
+ markRead,
+ refreshUnread,
+ setStatusFilter,
+ startPolling,
+ closePolling,
+ };
+}
diff --git a/frontend/src/main.js b/frontend/src/main.js
index e06f2b0..7472361 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -6,7 +6,7 @@ import "./styles.css";
async function loadBootstrap() {
if (document.getElementById("webapp-config")) return;
try {
- const response = await fetch("/api/bootstrap", {
+ const response = await fetch("/api/bootstrap?i18n_scope=webapp", {
credentials: "include",
headers: { Accept: "application/json" },
});
@@ -31,6 +31,7 @@ const target = document.getElementById("app");
if (target) {
loadBootstrap().finally(() => {
+ target.replaceChildren();
mount(App, { target });
});
}
diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css
index 4872d94..d5e2952 100644
--- a/frontend/src/styles/admin.css
+++ b/frontend/src/styles/admin.css
@@ -32,13 +32,37 @@
height: 100%;
display: flex;
flex-direction: column;
- gap: 2px;
- padding: 28px 14px;
+ gap: 1px;
+ padding: 22px 14px;
border-right: 1px solid var(--admin-border);
background: var(--rail-bg);
backdrop-filter: blur(14px);
box-shadow: inset -1px 0 0 var(--admin-border);
overflow-y: auto;
+ overflow-x: hidden;
+ scrollbar-gutter: stable;
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in srgb, var(--admin-muted) 34%, var(--admin-border)) transparent;
+}
+
+.admin-sidebar::-webkit-scrollbar {
+ width: 8px;
+}
+
+.admin-sidebar::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.admin-sidebar::-webkit-scrollbar-thumb {
+ border: 2px solid transparent;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--admin-muted) 34%, var(--admin-border));
+ background-clip: padding-box;
+}
+
+.admin-sidebar::-webkit-scrollbar-thumb:hover {
+ background: color-mix(in srgb, var(--admin-muted) 48%, var(--admin-border));
+ background-clip: padding-box;
}
.admin-sidebar > *:not(.admin-sidebar-footer) {
@@ -50,8 +74,8 @@
grid-template-columns: 38px minmax(0, 1fr) 34px;
align-items: center;
gap: 12px;
- padding: 4px 12px 18px;
- margin-bottom: 8px;
+ padding: 2px 10px 14px;
+ margin-bottom: 6px;
border-bottom: 1px solid var(--admin-border);
color: var(--admin-text);
}
@@ -89,7 +113,7 @@
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--admin-dim);
- padding: 14px 12px 6px;
+ padding: 10px 10px 5px;
}
.admin-nav {
@@ -102,8 +126,9 @@
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
align-items: center;
- gap: 12px;
- padding: 12px 14px;
+ gap: 10px;
+ min-height: 38px;
+ padding: 9px 12px;
border-radius: 10px;
border: 1px solid transparent;
background: transparent;
@@ -120,8 +145,34 @@
}
.admin-nav-item > svg {
- width: 20px;
- height: 20px;
+ width: 18px;
+ height: 18px;
+}
+
+.admin-nav-item > span:last-child {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ justify-content: flex-end;
+}
+
+.admin-nav-item .admin-badge.admin-badge-danger {
+ display: inline-grid;
+ box-sizing: border-box;
+ --numeric-badge-optical-y: 0.5px;
+ min-width: 22px;
+ height: 22px;
+ place-items: center;
+ border: 0;
+ background: var(--danger);
+ color: #fff;
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 1;
+ padding: 0 7px;
+ text-align: center;
+ font-variant-numeric: lining-nums;
+ font-feature-settings: "lnum";
}
.admin-nav-item:hover {
@@ -142,7 +193,7 @@
.admin-sidebar-footer {
margin-top: auto;
- padding: 14px 0 0;
+ padding: 10px 0 0;
border-top: 1px solid var(--admin-border);
font-size: 11px;
color: var(--admin-dim);
@@ -321,6 +372,11 @@
width: 100%;
}
+.admin-section-stage:has(.support-admin-layout) {
+ flex: 1 1 auto;
+ min-height: 0;
+}
+
.admin-card {
border: 1px solid var(--admin-border);
background: var(--admin-card-bg);
@@ -2864,6 +2920,13 @@
box-shadow 0.12s ease;
}
+.admin-select-trigger span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
.admin-select-trigger:hover {
border-color: var(--admin-border-strong);
}
@@ -3370,3 +3433,1678 @@
white-space: normal;
}
}
+.support-admin-layout {
+ display: grid;
+ grid-template-columns: minmax(300px, 360px) minmax(520px, 1fr) minmax(280px, 340px);
+ gap: 16px;
+ align-items: stretch;
+ min-height: min(820px, calc(100dvh - var(--admin-header-h) - 120px));
+}
+
+.support-admin-inbox,
+.support-admin-detail,
+.support-user-context {
+ display: grid;
+ gap: 12px;
+ min-width: 0;
+}
+
+.support-admin-inbox,
+.support-admin-detail {
+ min-height: 0;
+ align-self: stretch;
+}
+
+.support-admin-inbox {
+ grid-template-rows: auto minmax(0, 1fr);
+ border: 1px solid var(--admin-border);
+ border-radius: 14px;
+ background: color-mix(in srgb, var(--admin-surface) 88%, var(--admin-bg));
+ padding: 14px;
+}
+
+.support-admin-toolbar {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 10px;
+ align-items: stretch;
+}
+
+.support-admin-inbox .support-admin-toolbar {
+ padding: 0;
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+}
+
+.support-admin-search {
+ position: relative;
+ display: block;
+ min-width: 0;
+}
+
+.support-admin-search svg {
+ position: absolute;
+ top: 50%;
+ left: 12px;
+ color: var(--admin-muted);
+ transform: translateY(-50%);
+ pointer-events: none;
+}
+
+.support-admin-search .input {
+ width: 100%;
+ padding-left: 36px;
+}
+
+.support-admin-filter-row {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ align-items: center;
+}
+
+.support-admin-filter-row > :last-child {
+ grid-column: 1 / -1;
+}
+
+.support-inbox-list {
+ gap: 8px;
+ min-height: 0;
+ overflow-y: auto;
+ padding-right: 2px;
+ scrollbar-width: thin;
+}
+
+.support-inbox-row {
+ position: relative;
+ width: 100%;
+ display: grid;
+ grid-template-columns: 36px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 12px;
+ text-align: left;
+ border: 1px solid var(--admin-border);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--admin-surface) 72%, var(--admin-bg));
+ color: inherit;
+ padding: 12px 12px 12px 14px;
+ cursor: pointer;
+ transition:
+ border-color 0.16s ease,
+ background 0.16s ease,
+ box-shadow 0.16s ease;
+}
+
+.support-inbox-row::before {
+ content: "";
+ position: absolute;
+ inset: 10px auto 10px 5px;
+ width: 3px;
+ border-radius: 3px;
+ background: var(--admin-border-strong);
+ transition: background 0.16s ease;
+}
+
+.support-inbox-row[data-status="open"]::before,
+.support-inbox-row[data-status="awaiting_admin"]::before {
+ background: var(--accent);
+}
+
+.support-inbox-row[data-status="awaiting_user"]::before {
+ background: var(--info);
+}
+
+.support-inbox-row[data-status="resolved"]::before,
+.support-inbox-row[data-status="closed"]::before {
+ background: color-mix(in srgb, var(--muted) 70%, transparent);
+}
+
+.support-inbox-row[data-priority="urgent"]::before {
+ background: var(--danger);
+}
+
+.support-inbox-row:hover {
+ border-color: color-mix(in srgb, var(--accent) 38%, var(--admin-border));
+ background: color-mix(in srgb, var(--admin-surface) 78%, var(--accent) 6%);
+}
+
+.support-inbox-row.active {
+ border-color: color-mix(in srgb, var(--accent) 60%, var(--admin-border));
+ background: color-mix(in srgb, var(--admin-surface) 82%, var(--accent) 10%);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent);
+}
+
+.support-inbox-row-avatar {
+ display: grid;
+ width: 36px;
+ height: 36px;
+ place-items: center;
+ overflow: hidden;
+ border: 1px solid var(--admin-border);
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--admin-surface-2) 80%, transparent);
+ color: var(--admin-muted);
+ font-size: 12px;
+ font-weight: 800;
+ letter-spacing: 0.02em;
+}
+
+.support-inbox-row-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.support-inbox-row-main {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+}
+
+.support-inbox-row-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.support-inbox-row-title svg {
+ flex: 0 0 auto;
+ color: color-mix(in srgb, var(--accent) 72%, var(--admin-muted));
+}
+
+.support-inbox-row-title strong {
+ overflow: hidden;
+ font-size: 14px;
+ font-weight: 800;
+ letter-spacing: -0.01em;
+ line-height: 1.25;
+ color: var(--admin-text);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.support-inbox-row-main > small {
+ display: block;
+ margin: 0;
+ color: var(--admin-muted);
+ font-size: 12px;
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.support-row-badges {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 6px;
+ flex: 0 0 auto;
+}
+
+.support-row-badges small {
+ margin: 0;
+ color: var(--admin-muted);
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.support-row-badges b {
+ display: inline-grid;
+ box-sizing: border-box;
+ --numeric-badge-optical-y: 0.5px;
+ min-width: 22px;
+ height: 22px;
+ place-items: center;
+ border-radius: 999px;
+ background: var(--danger);
+ color: #fff;
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 1;
+ padding: 0 7px;
+ text-align: center;
+ font-variant-numeric: lining-nums;
+ font-feature-settings: "lnum";
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 22%, transparent);
+}
+
+.support-ticket-header {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 14px 16px;
+ align-items: start;
+ border: 1px solid var(--admin-border);
+ border-radius: 14px;
+ background: color-mix(in srgb, var(--admin-surface) 88%, var(--admin-bg));
+ padding: 16px;
+}
+
+.support-ticket-title {
+ display: grid;
+ gap: 8px;
+ min-width: 0;
+}
+
+.support-ticket-title small,
+.support-ticket-controls label > span {
+ color: var(--admin-muted);
+ font-size: 11px;
+ font-weight: 800;
+ line-height: 1.2;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.support-ticket-title h3 {
+ margin: 0;
+ overflow-wrap: anywhere;
+ font-size: 19px;
+ font-weight: 800;
+ letter-spacing: -0.01em;
+ line-height: 1.25;
+ color: var(--admin-text);
+}
+
+.support-ticket-title-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.support-ticket-close {
+ justify-self: end;
+ align-self: start;
+ white-space: nowrap;
+}
+
+.support-ticket-controls {
+ grid-column: 1 / -1;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(140px, 1fr));
+ gap: 10px;
+ padding-top: 12px;
+ border-top: 1px solid var(--admin-border);
+}
+
+.support-ticket-controls label {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+}
+
+.support-admin-message-scroll {
+ border-color: var(--admin-border);
+ background: color-mix(in srgb, var(--admin-bg) 70%, transparent);
+}
+
+.support-admin-messages .ticket-message-content {
+ max-width: min(78%, 760px);
+}
+
+.support-admin-messages .ticket-message-bubble {
+ --ticket-bubble-border: var(--admin-border);
+ border-color: var(--admin-border);
+}
+
+.support-admin-composer {
+ display: grid;
+ gap: 10px;
+ margin-top: 12px;
+ border: 1px solid var(--admin-border);
+ border-radius: 14px;
+ background: var(--admin-surface);
+ padding: 14px;
+ transition: border-color 0.16s ease;
+}
+
+.support-admin-composer:focus-within {
+ border-color: color-mix(in srgb, var(--accent) 48%, var(--admin-border));
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
+}
+
+.support-admin-composer-textarea {
+ min-height: 118px;
+ resize: vertical;
+}
+
+.support-admin-composer-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: center;
+}
+
+.support-admin-note-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ padding: 6px 10px 6px 6px;
+ border: 1px solid var(--admin-border);
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--admin-surface-2) 78%, transparent);
+}
+
+.support-admin-note-toggle label {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--admin-muted);
+ font-size: 13px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.support-admin-note-toggle label svg {
+ color: var(--warning);
+}
+
+.support-user-context {
+ display: grid;
+ gap: 14px;
+ align-content: start;
+ border: 1px solid var(--admin-border);
+ border-radius: 14px;
+ background: color-mix(in srgb, var(--admin-surface) 88%, var(--admin-bg));
+ padding: 16px;
+}
+
+.support-user-context > h3 {
+ margin: 0;
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ line-height: 1.2;
+ color: var(--admin-muted);
+ text-transform: uppercase;
+}
+
+.support-user-context-head {
+ display: grid;
+ grid-template-columns: 48px minmax(0, 1fr);
+ gap: 12px;
+ align-items: center;
+}
+
+.support-user-context-avatar {
+ display: grid;
+ width: 48px;
+ height: 48px;
+ place-items: center;
+ overflow: hidden;
+ border: 1px solid var(--admin-border);
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--admin-surface-2) 80%, transparent);
+ color: var(--admin-text);
+ font-size: 14px;
+ font-weight: 800;
+ letter-spacing: 0.02em;
+}
+
+.support-user-context-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.support-user-context-identity {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+}
+
+.support-user-context-identity strong {
+ font-size: 15px;
+ font-weight: 800;
+ letter-spacing: -0.01em;
+ line-height: 1.2;
+ color: var(--admin-text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.support-user-context-identity small {
+ color: var(--admin-muted);
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+}
+
+.support-user-context-actions {
+ display: grid;
+ gap: 8px;
+}
+
+.support-user-context dl {
+ display: grid;
+ grid-template-columns: minmax(80px, auto) 1fr;
+ gap: 8px 12px;
+ margin: 0;
+ padding: 10px 0;
+ border-top: 1px solid var(--admin-border);
+ border-bottom: 1px solid var(--admin-border);
+}
+
+.support-user-context dt {
+ margin: 0;
+ color: var(--admin-muted);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ align-self: center;
+}
+
+.support-user-context dd {
+ margin: 0;
+ color: var(--admin-text);
+ font-size: 13px;
+ font-weight: 600;
+ overflow-wrap: anywhere;
+ align-self: center;
+}
+
+.support-user-context .admin-btn {
+ width: 100%;
+ justify-content: center;
+}
+
+@media (min-width: 981px) {
+ .support-admin-layout {
+ height: clamp(620px, calc(100dvh - var(--admin-header-h) - 120px), 820px);
+ }
+
+ .support-admin-detail {
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ gap: 0;
+ border: 1px solid var(--admin-border);
+ border-radius: 14px;
+ background: color-mix(in srgb, var(--admin-surface) 92%, var(--admin-bg));
+ padding: 16px;
+ }
+
+ .support-admin-detail > .admin-empty-state {
+ min-height: 420px;
+ align-self: stretch;
+ }
+
+ .support-admin-detail .support-ticket-header {
+ border: 0;
+ border-bottom: 1px solid var(--admin-border);
+ border-radius: 0;
+ background: transparent;
+ padding: 0 0 14px;
+ }
+
+ .support-admin-detail .support-admin-message-scroll {
+ height: 100%;
+ min-height: 0;
+ max-height: none !important;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ padding: 16px 4px;
+ }
+
+ .support-admin-detail .support-admin-composer {
+ margin-top: 0;
+ border-radius: 0;
+ border: 0;
+ border-top: 1px solid var(--admin-border);
+ background: transparent;
+ padding: 14px 0 0;
+ }
+
+ .support-admin-detail .support-admin-composer:focus-within {
+ border-color: transparent;
+ box-shadow: none;
+ }
+
+ .support-user-context {
+ position: sticky;
+ top: 0;
+ align-self: start;
+ }
+}
+
+@media (max-width: 1420px) {
+ .support-admin-layout {
+ grid-template-columns: minmax(280px, 350px) minmax(0, 1fr);
+ }
+
+ .support-user-context {
+ grid-column: 1 / -1;
+ position: static;
+ }
+}
+
+@media (max-width: 980px) {
+ .support-admin-layout {
+ grid-template-columns: 1fr;
+ gap: 12px;
+ height: auto;
+ min-height: 0;
+ }
+
+ .support-admin-inbox {
+ border-radius: 12px;
+ padding: 12px;
+ }
+
+ .support-admin-filter-row,
+ .support-ticket-controls {
+ grid-template-columns: 1fr;
+ }
+
+ .support-ticket-header {
+ grid-template-columns: 1fr;
+ padding: 14px;
+ border-radius: 12px;
+ }
+
+ .support-admin-composer,
+ .support-user-context {
+ border-radius: 12px;
+ padding: 14px;
+ }
+
+ .support-inbox-row {
+ grid-template-columns: 32px minmax(0, 1fr) auto;
+ gap: 10px;
+ padding: 11px 11px 11px 13px;
+ }
+
+ .support-inbox-row-avatar {
+ width: 32px;
+ height: 32px;
+ }
+
+ .support-row-badges {
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 4px;
+ }
+
+ .support-ticket-title h3 {
+ font-size: 17px;
+ }
+
+ .support-ticket-controls {
+ padding-top: 10px;
+ }
+
+ .support-admin-messages .ticket-message-content {
+ max-width: min(86%, calc(100vw - 80px));
+ }
+
+ .support-admin-composer-row {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .support-admin-composer-row .admin-btn {
+ width: 100%;
+ }
+}
+
+/* Support tickets: structured inbox + conversation workspace. */
+
+.support-admin-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 14px;
+ min-height: 0;
+ width: 100%;
+}
+
+.support-admin-summary {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+ min-width: 0;
+}
+
+.support-admin-summary span {
+ display: grid;
+ min-width: 0;
+ gap: 2px;
+ border: 1px solid var(--admin-border);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--admin-surface) 86%, var(--admin-bg));
+ padding: 12px 14px;
+ box-shadow: var(--admin-card-shadow);
+}
+
+.support-admin-summary strong {
+ color: var(--admin-text);
+ font-size: 24px;
+ font-weight: 750;
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+}
+
+.support-admin-summary small {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--admin-muted);
+ font-size: 12px;
+ font-weight: 650;
+ line-height: 1.25;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.support-admin-workspace {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 14px;
+ min-height: 0;
+}
+
+.support-admin-inbox,
+.support-admin-detail,
+.support-admin-detail .support-ticket-header,
+.support-admin-detail .support-admin-composer,
+.support-user-context {
+ border: 1px solid var(--admin-border);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--admin-surface) 88%, var(--admin-bg));
+ box-shadow: var(--admin-card-shadow);
+}
+
+.support-admin-inbox {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ gap: 12px;
+ min-width: 0;
+ min-height: 0;
+ padding: 14px;
+}
+
+.support-admin-inbox .support-admin-toolbar {
+ padding: 0;
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+}
+
+.support-admin-filter-row {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.support-admin-filter-row > :last-child {
+ grid-column: 1 / -1;
+}
+
+.support-inbox-list {
+ align-content: start;
+ min-height: 0;
+ overflow-y: auto;
+ padding-right: 2px;
+ scrollbar-width: thin;
+}
+
+.support-inbox-row {
+ min-height: 82px;
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--admin-surface) 74%, var(--admin-bg));
+}
+
+.support-inbox-row-title strong {
+ letter-spacing: 0;
+}
+
+.support-row-badges {
+ min-width: 86px;
+ max-width: 132px;
+}
+
+.support-row-badges .admin-badge {
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.support-admin-detail {
+ display: grid;
+ grid-template-rows: auto;
+ gap: 12px;
+ min-width: 0;
+ min-height: 0;
+ padding: 14px;
+}
+
+.support-admin-detail > .admin-empty-state {
+ min-height: 360px;
+ align-self: stretch;
+}
+
+.support-admin-detail .support-ticket-header {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 14px;
+ align-items: start;
+ padding: 14px;
+}
+
+.support-ticket-actions {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.support-ticket-close {
+ white-space: nowrap;
+}
+
+.support-ticket-controls {
+ grid-column: 1 / -1;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ padding-top: 12px;
+ border-top: 1px solid var(--admin-border);
+}
+
+.support-ticket-controls label {
+ display: grid;
+ flex: 1 1 150px;
+ gap: 6px;
+ min-width: 0;
+}
+
+.support-admin-detail .support-admin-message-scroll {
+ min-height: 240px;
+ border: 1px solid var(--admin-border);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--admin-bg) 68%, transparent);
+ padding: 16px;
+}
+
+.support-admin-detail .support-admin-composer {
+ display: grid;
+ gap: 10px;
+ margin-top: 0;
+ padding: 14px;
+}
+
+.support-admin-detail .support-admin-composer-textarea {
+ min-height: 86px;
+}
+
+.support-admin-detail .support-admin-composer:focus-within {
+ border-color: color-mix(in srgb, var(--accent) 48%, var(--admin-border));
+ box-shadow:
+ var(--admin-card-shadow),
+ 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
+}
+
+.support-user-context {
+ display: grid;
+ grid-column: auto;
+ gap: 12px;
+ position: static;
+ min-width: 0;
+ padding: 12px;
+}
+
+.support-user-context-head {
+ display: grid;
+ grid-template-columns: 44px minmax(0, 1fr);
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+}
+
+.support-user-context-avatar {
+ width: 44px;
+ height: 44px;
+}
+
+.support-user-context-identity {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+}
+
+.support-user-context-identity small {
+ color: var(--admin-muted);
+ font-size: 11px;
+ font-weight: 750;
+ letter-spacing: 0.06em;
+ line-height: 1.1;
+ text-transform: uppercase;
+}
+
+.support-user-context-identity strong,
+.support-user-context-identity span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.support-user-context-identity strong {
+ color: var(--admin-text);
+ font-size: 14px;
+ font-weight: 750;
+ line-height: 1.15;
+}
+
+.support-user-context-identity span {
+ color: var(--admin-muted);
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+}
+
+.support-user-context-metrics {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
+ gap: 8px;
+ min-width: 0;
+}
+
+.support-user-context-metrics span {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+ border: 1px solid color-mix(in srgb, var(--admin-border) 82%, transparent);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--admin-surface-2) 68%, var(--admin-bg));
+ padding: 9px 10px;
+}
+
+.support-user-context-metrics small {
+ color: var(--admin-muted);
+ font-size: 10px;
+ font-weight: 750;
+ letter-spacing: 0.05em;
+ line-height: 1.1;
+ text-transform: uppercase;
+}
+
+.support-user-context-metrics strong {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--admin-text);
+ font-size: 12px;
+ font-weight: 650;
+ line-height: 1.25;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.support-user-context-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+ justify-content: flex-start;
+}
+
+.support-user-context-actions .admin-btn {
+ width: auto;
+ justify-content: center;
+}
+
+@media (min-width: 981px) {
+ .support-admin-workspace {
+ grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
+ height: clamp(660px, calc(100dvh - var(--admin-header-h) - 112px), 840px);
+ }
+
+ .support-admin-inbox,
+ .support-admin-detail {
+ height: 100%;
+ }
+
+ .support-admin-detail {
+ grid-template-rows: auto auto minmax(0, 1fr) auto;
+ overflow: hidden;
+ }
+
+ .support-admin-detail .support-admin-message-scroll {
+ height: 100%;
+ min-height: 0;
+ max-height: none !important;
+ overflow: auto;
+ }
+}
+
+@media (min-width: 1360px) {
+ .support-user-context {
+ grid-template-columns: minmax(170px, 0.65fr) minmax(260px, 1.35fr) auto;
+ align-items: center;
+ }
+
+ .support-user-context-actions {
+ justify-content: flex-end;
+ }
+}
+
+@media (max-width: 640px) {
+ .support-admin-summary {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .support-admin-detail .support-ticket-header {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .support-ticket-actions {
+ justify-content: stretch;
+ }
+
+ .support-ticket-actions .admin-btn,
+ .support-user-context-actions .admin-btn {
+ width: 100%;
+ }
+
+ .support-ticket-controls,
+ .support-admin-composer-row {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .support-ticket-controls label {
+ flex-basis: auto;
+ }
+
+ .support-inbox-row {
+ grid-template-columns: 32px minmax(0, 1fr);
+ }
+
+ .support-row-badges {
+ grid-column: 2;
+ max-width: none;
+ min-width: 0;
+ flex-direction: row;
+ align-items: center;
+ justify-content: flex-start;
+ }
+}
+
+/* Support tickets v2: one-column admin list with modal ticket details. */
+
+.support-admin-layout {
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: auto minmax(0, 1fr);
+ align-content: start;
+ gap: 14px;
+ flex: 1 1 auto;
+ height: 100%;
+ min-height: 0;
+}
+
+.support-admin-summary span {
+ min-height: 66px;
+}
+
+.support-admin-list-panel {
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr);
+ gap: 12px;
+ min-width: 0;
+ min-height: 0;
+ border: 1px solid var(--admin-border);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--admin-surface) 88%, var(--admin-bg));
+ box-shadow: var(--admin-card-shadow);
+ padding: 14px;
+}
+
+.support-admin-ticket-tabs {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ min-width: 0;
+ border: 1px solid var(--admin-border);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--admin-bg) 78%, var(--admin-surface));
+ padding: 4px;
+}
+
+.support-admin-ticket-tabs button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ min-width: 0;
+ min-height: 34px;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--admin-muted);
+ font: inherit;
+ font-size: 13px;
+ font-weight: 750;
+ cursor: pointer;
+}
+
+.support-admin-ticket-tabs button.active {
+ background: var(--admin-surface);
+ color: var(--admin-text);
+ box-shadow: var(--admin-card-shadow);
+}
+
+.support-admin-ticket-tabs button b {
+ display: inline-grid;
+ min-width: 21px;
+ height: 21px;
+ place-items: center;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--admin-surface-2) 76%, transparent);
+ color: var(--admin-muted);
+ font-size: 11px;
+ font-weight: 850;
+ padding-inline: 7px;
+}
+
+.support-admin-ticket-tabs button.active b {
+ background: color-mix(in srgb, var(--accent) 18%, transparent);
+ color: color-mix(in srgb, var(--accent) 78%, white);
+}
+
+.support-admin-list-panel .support-admin-toolbar {
+ display: grid;
+ grid-template-columns: minmax(260px, 0.85fr) minmax(0, 1.35fr);
+ gap: 10px;
+ align-items: start;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+}
+
+.support-admin-list-panel .support-admin-search .input {
+ box-sizing: border-box;
+ height: 36px;
+ padding-left: 42px;
+}
+
+.support-admin-list-panel .support-admin-filter-row {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 8px;
+ align-items: stretch;
+}
+
+.support-admin-list-panel .support-admin-filter-row .admin-select-trigger,
+.support-admin-list-panel .support-admin-filter-row .admin-btn {
+ height: 46px;
+ min-height: 46px;
+}
+
+.support-admin-list-panel .support-admin-filter-row > :last-child {
+ grid-column: auto;
+}
+
+.support-admin-list-panel .support-inbox-list {
+ display: grid;
+ align-content: start;
+ gap: 8px;
+ min-height: 0;
+ height: 100%;
+ overflow: auto;
+ padding: 0;
+ padding-right: 4px;
+ scrollbar-width: thin;
+}
+
+.support-admin-list-panel > .admin-empty-state {
+ align-self: stretch;
+ min-height: 0;
+ height: 100%;
+ display: grid;
+ place-items: center;
+ border: 1px dashed color-mix(in srgb, var(--admin-border) 84%, transparent);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--admin-bg) 66%, transparent);
+}
+
+.support-admin-list-panel .support-inbox-row {
+ min-height: 74px;
+ grid-template-columns: 38px minmax(0, 1fr) auto;
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--admin-surface) 74%, var(--admin-bg));
+}
+
+.support-admin-list-panel .support-inbox-row[data-priority="high"]::before {
+ background: var(--warning);
+}
+
+.support-admin-list-panel .support-inbox-row[data-priority="urgent"]::before {
+ background: var(--danger);
+}
+
+.support-admin-list-panel .support-inbox-row-avatar {
+ width: 38px;
+ height: 38px;
+}
+
+.support-admin-list-panel .support-row-badges {
+ min-width: 168px;
+ max-width: 240px;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-end;
+}
+
+.support-admin-list-panel .support-row-badges small {
+ flex-basis: 100%;
+ text-align: right;
+}
+
+.support-ticket-list-skeleton {
+ display: grid;
+ gap: 8px;
+}
+
+.support-ticket-row-skeleton {
+ display: grid;
+ grid-template-columns: 38px minmax(0, 1fr) minmax(120px, auto);
+ align-items: center;
+ gap: 12px;
+ min-height: 74px;
+ border: 1px solid var(--admin-border);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--admin-surface) 74%, var(--admin-bg));
+ padding: 12px 14px;
+}
+
+.support-ticket-row-skeleton-main,
+.support-ticket-row-skeleton-side {
+ display: grid;
+ gap: 8px;
+ min-width: 0;
+}
+
+.support-ticket-row-skeleton-side {
+ justify-items: end;
+}
+
+.support-ticket-dialog {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ width: min(1040px, calc(100vw - 28px));
+ max-height: min(840px, calc(100dvh - 28px));
+ overflow: hidden;
+}
+
+.support-ticket-dialog-body {
+ display: grid;
+ grid-template-rows: auto auto minmax(180px, 1fr) auto;
+ gap: 12px;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.support-ticket-dialog .support-ticket-header,
+.support-ticket-dialog .support-user-context,
+.support-ticket-dialog .support-admin-composer {
+ border: 1px solid var(--admin-border);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--admin-surface) 88%, var(--admin-bg));
+ box-shadow: none;
+}
+
+.support-ticket-dialog .support-ticket-header {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 14px;
+ padding: 14px;
+}
+
+.support-ticket-dialog .support-user-context {
+ padding: 12px;
+}
+
+.support-ticket-dialog .support-admin-message-scroll {
+ min-height: 0;
+ height: 100%;
+ max-height: none !important;
+ overflow: auto;
+ border: 1px solid var(--admin-border);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--admin-bg) 68%, transparent);
+ padding: 16px;
+}
+
+.support-ticket-dialog .support-admin-composer {
+ margin: 0;
+ padding: 12px;
+}
+
+.support-ticket-dialog .support-admin-composer-textarea {
+ min-height: 76px;
+}
+
+.support-ticket-dialog-skeleton {
+ display: grid;
+ gap: 12px;
+ min-width: 0;
+}
+
+@media (max-width: 1120px) {
+ .support-admin-list-panel .support-admin-toolbar,
+ .support-admin-list-panel .support-admin-filter-row {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+@media (max-width: 720px) {
+ .support-admin-summary {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+ }
+
+ .support-admin-summary span {
+ min-height: 56px;
+ border-radius: 10px;
+ padding: 9px 8px;
+ }
+
+ .support-admin-summary strong {
+ font-size: 20px;
+ }
+
+ .support-admin-summary small {
+ font-size: 10px;
+ }
+
+ .support-admin-list-panel {
+ padding: 12px;
+ }
+
+ .support-admin-list-panel .support-inbox-row,
+ .support-ticket-row-skeleton {
+ grid-template-columns: 34px minmax(0, 1fr);
+ gap: 10px;
+ }
+
+ .support-admin-list-panel .support-row-badges,
+ .support-ticket-row-skeleton-side {
+ grid-column: 2;
+ min-width: 0;
+ max-width: none;
+ justify-content: flex-start;
+ justify-items: start;
+ }
+
+ .support-admin-list-panel .support-row-badges small {
+ flex-basis: auto;
+ text-align: left;
+ }
+
+ .support-ticket-dialog {
+ width: calc(100vw - 16px);
+ max-height: calc(100dvh - 16px);
+ border-radius: 14px;
+ overflow-y: auto;
+ }
+
+ .support-ticket-dialog-body {
+ grid-template-rows: auto auto auto auto;
+ gap: 10px;
+ overflow: visible;
+ }
+
+ .support-ticket-dialog .support-ticket-header {
+ grid-template-columns: minmax(0, 1fr);
+ padding: 12px;
+ }
+
+ .support-ticket-dialog .support-ticket-actions {
+ justify-content: stretch;
+ }
+
+ .support-ticket-dialog .support-ticket-actions .admin-btn {
+ width: 100%;
+ }
+
+ .support-ticket-dialog .support-admin-message-scroll {
+ height: auto;
+ min-height: 180px;
+ max-height: min(32dvh, 260px) !important;
+ padding: 12px 10px;
+ }
+
+ .support-ticket-dialog .support-admin-composer-row {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .support-ticket-dialog .support-admin-composer-row .admin-btn {
+ width: 100%;
+ }
+}
+
+/* Support ticket dialog v3: one readable page, compact metadata, no nested chat scroll. */
+
+.dialog:has(.support-ticket-dialog) {
+ align-items: start;
+ overflow-y: auto;
+}
+
+.support-ticket-dialog {
+ display: grid;
+ grid-template-rows: auto;
+ gap: 10px;
+ width: min(940px, calc(100vw - 24px));
+ max-height: none;
+ margin-block: 0;
+ overflow: visible;
+ padding: 14px;
+ border-radius: 16px;
+}
+
+.support-ticket-dialog .dialog-head {
+ align-items: center;
+ gap: 10px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid var(--admin-border);
+}
+
+.support-ticket-dialog .dialog-head h2 {
+ overflow: hidden;
+ font-size: 18px;
+ line-height: 1.2;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.support-ticket-dialog .dialog-head p {
+ margin-top: 3px;
+ font-size: 12px;
+ line-height: 1.25;
+}
+
+.support-ticket-dialog-body {
+ display: grid;
+ grid-template-rows: auto;
+ gap: 10px;
+ min-width: 0;
+ min-height: 0;
+ overflow: visible;
+}
+
+.support-ticket-dialog .support-ticket-header,
+.support-ticket-dialog .support-user-context,
+.support-ticket-dialog .support-admin-composer {
+ border-radius: 10px;
+ box-shadow: none;
+}
+
+.support-ticket-dialog .support-ticket-header {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+ align-items: center;
+ padding: 10px;
+}
+
+.support-ticket-statusbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+ min-width: 0;
+}
+
+.support-ticket-statusbar .admin-badge {
+ max-width: 180px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.support-ticket-dialog .support-ticket-actions {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.support-ticket-dialog .support-ticket-close {
+ min-height: 34px;
+ white-space: nowrap;
+}
+
+.support-ticket-dialog .support-ticket-controls {
+ grid-column: 1 / -1;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+ padding-top: 8px;
+ border-top: 1px solid var(--admin-border);
+}
+
+.support-ticket-dialog .support-ticket-controls .admin-select-trigger {
+ height: 34px;
+ min-width: 0;
+ padding-inline: 10px;
+}
+
+.support-ticket-dialog .support-ticket-controls .admin-select-trigger span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.support-ticket-dialog .support-user-context {
+ display: grid;
+ grid-template-columns: minmax(220px, 0.9fr) minmax(0, 1.4fr) auto;
+ gap: 10px;
+ align-items: center;
+ padding: 10px;
+}
+
+.support-ticket-dialog .support-user-context-head {
+ grid-template-columns: 34px minmax(0, 1fr);
+ gap: 9px;
+}
+
+.support-ticket-dialog .support-user-context-avatar {
+ width: 34px;
+ height: 34px;
+ font-size: 12px;
+}
+
+.support-ticket-dialog .support-user-context-identity {
+ gap: 2px;
+}
+
+.support-ticket-dialog .support-user-context-identity strong {
+ font-size: 13px;
+}
+
+.support-ticket-dialog .support-user-context-identity small {
+ overflow: hidden;
+ color: var(--admin-muted);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0;
+ text-overflow: ellipsis;
+ text-transform: none;
+ white-space: nowrap;
+}
+
+.support-ticket-dialog .support-user-context-metrics {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+}
+
+.support-ticket-dialog .support-user-context-metrics span {
+ gap: 2px;
+ min-height: 44px;
+ border-radius: 8px;
+ padding: 6px 8px;
+}
+
+.support-ticket-dialog .support-user-context-metrics small {
+ font-size: 9px;
+ line-height: 1;
+}
+
+.support-ticket-dialog .support-user-context-metrics strong {
+ font-size: 11px;
+ line-height: 1.2;
+}
+
+.support-ticket-dialog .support-user-context-actions {
+ display: flex;
+ gap: 6px;
+ justify-content: flex-end;
+}
+
+.support-ticket-dialog .support-user-context-actions .admin-btn {
+ width: 30px;
+ min-width: 30px;
+ height: 30px;
+ min-height: 30px;
+ border-radius: 7px;
+ padding: 0;
+ white-space: nowrap;
+}
+
+.support-ticket-dialog .support-admin-message-scroll {
+ height: auto;
+ min-height: 0;
+ max-height: none !important;
+ overflow: visible;
+ border-radius: 10px;
+ padding: 12px;
+}
+
+.support-ticket-dialog .support-admin-messages {
+ gap: 12px;
+}
+
+.support-ticket-dialog .support-admin-composer {
+ margin: 0;
+ padding: 10px;
+}
+
+.support-ticket-dialog .support-admin-composer-textarea {
+ min-height: 74px;
+}
+
+.support-ticket-dialog .support-admin-composer-row {
+ gap: 8px;
+}
+
+@media (min-width: 721px) {
+ .dialog:has(.support-ticket-dialog) {
+ align-items: start;
+ }
+
+ .support-ticket-dialog {
+ grid-template-rows: auto minmax(0, 1fr);
+ height: calc(100dvh - 28px);
+ min-height: calc(100dvh - 28px);
+ max-height: calc(100dvh - 28px);
+ overflow: hidden;
+ }
+
+ .support-ticket-dialog-body {
+ grid-template-rows: auto auto minmax(0, 1fr) auto;
+ min-height: 0;
+ overflow: hidden;
+ }
+
+ .support-ticket-dialog .support-admin-message-scroll {
+ height: 100%;
+ min-height: 320px;
+ overflow: auto;
+ }
+}
+
+@media (max-width: 720px) {
+ .dialog:has(.support-ticket-dialog) {
+ padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right))
+ max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left));
+ }
+
+ .support-ticket-dialog {
+ width: calc(100vw - 16px);
+ max-height: none;
+ padding: 12px;
+ border-radius: 14px;
+ overflow: visible;
+ }
+
+ .support-ticket-dialog .dialog-head {
+ padding-bottom: 8px;
+ }
+
+ .support-ticket-dialog .dialog-head h2 {
+ font-size: 16px;
+ }
+
+ .support-ticket-dialog .support-ticket-header {
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+ padding: 9px;
+ }
+
+ .support-ticket-dialog .support-ticket-actions {
+ justify-content: flex-start;
+ }
+
+ .support-ticket-dialog .support-ticket-actions .admin-btn {
+ width: auto;
+ }
+
+ .support-ticket-dialog .support-ticket-controls {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+ }
+
+ .support-ticket-dialog .support-ticket-controls .admin-select-trigger {
+ height: 32px;
+ padding-inline: 8px;
+ font-size: 12px;
+ }
+
+ .support-ticket-dialog .support-user-context {
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+ padding: 9px;
+ }
+
+ .support-ticket-dialog .support-user-context-metrics {
+ grid-column: 1 / -1;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .support-ticket-dialog .support-user-context-actions {
+ grid-column: 2;
+ grid-row: 1;
+ align-self: center;
+ justify-content: flex-start;
+ }
+
+ .support-ticket-dialog .support-admin-message-scroll {
+ height: auto;
+ min-height: 0;
+ max-height: none !important;
+ padding: 10px 8px;
+ }
+
+ .support-ticket-dialog .support-admin-composer-row {
+ flex-direction: row;
+ align-items: center;
+ }
+
+ .support-ticket-dialog .support-admin-composer-row .admin-btn {
+ width: auto;
+ }
+}
diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css
index bc75e07..12f8345 100644
--- a/frontend/src/styles/components.css
+++ b/frontend/src/styles/components.css
@@ -26,9 +26,10 @@
border-radius: 12px;
border: 1px solid var(--admin-border);
background:
- linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)),
- var(--admin-surface);
- box-shadow: var(--shadow-soft), inset 0 1px 0 var(--inset-highlight);
+ linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--admin-surface);
+ box-shadow:
+ var(--shadow-soft),
+ inset 0 1px 0 var(--inset-highlight);
position: relative;
overflow: hidden;
}
@@ -292,3 +293,1506 @@
}
}
+.textarea {
+ width: 100%;
+ min-height: 92px;
+ resize: vertical;
+ border: 1px solid var(--border, rgba(255, 255, 255, 0.12));
+ border-radius: 8px;
+ background: var(--input-bg, rgba(255, 255, 255, 0.06));
+ color: inherit;
+ padding: 10px 12px;
+ font: inherit;
+}
+
+.scroll-area {
+ overflow: auto;
+ min-height: 0;
+}
+
+.scroll-area--mono {
+ scrollbar-gutter: stable;
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(
+ in srgb,
+ var(--admin-muted, var(--muted)) 34%,
+ var(--admin-border, var(--border))
+ )
+ transparent;
+}
+
+.scroll-area--mono::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+.scroll-area--mono::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.scroll-area--mono::-webkit-scrollbar-thumb {
+ border: 2px solid transparent;
+ border-radius: 999px;
+ background: color-mix(
+ in srgb,
+ var(--admin-muted, var(--muted)) 34%,
+ var(--admin-border, var(--border))
+ );
+ background-clip: padding-box;
+}
+
+.scroll-area--mono::-webkit-scrollbar-thumb:hover {
+ background: color-mix(
+ in srgb,
+ var(--admin-muted, var(--muted)) 48%,
+ var(--admin-border, var(--border))
+ );
+ background-clip: padding-box;
+}
+
+.attention-dot {
+ position: absolute;
+ top: 0;
+ right: 0;
+ min-width: 13px;
+ width: 13px;
+ height: 13px;
+ border-radius: 999px;
+ background: #ff4b4b;
+ box-shadow: 0 0 0 0 rgba(255, 75, 75, 0.75);
+ animation: attention-pulse 1.6s ease-out infinite;
+ transform: translate(50%, -50%);
+ pointer-events: none;
+}
+
+.attention-dot-inline {
+ position: static;
+ display: block;
+ flex: 0 0 auto;
+ transform: none;
+}
+
+@keyframes attention-pulse {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(255, 75, 75, 0.75);
+ }
+
+ 70% {
+ box-shadow: 0 0 0 8px rgba(255, 75, 75, 0);
+ }
+
+ 100% {
+ box-shadow: 0 0 0 0 rgba(255, 75, 75, 0);
+ }
+}
+
+.nav-badge {
+ display: inline-grid;
+ box-sizing: border-box;
+ --numeric-badge-optical-y: 0.5px;
+ min-width: 20px;
+ height: 20px;
+ place-items: center;
+ border-radius: 999px;
+ background: #ef4444;
+ color: #fff;
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 1;
+ padding: 0 6px;
+ text-align: center;
+ font-variant-numeric: lining-nums;
+ font-feature-settings: "lnum";
+}
+
+.numeric-badge-value {
+ display: block;
+ min-width: 0;
+ font-family: var(--font-sans);
+ font-size: inherit;
+ font-weight: inherit;
+ line-height: 1;
+ text-align: center;
+ transform: translateY(var(--numeric-badge-optical-y, 0.5px));
+ text-box-trim: trim-both;
+ text-box-edge: cap alphabetic;
+ font-variant-numeric: lining-nums;
+ font-feature-settings: "lnum";
+}
+
+@supports (text-box-trim: trim-both) {
+ .numeric-badge-value {
+ transform: none;
+ }
+}
+
+.nav-badge-floating {
+ position: absolute;
+ top: 2px;
+ right: 14px;
+}
+
+.section-heading-row {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.support-screen {
+ display: grid;
+ align-content: start;
+ gap: 10px;
+}
+
+.support-ticket-screen {
+ display: grid;
+ align-content: start;
+ gap: 12px;
+}
+
+.support-overview-card,
+.support-list-card,
+.support-ticket-card,
+.support-conversation-card {
+ display: grid;
+ min-width: 0;
+ gap: 12px;
+ overflow: visible;
+}
+
+.support-list-card {
+ align-content: start;
+}
+
+.support-ticket-card .empty-card {
+ display: grid;
+ min-height: 160px;
+ place-items: center;
+ text-align: center;
+}
+
+.support-conversation-card {
+ gap: 12px;
+}
+
+.support-heading-row {
+ display: grid;
+ grid-template-columns: 42px minmax(0, 1fr);
+ align-items: center;
+ gap: 14px;
+}
+
+.support-heading-copy {
+ min-width: 0;
+}
+
+.support-heading-copy h1 {
+ margin: 0;
+ color: var(--accent);
+ font-size: 17px;
+ font-weight: 850;
+ line-height: 1.16;
+}
+
+.support-heading-copy p {
+ margin: 4px 0 0;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.35;
+}
+
+.support-heading-icon {
+ display: flex;
+ width: 42px;
+ height: 42px;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ color: var(--accent);
+}
+
+.support-new-ticket-button {
+ display: grid;
+ grid-template-columns: 38px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ min-height: 58px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft));
+ color: var(--text);
+ padding: 9px 10px;
+ text-align: left;
+ box-shadow: inset 0 1px 0 var(--inset-highlight);
+ transition:
+ border-color 0.16s ease,
+ background 0.16s ease,
+ box-shadow 0.16s ease;
+}
+
+.support-new-ticket-button:hover {
+ border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
+ background: color-mix(in srgb, var(--surface-muted) 84%, var(--accent) 8%);
+}
+
+.support-new-ticket-button.active {
+ border-color: color-mix(in srgb, var(--accent) 58%, var(--border));
+ background: color-mix(in srgb, var(--surface-muted) 82%, var(--accent) 12%);
+}
+
+.support-new-ticket-button > span:not(.support-new-ticket-icon) {
+ min-width: 0;
+}
+
+.support-new-ticket-button strong,
+.support-new-ticket-button small {
+ display: block;
+}
+
+.support-new-ticket-button strong {
+ font-size: 13px;
+ line-height: 1.2;
+}
+
+.support-new-ticket-button small {
+ margin-top: 2px;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.3;
+}
+
+.support-new-ticket-icon {
+ display: grid;
+ width: 38px;
+ height: 38px;
+ place-items: center;
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--accent) 14%, var(--surface-muted));
+ color: var(--accent);
+}
+
+.support-create-panel,
+.ticket-composer,
+.support-admin-composer {
+ display: grid;
+ gap: 10px;
+}
+
+.support-create-panel {
+ overflow: visible;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: color-mix(in srgb, var(--surface-muted) 86%, transparent);
+ box-shadow: inset 0 1px 0 var(--inset-highlight);
+}
+
+.support-create-panel-inner {
+ display: grid;
+ gap: 12px;
+ padding: 12px;
+}
+
+.support-create-grid,
+.ticket-composer-row,
+.support-admin-composer-row,
+.ticket-badges {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ align-items: center;
+}
+
+.ticket-composer-row,
+.support-admin-composer-row,
+.ticket-badges {
+ display: flex;
+}
+
+.support-field {
+ display: grid;
+ gap: 7px;
+ min-width: 0;
+}
+
+.support-field > span,
+.support-field > small {
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 800;
+ line-height: 1.2;
+}
+
+.support-field > small {
+ justify-self: end;
+ font-weight: 700;
+}
+
+.support-message-input {
+ min-height: 128px;
+}
+
+.support-submit-button {
+ margin-top: 2px;
+}
+
+.support-select-trigger {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ min-height: 46px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--surface-muted);
+ color: var(--text);
+ padding: 0 12px;
+ text-align: left;
+ font: inherit;
+ font-size: 14px;
+ font-weight: 800;
+ box-shadow: inset 0 1px 0 color-mix(in srgb, white 5%, transparent);
+}
+
+.support-select-trigger:focus-visible {
+ outline: none;
+ border-color: color-mix(in srgb, var(--accent) 72%, white);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 26%, transparent);
+}
+
+.support-select-content {
+ z-index: 150;
+ width: var(--bits-select-anchor-width, min(250px, calc(100vw - 28px)));
+ min-width: min(180px, calc(100vw - 28px));
+ max-width: calc(100vw - 28px);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius);
+ background: var(--panel-3);
+ box-shadow: var(--shadow-popover);
+ overflow: hidden;
+ transform-origin: top left;
+ animation: dropdown-enter 0.16s ease-out both;
+}
+
+.support-select-viewport {
+ max-height: min(240px, 45dvh);
+ padding: 6px;
+}
+
+.support-select-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ min-height: 38px;
+ border-radius: 6px;
+ padding: 8px 9px;
+ color: var(--text);
+ font-size: 13px;
+ cursor: pointer;
+}
+
+.support-select-item[data-highlighted] {
+ background: var(--surface-hover);
+}
+
+.support-select-check {
+ opacity: 0;
+ color: var(--accent);
+}
+
+.support-select-item[data-selected] .support-select-check {
+ opacity: 1;
+}
+
+.support-status-tabs {
+ min-width: 0;
+}
+
+.support-status-tabs-list {
+ display: flex;
+ width: 100%;
+ max-width: 100%;
+ align-items: center;
+ gap: 4px;
+ overflow-x: auto;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--surface-muted) 86%, transparent);
+ padding: 4px;
+ scrollbar-width: none;
+ box-shadow: inset 0 1px 0 color-mix(in srgb, white 5%, transparent);
+}
+
+.support-status-tabs-list::-webkit-scrollbar {
+ display: none;
+}
+
+.support-status-tabs-trigger {
+ appearance: none;
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ min-height: 34px;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--muted);
+ font: inherit;
+ font-size: 13px;
+ font-weight: 800;
+ line-height: 1;
+ padding: 8px 11px;
+ white-space: nowrap;
+ cursor: pointer;
+ outline: none;
+ transition:
+ background 0.16s ease,
+ color 0.16s ease,
+ box-shadow 0.16s ease,
+ transform 0.16s ease;
+}
+
+.support-status-tabs-trigger b {
+ display: inline-grid;
+ min-width: 20px;
+ height: 20px;
+ place-items: center;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--surface-muted) 82%, transparent);
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 800;
+ padding-inline: 6px;
+}
+
+.support-status-tabs-trigger:hover {
+ color: var(--text);
+}
+
+.support-status-tabs-trigger[data-state="active"] {
+ background: var(--panel);
+ color: var(--text);
+ box-shadow: var(--shadow-soft);
+}
+
+.support-status-tabs-trigger[data-state="active"] b {
+ background: color-mix(in srgb, var(--accent) 18%, transparent);
+ color: color-mix(in srgb, var(--accent) 78%, white);
+}
+
+.support-status-tabs-trigger:focus-visible {
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 26%, transparent);
+}
+
+.support-list-card .ticket-list {
+ display: grid;
+ gap: 10px;
+}
+
+.support-list-card .empty-card {
+ display: grid;
+ min-height: 180px;
+ place-items: center;
+ text-align: center;
+}
+
+.support-user-list-skeleton {
+ display: grid;
+ gap: 10px;
+ min-width: 0;
+}
+
+.support-user-ticket-skeleton {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(112px, auto);
+ align-items: center;
+ gap: 14px;
+ min-height: 78px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--panel-2) 92%, transparent);
+ padding: 14px 14px 14px 18px;
+}
+
+.support-user-ticket-skeleton-main,
+.support-user-ticket-skeleton-side {
+ display: grid;
+ gap: 9px;
+ min-width: 0;
+}
+
+.support-user-ticket-skeleton-side {
+ justify-items: end;
+}
+
+.ticket-list,
+.ticket-message-list,
+.support-admin-messages,
+.support-inbox-list {
+ display: grid;
+ gap: 10px;
+}
+
+.ticket-card {
+ position: relative;
+ width: 100%;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 14px;
+ text-align: left;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--panel-2) 92%, transparent);
+ color: inherit;
+ padding: 14px 14px 14px 18px;
+ cursor: pointer;
+ transition:
+ border-color 0.16s ease,
+ background 0.16s ease,
+ box-shadow 0.16s ease;
+}
+
+.ticket-card::before {
+ content: "";
+ position: absolute;
+ inset: 10px auto 10px 6px;
+ width: 3px;
+ border-radius: 3px;
+ background: var(--ticket-accent, var(--border-strong));
+ transition: background 0.16s ease;
+}
+
+.ticket-card[data-status="open"],
+.ticket-card[data-status="awaiting_admin"] {
+ --ticket-accent: var(--accent);
+}
+.ticket-card[data-status="awaiting_user"] {
+ --ticket-accent: var(--info);
+}
+.ticket-card[data-status="resolved"],
+.ticket-card[data-status="closed"] {
+ --ticket-accent: color-mix(in srgb, var(--muted) 70%, transparent);
+}
+.ticket-card[data-priority="urgent"] {
+ --ticket-accent: var(--danger);
+}
+
+.ticket-card:hover {
+ border-color: color-mix(in srgb, var(--accent) 38%, var(--border));
+ background: color-mix(in srgb, var(--panel-2) 86%, var(--accent) 6%);
+ box-shadow: var(--shadow-soft);
+}
+
+.ticket-card:focus-visible {
+ outline: none;
+ border-color: color-mix(in srgb, var(--accent) 60%, var(--border));
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 26%, transparent);
+}
+
+.ticket-card-main,
+.ticket-card-title,
+.ticket-card-side,
+.ticket-card-badges,
+.ticket-card-meta {
+ min-width: 0;
+}
+
+.ticket-card-main {
+ display: grid;
+ gap: 6px;
+}
+
+.ticket-card-side {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 8px;
+}
+
+.ticket-card-title {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+}
+
+.ticket-card-title svg {
+ flex: 0 0 auto;
+ color: color-mix(in srgb, var(--accent) 72%, var(--muted));
+}
+
+.ticket-card-title strong {
+ overflow: hidden;
+ font-size: 15px;
+ font-weight: 800;
+ letter-spacing: -0.01em;
+ line-height: 1.25;
+ color: var(--text);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ticket-card-meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px 8px;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1.2;
+}
+
+.ticket-card-meta > span + span::before {
+ content: "·";
+ margin-right: 8px;
+ color: color-mix(in srgb, var(--muted) 60%, transparent);
+}
+
+.ticket-card-badges,
+.ticket-badges {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 6px;
+}
+
+.ticket-status-badge,
+.ticket-priority-badge,
+.ticket-message-role-badge {
+ border: 1px solid var(--border);
+ background: color-mix(in srgb, var(--surface-muted) 80%, transparent);
+ color: var(--muted);
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ text-transform: none;
+}
+
+.ticket-status-badge--open,
+.ticket-status-badge--awaiting_admin {
+ border-color: color-mix(in srgb, var(--accent) 38%, var(--border));
+ background: color-mix(in srgb, var(--accent) 14%, transparent);
+ color: color-mix(in srgb, var(--accent) 80%, white);
+}
+
+.ticket-status-badge--awaiting_user {
+ border-color: color-mix(in srgb, var(--info) 38%, var(--border));
+ background: color-mix(in srgb, var(--info) 14%, transparent);
+ color: var(--info-text);
+}
+
+.ticket-status-badge--closed,
+.ticket-status-badge--resolved {
+ border-color: color-mix(in srgb, var(--muted) 24%, var(--border));
+ background: color-mix(in srgb, var(--muted) 10%, transparent);
+ color: var(--muted);
+}
+
+.ticket-priority-badge--normal,
+.ticket-priority-badge--low {
+ border-color: var(--border);
+ background: color-mix(in srgb, var(--surface-muted) 80%, transparent);
+ color: var(--muted);
+}
+
+.ticket-priority-badge--high {
+ border-color: color-mix(in srgb, var(--warning) 42%, var(--border));
+ background: color-mix(in srgb, var(--warning) 14%, transparent);
+ color: var(--warning-text);
+}
+
+.ticket-priority-badge--urgent {
+ border-color: color-mix(in srgb, var(--danger) 48%, var(--border));
+ background: color-mix(in srgb, var(--danger) 14%, transparent);
+ color: var(--danger-text);
+}
+
+.ticket-card-unread {
+ display: inline-grid;
+ box-sizing: border-box;
+ --numeric-badge-optical-y: 0.5px;
+ min-width: 22px;
+ height: 22px;
+ place-items: center;
+ border-radius: 999px;
+ background: var(--danger);
+ color: #fff;
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 1;
+ padding: 0 7px;
+ text-align: center;
+ font-variant-numeric: lining-nums;
+ font-feature-settings: "lnum";
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 22%, transparent);
+}
+
+.ticket-card-unread-dot {
+ flex: 0 0 auto;
+}
+
+.support-ticket-detail-header {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 10px 12px;
+ align-items: start;
+ margin-bottom: 0;
+}
+
+.support-back-button {
+ align-self: start;
+}
+
+.ticket-detail-title {
+ min-width: 0;
+}
+
+.ticket-detail-title small {
+ display: block;
+ margin-bottom: 4px;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.ticket-detail-title h1 {
+ margin: 0;
+ overflow-wrap: anywhere;
+ font-size: 20px;
+ line-height: 1.18;
+}
+
+.support-ticket-detail-header .ticket-badges {
+ grid-column: 2;
+ justify-content: flex-start;
+}
+
+.support-ticket-state-card {
+ align-content: stretch;
+}
+
+.support-empty-state {
+ display: grid;
+ grid-column: 1 / -1;
+ min-height: 170px;
+ place-items: center;
+ gap: 9px;
+ border: 1px dashed color-mix(in srgb, var(--accent) 34%, var(--border));
+ border-radius: var(--radius);
+ background: color-mix(in srgb, var(--panel) 86%, transparent);
+ color: var(--text);
+ padding: 22px 18px;
+ text-align: center;
+}
+
+.support-empty-state svg {
+ color: var(--accent);
+}
+
+.support-empty-state strong {
+ font-size: 18px;
+ line-height: 1.2;
+}
+
+.support-empty-state small {
+ max-width: 260px;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.4;
+}
+
+.ticket-message-meta time {
+ display: block;
+ color: var(--muted);
+ margin: 0;
+}
+
+.support-message-scroll,
+.support-admin-message-scroll {
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: color-mix(in srgb, var(--panel) 70%, transparent);
+ padding: 14px;
+}
+
+.support-ticket-screen .support-message-scroll {
+ overflow: visible;
+}
+
+.support-conversation-card--loading {
+ grid-template-rows: minmax(0, 1fr);
+}
+
+.ticket-message-list,
+.support-admin-messages {
+ gap: 16px;
+}
+
+.ticket-message-list--loading {
+ align-content: end;
+ min-height: 100%;
+}
+
+.ticket-message-skeleton-row {
+ display: grid;
+ grid-template-columns: 32px minmax(0, min(72%, 520px));
+ align-items: end;
+ gap: 10px;
+ min-width: 0;
+}
+
+.ticket-message-skeleton-row--outgoing {
+ grid-template-columns: minmax(0, min(72%, 520px)) 32px;
+ justify-content: end;
+}
+
+.ticket-message-skeleton-row--outgoing .ui-skeleton-dot {
+ grid-column: 2;
+ grid-row: 1;
+}
+
+.ticket-message-skeleton-row--outgoing .ticket-message-skeleton-content {
+ grid-column: 1;
+ grid-row: 1;
+ justify-items: end;
+}
+
+.ticket-message-skeleton-content {
+ display: grid;
+ gap: 7px;
+ min-width: 0;
+}
+
+.ticket-message-row {
+ display: grid;
+ grid-template-columns: 36px minmax(0, 1fr);
+ gap: 10px;
+ align-items: flex-end;
+ min-width: 0;
+ animation: ticket-message-enter 0.18s ease-out both;
+}
+
+.ticket-message-row--outgoing {
+ grid-template-columns: minmax(0, 1fr) 36px;
+}
+
+.ticket-message-row--outgoing .ticket-message-avatar {
+ grid-column: 2;
+ grid-row: 1;
+}
+
+.ticket-message-row--outgoing .ticket-message-content {
+ grid-column: 1;
+ grid-row: 1;
+ justify-self: end;
+ align-items: flex-end;
+}
+
+.ticket-message-row--incoming .ticket-message-content {
+ grid-column: 2;
+ grid-row: 1;
+ justify-self: start;
+ align-items: flex-start;
+}
+
+.ticket-message-avatar {
+ display: grid;
+ width: 36px;
+ height: 36px;
+ place-items: center;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--surface-muted) 78%, transparent);
+ color: var(--muted);
+}
+
+.ticket-message-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.ticket-message-avatar strong {
+ color: var(--text);
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.02em;
+}
+
+.ticket-message-avatar .brand-mark {
+ width: 100%;
+ height: 100%;
+ color: inherit;
+ font-size: 22px;
+}
+
+.ticket-message-row--admin {
+ --ticket-bubble-bg: color-mix(in srgb, var(--info) 14%, var(--panel-2));
+ --ticket-bubble-border: color-mix(in srgb, var(--info) 32%, var(--border));
+ --ticket-bubble-text: #eaf3ff;
+}
+
+.ticket-message-row--user {
+ --ticket-bubble-bg: color-mix(in srgb, var(--accent) 18%, var(--panel-2));
+ --ticket-bubble-border: color-mix(in srgb, var(--accent) 38%, var(--border));
+ --ticket-bubble-text: #f4fff9;
+}
+
+.ticket-message-row--system {
+ --ticket-bubble-bg: color-mix(in srgb, var(--surface-muted) 72%, var(--panel-2));
+ --ticket-bubble-border: var(--border);
+ --ticket-bubble-text: var(--text);
+}
+
+.ticket-message-row--admin .ticket-message-avatar {
+ border-color: color-mix(in srgb, var(--info) 32%, var(--border));
+ background: color-mix(in srgb, var(--info) 12%, transparent);
+ color: var(--info-text);
+}
+
+.ticket-message-row--user .ticket-message-avatar {
+ border-color: color-mix(in srgb, var(--accent) 34%, var(--border));
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
+ color: var(--accent);
+}
+
+.ticket-message-row--internal .ticket-message-avatar {
+ border-style: dashed;
+ border-color: color-mix(in srgb, var(--warning) 42%, var(--border));
+ background: color-mix(in srgb, var(--warning) 10%, transparent);
+ color: var(--warning-text);
+}
+
+.ticket-message-content {
+ display: flex;
+ width: fit-content;
+ max-width: min(82%, 680px);
+ min-width: 0;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.ticket-message-meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 5px;
+ min-width: 0;
+ padding: 0 4px;
+ color: var(--muted);
+ font-size: 11px;
+ line-height: 1.1;
+}
+
+.ticket-message-row--outgoing .ticket-message-meta {
+ justify-content: flex-end;
+}
+
+.ticket-message-author {
+ min-width: 0;
+ overflow: hidden;
+ color: color-mix(in srgb, var(--muted) 84%, var(--text));
+ font-weight: 650;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ticket-message-meta time {
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ line-height: 1;
+}
+
+.ticket-message-bubble {
+ width: fit-content;
+ max-width: 100%;
+ border: 1px solid var(--ticket-bubble-border, var(--border));
+ border-radius: 14px 14px 14px 4px;
+ background: var(--ticket-bubble-bg, color-mix(in srgb, var(--panel-2) 92%, transparent));
+ padding: 10px 13px;
+ white-space: pre-wrap;
+}
+
+.ticket-message-bubble p {
+ margin: 0;
+ overflow-wrap: anywhere;
+ word-break: normal;
+ color: var(--ticket-bubble-text, var(--text));
+ font-size: 14px;
+ line-height: 1.5;
+}
+
+.ticket-message-row--outgoing .ticket-message-bubble {
+ border-radius: 14px 14px 4px 14px;
+}
+
+.ticket-message-row--internal .ticket-message-bubble {
+ --ticket-bubble-bg: color-mix(in srgb, var(--warning) 8%, var(--panel-2));
+ --ticket-bubble-border: color-mix(in srgb, var(--warning) 28%, var(--border));
+ border-style: dashed;
+}
+
+.ticket-composer {
+ display: grid;
+ gap: 10px;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: color-mix(in srgb, var(--panel) 88%, transparent);
+ padding: 12px;
+ box-shadow: var(--shadow-soft);
+ transition: border-color 0.16s ease;
+}
+
+.ticket-composer:focus-within {
+ border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
+ box-shadow:
+ var(--shadow-soft),
+ 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
+}
+
+.ticket-composer-textarea {
+ min-height: 96px;
+ resize: vertical;
+}
+
+.ticket-composer-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.ticket-composer-row small {
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0.01em;
+}
+
+.ticket-composer-send {
+ min-width: 132px;
+}
+
+.support-messages-empty {
+ display: grid;
+ min-height: 120px;
+ place-items: center;
+ color: var(--muted);
+ font-size: 13px;
+ text-align: center;
+}
+
+@keyframes ticket-message-enter {
+ from {
+ opacity: 0;
+ transform: translateY(6px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@media (min-width: 1024px) {
+ .support-screen {
+ min-height: calc(100dvh - 74px);
+ grid-template-rows: auto minmax(0, 1fr);
+ align-content: stretch;
+ gap: 12px;
+ }
+
+ .support-ticket-screen {
+ height: calc(100dvh - 68px);
+ min-height: 0;
+ max-height: calc(100dvh - 68px);
+ grid-template-rows: auto minmax(0, 1fr);
+ align-content: stretch;
+ gap: 14px;
+ overflow: hidden;
+ padding-bottom: 0;
+ }
+
+ .support-overview-card,
+ .support-list-card,
+ .support-ticket-card,
+ .support-conversation-card {
+ gap: 14px;
+ }
+
+ .support-list-card {
+ min-height: 0;
+ grid-template-rows: auto minmax(0, 1fr);
+ align-content: stretch;
+ }
+
+ .support-list-card .ticket-list,
+ .support-user-list-skeleton {
+ align-content: start;
+ min-height: 0;
+ height: 100%;
+ overflow: auto;
+ padding-right: 4px;
+ scrollbar-width: thin;
+ }
+
+ .support-list-card .support-empty-state {
+ width: min(100%, 430px);
+ min-height: 220px;
+ height: auto;
+ align-self: center;
+ justify-self: center;
+ }
+
+ .support-heading-row {
+ grid-template-columns: 46px minmax(0, 1fr);
+ }
+
+ .support-heading-icon {
+ width: 46px;
+ height: 46px;
+ }
+
+ .support-new-ticket-button {
+ min-height: 62px;
+ padding: 10px 12px;
+ }
+
+ .support-create-panel-inner {
+ padding: 14px;
+ }
+
+ .support-status-tabs-trigger {
+ flex: 1 1 0;
+ text-align: center;
+ }
+
+ .ticket-card {
+ min-height: 84px;
+ padding: 16px 16px 16px 20px;
+ }
+
+ .ticket-card-title strong {
+ font-size: 15.5px;
+ }
+
+ .support-ticket-detail-header {
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 14px;
+ margin-bottom: 0;
+ }
+
+ .support-ticket-detail-header .ticket-badges {
+ grid-column: 3;
+ justify-content: flex-end;
+ }
+
+ .support-ticket-screen > .support-ticket-state-card {
+ min-height: calc(100dvh - 74px);
+ }
+
+ .support-conversation-card {
+ height: 100%;
+ min-height: 0;
+ grid-template-rows: minmax(0, 1fr) auto;
+ align-content: stretch;
+ overflow: hidden;
+ }
+
+ .support-conversation-card--loading {
+ grid-template-rows: minmax(0, 1fr);
+ }
+
+ .support-ticket-screen .support-message-scroll {
+ min-height: 0;
+ height: 100%;
+ overflow: auto;
+ padding: 20px;
+ scrollbar-width: thin;
+ }
+
+ .support-ticket-screen .ticket-message-list {
+ min-height: 100%;
+ }
+
+ .support-ticket-screen .support-messages-empty {
+ min-height: 0;
+ height: 100%;
+ }
+
+ .support-ticket-screen .ticket-composer {
+ padding: 14px;
+ }
+
+ .support-ticket-screen .ticket-composer-textarea {
+ min-height: 92px;
+ }
+
+ .ticket-message-row {
+ grid-template-columns: 40px minmax(0, 1fr);
+ }
+
+ .ticket-message-row--outgoing {
+ grid-template-columns: minmax(0, 1fr) 40px;
+ }
+
+ .ticket-message-avatar {
+ width: 40px;
+ height: 40px;
+ }
+}
+
+@media (max-width: 520px) {
+ .support-screen {
+ padding-bottom: calc(var(--bottom-nav-height) + var(--bottom-nav-offset) + 24px);
+ }
+
+ .support-ticket-screen {
+ height: calc(100dvh - max(16px, env(safe-area-inset-top)) - 48px);
+ min-height: 0;
+ grid-template-rows: auto minmax(0, 1fr);
+ align-content: stretch;
+ gap: 10px;
+ overflow: hidden;
+ padding-bottom: calc(
+ var(--bottom-nav-height) + max(var(--bottom-nav-offset), env(safe-area-inset-bottom)) + 12px
+ );
+ }
+
+ .support-ticket-screen > .support-ticket-state-card {
+ min-height: 100%;
+ }
+
+ .support-ticket-screen .support-conversation-card {
+ min-height: 0;
+ grid-template-rows: minmax(0, 1fr) auto;
+ align-content: stretch;
+ overflow: hidden;
+ }
+
+ .support-ticket-screen .support-conversation-card--loading {
+ grid-template-rows: minmax(0, 1fr);
+ }
+
+ .support-new-ticket-button {
+ min-height: 58px;
+ grid-template-columns: 38px minmax(0, 1fr) auto;
+ padding: 9px 10px;
+ }
+
+ .support-new-ticket-icon {
+ width: 38px;
+ height: 38px;
+ }
+
+ .support-create-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .support-message-input {
+ height: 108px;
+ min-height: 108px;
+ }
+
+ .support-status-tabs-list {
+ display: flex;
+ width: 100%;
+ overflow-x: auto;
+ }
+
+ .support-status-tabs-trigger {
+ flex: 0 0 auto;
+ padding-inline: 10px;
+ white-space: nowrap;
+ line-height: 1;
+ }
+
+ .support-empty-state {
+ min-height: 190px;
+ }
+
+ .support-user-ticket-skeleton {
+ grid-template-columns: 1fr;
+ align-items: start;
+ gap: 12px;
+ padding: 14px;
+ }
+
+ .support-user-ticket-skeleton-side {
+ grid-auto-flow: column;
+ justify-content: start;
+ justify-items: start;
+ }
+
+ .ticket-card {
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: start;
+ gap: 10px;
+ padding: 14px 14px 14px 18px;
+ }
+
+ .ticket-card-main {
+ gap: 5px;
+ }
+
+ .ticket-card-title strong {
+ display: -webkit-box;
+ overflow: hidden;
+ white-space: normal;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ font-size: 14.5px;
+ }
+
+ .ticket-card-side {
+ align-items: flex-end;
+ gap: 6px;
+ }
+
+ .ticket-card-badges {
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 5px;
+ }
+
+ .support-ticket-detail-header {
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 8px 10px;
+ }
+
+ .support-ticket-detail-header .ticket-badges {
+ grid-column: 2;
+ justify-content: flex-start;
+ }
+
+ .support-back-button {
+ width: fit-content;
+ }
+
+ .support-ticket-detail-header .ticket-detail-title h1 {
+ font-size: 17px;
+ line-height: 1.18;
+ }
+
+ .support-message-scroll,
+ .support-admin-message-scroll {
+ max-height: none !important;
+ overflow: visible;
+ padding: 12px 10px;
+ }
+
+ .support-ticket-screen .support-message-scroll {
+ min-height: 0;
+ height: 100%;
+ overflow: auto;
+ padding: 10px;
+ }
+
+ .support-ticket-screen .ticket-message-list {
+ min-height: 100%;
+ align-content: end;
+ }
+
+ .support-ticket-screen .support-messages-empty {
+ min-height: 0;
+ height: 100%;
+ }
+
+ .ticket-message-list,
+ .support-admin-messages {
+ gap: 14px;
+ }
+
+ .ticket-message-row {
+ grid-template-columns: 32px minmax(0, 1fr);
+ gap: 8px;
+ }
+
+ .ticket-message-row--outgoing {
+ grid-template-columns: minmax(0, 1fr) 32px;
+ }
+
+ .ticket-message-row--outgoing .ticket-message-avatar {
+ grid-column: 2;
+ }
+
+ .ticket-message-row--outgoing .ticket-message-content {
+ grid-column: 1;
+ justify-self: end;
+ align-items: flex-end;
+ }
+
+ .ticket-message-row--incoming .ticket-message-content {
+ grid-column: 2;
+ justify-self: start;
+ }
+
+ .ticket-message-content {
+ max-width: min(86%, calc(100vw - 80px));
+ }
+
+ .ticket-message-avatar {
+ width: 32px;
+ height: 32px;
+ }
+
+ .ticket-message-avatar .brand-mark {
+ font-size: 18px;
+ }
+
+ .ticket-message-bubble {
+ width: fit-content;
+ border-radius: 14px 14px 14px 4px;
+ padding: 9px 11px;
+ }
+
+ .ticket-message-row--outgoing .ticket-message-bubble {
+ border-radius: 14px 14px 4px 14px;
+ }
+
+ .ticket-message-bubble p {
+ font-size: 13.5px;
+ line-height: 1.45;
+ }
+
+ .ticket-composer-textarea {
+ min-height: 76px;
+ }
+
+ .support-ticket-screen .ticket-composer {
+ gap: 6px;
+ padding: 9px 10px;
+ }
+
+ .support-ticket-screen .ticket-composer-textarea {
+ height: 56px;
+ min-height: 56px;
+ }
+
+ .ticket-composer-row {
+ align-items: center;
+ flex-direction: row;
+ }
+
+ .ticket-composer-send {
+ width: auto;
+ min-width: 108px;
+ padding-inline: 14px;
+ }
+}
+
+@media (max-width: 520px) {
+ .support-new-ticket-button strong,
+ .ticket-card-title strong,
+ .ticket-detail-title h1 {
+ letter-spacing: 0;
+ }
+}
diff --git a/frontend/src/styles/webapp.css b/frontend/src/styles/webapp.css
index 3427838..bb5e21f 100644
--- a/frontend/src/styles/webapp.css
+++ b/frontend/src/styles/webapp.css
@@ -1466,20 +1466,6 @@ a {
position: relative;
}
-.attention-dot {
- position: absolute;
- top: 0;
- right: 0;
- width: 13px;
- height: 13px;
- transform: translate(50%, -50%);
- border-radius: 999px;
- background: #ff4b4b;
- box-shadow: 0 0 0 0 rgba(255, 75, 75, 0.75);
- animation: attention-pulse 1.6s ease-out infinite;
- pointer-events: none;
-}
-
.nav-attention-dot {
top: 6px;
right: 10px;
@@ -1490,20 +1476,6 @@ a {
padding-right: 6px;
}
-@keyframes attention-pulse {
- 0% {
- box-shadow: 0 0 0 0 rgba(255, 75, 75, 0.75);
- }
-
- 70% {
- box-shadow: 0 0 0 8px rgba(255, 75, 75, 0);
- }
-
- 100% {
- box-shadow: 0 0 0 0 rgba(255, 75, 75, 0);
- }
-}
-
.bottom-nav {
position: fixed !important;
left: var(--bottom-nav-left);
@@ -1514,9 +1486,11 @@ a {
bottom: max(var(--bottom-nav-offset), env(safe-area-inset-bottom));
z-index: 80;
display: grid;
- grid-template-columns: repeat(3, 1fr);
+ grid-template-columns: repeat(var(--bottom-nav-visible-items, 3), minmax(0, 1fr));
+ grid-auto-flow: column;
gap: 2px;
min-height: 64px;
+ overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--nav-bg);
@@ -1525,7 +1499,7 @@ a {
}
.bottom-nav-devices {
- grid-template-columns: repeat(4, 1fr);
+ grid-template-columns: repeat(var(--bottom-nav-visible-items, 4), minmax(0, 1fr));
}
.bottom-nav.static {
@@ -1534,6 +1508,7 @@ a {
.bottom-nav button {
display: grid;
+ min-width: 0;
place-items: center;
align-content: center;
gap: 4px;
@@ -1542,16 +1517,77 @@ a {
color: var(--muted);
font-size: 10px;
font-weight: 800;
+ overflow: hidden;
+ padding: 6px 2px;
}
.bottom-nav button.active {
color: var(--accent);
}
+.bottom-nav .bottom-nav-label {
+ display: block;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ line-height: 1.1;
+}
+
.rail-admin-entry {
display: none !important;
}
+@media (max-width: 380px) {
+ .bottom-nav.bottom-nav-many {
+ min-height: 58px;
+ }
+
+ .bottom-nav.bottom-nav-many button {
+ gap: 0;
+ padding: 0 2px;
+ }
+
+ .bottom-nav.bottom-nav-many .bottom-nav-label {
+ display: none;
+ }
+
+ .bottom-nav.bottom-nav-many .nav-attention-dot {
+ top: 8px;
+ right: calc(50% - 18px);
+ }
+
+ .bottom-nav.bottom-nav-many .nav-badge-floating {
+ top: 6px;
+ right: calc(50% - 24px);
+ }
+}
+
+@media (max-width: 360px) {
+ .bottom-nav {
+ min-height: 56px;
+ }
+
+ .bottom-nav button {
+ gap: 0;
+ padding: 0 2px;
+ }
+
+ .bottom-nav .bottom-nav-label {
+ display: none;
+ }
+
+ .bottom-nav .nav-attention-dot {
+ top: 8px;
+ right: calc(50% - 18px);
+ }
+
+ .bottom-nav .nav-badge-floating {
+ top: 6px;
+ right: calc(50% - 24px);
+ }
+}
+
.home-layout {
display: grid;
min-height: calc(100dvh - 34px);
@@ -1975,6 +2011,10 @@ a {
font-size: 9px;
}
+.preview-phone .bottom-nav .bottom-nav-label {
+ font-size: 9px;
+}
+
.preview-phone .card {
padding: 12px;
}
@@ -2247,10 +2287,14 @@ a {
display: grid !important;
grid-template-columns: 1fr !important;
grid-template-rows: none !important;
+ grid-auto-flow: row !important;
grid-auto-rows: auto !important;
+ grid-auto-columns: 1fr !important;
align-content: start !important;
gap: 2px !important;
padding: 28px 14px !important;
+ overflow-x: hidden !important;
+ overflow-y: auto !important;
border: 0 !important;
border-right: 1px solid var(--border) !important;
border-radius: 0 !important;
@@ -2271,7 +2315,7 @@ a {
align-items: center !important;
justify-items: start !important;
gap: 12px !important;
- padding: 12px 14px !important;
+ padding: 12px 42px 12px 14px !important;
border-radius: 10px !important;
text-align: left !important;
font-size: 13px !important;
@@ -2281,6 +2325,7 @@ a {
background 0.12s ease,
color 0.12s ease,
border-color 0.12s ease;
+ width: 100%;
}
.bottom-nav button > svg {
@@ -2288,7 +2333,7 @@ a {
height: 20px;
}
- .bottom-nav button > span {
+ .bottom-nav .bottom-nav-label {
text-align: left !important;
font-size: 13px !important;
font-weight: 600;
@@ -2305,9 +2350,11 @@ a {
color: var(--text);
}
- .bottom-nav .nav-attention-dot {
- top: 14px;
+ .bottom-nav .nav-attention-dot,
+ .bottom-nav .nav-badge-floating {
+ top: 50%;
right: 14px;
+ transform: translateY(-50%);
}
.bottom-nav .rail-admin-entry {
@@ -2393,6 +2440,7 @@ a {
@media (min-width: 1024px) {
.bottom-nav .rail-brand {
display: flex;
+ width: 100%;
align-items: center;
gap: 12px;
padding: 4px 12px 18px;
diff --git a/frontend/src/webapp/BottomNav.svelte b/frontend/src/webapp/BottomNav.svelte
index 262d2e3..de0ea28 100644
--- a/frontend/src/webapp/BottomNav.svelte
+++ b/frontend/src/webapp/BottomNav.svelte
@@ -2,10 +2,12 @@
import {
Gift,
Home,
+ LifeBuoy,
Settings as SettingsIcon,
Shield,
Smartphone,
} from "$components/ui/icons.js";
+ import { AttentionDot } from "$components/ui/index.js";
import BrandMark from "$lib/webapp/BrandMark.svelte";
@@ -13,51 +15,107 @@
export let brand = {};
export let brandTitle = "";
export let devicesEnabled = false;
+ export let supportEnabled = true;
+ export let supportUnreadCount = 0;
+ export let supportUnreadLoading = false;
+ export let supportUnreadLoaded = false;
export let hasUnlinkedIdentity = false;
export let isAdmin = false;
export let onAdmin = () => {};
export let onDevices = () => {};
export let onHome = () => {};
export let onInvite = () => {};
+ export let onSupport = () => {};
export let onSettings = () => {};
export let t = (key) => key;
+
+ $: visibleNavItems = 3 + (devicesEnabled ? 1 : 0) + (supportEnabled ? 1 : 0);
+ $: adminLabel = t("admin_nav_title", {}, "Админ-панель");
-