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} +
+ +
+ + +
+ setFilterAndLoad("priority", value)} + /> + setFilterAndLoad("category", value)} + /> + setFilterAndLoad("sort", value)} + /> + supportStore.loadList()}> + {at("apply", {}, "Применить")} + +
+
+ + {#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} +
+
+ + + {#if !ticketReady} +
+ + + + + +
+ {:else} +
+ supportStore.patchTicket(updates)} + onClose={() => supportStore.closeTicket()} + /> + + +
+ {#if messages.length} + {#each messages as message} + + {/each} + {:else} +
+ {at("support_no_messages", {}, "Сообщений пока нет")} +
+ {/if} +
+
+ +
+ {/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 @@ + + +
+ 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", {}, "Админ-панель"); -