Переглянути джерело

feat: enhance chat UX with flood control and auto-scroll, optimize backend with slicing cache and SEO metadata

unknown 6 днів тому
батько
коміт
9a7811a9eb

+ 2 - 0
api_test_out.txt

@@ -0,0 +1,2 @@
+Status: 200
+Body: []

+ 18 - 3
backend/locales.py

@@ -8,7 +8,8 @@ ERROR_TRANSLATIONS = {
         "email_already_registered": "Email уже зарегистрирован",
         "incorrect_credentials": "Неверный email или пароль",
         "user_not_found": "Пользователь не найден",
-        "invalid_token": "Недействительный токен"
+        "invalid_token": "Недействительный токен",
+        "flood_control": "Слишком много сообщений. Пожалуйста, подождите 10 секунд."
     },
     "me": {
         "missing": "Ovo polje je obavezno",
@@ -19,7 +20,20 @@ ERROR_TRANSLATIONS = {
         "email_already_registered": "Email je već registrovan",
         "incorrect_credentials": "Neispravan email ili lozinka",
         "user_not_found": "Korisnik nije pronađen",
-        "invalid_token": "Neispravan token"
+        "invalid_token": "Neispravan token",
+        "flood_control": "Previše poruka. Molimo sačekajte 10 sekundi."
+    },
+    "ua": {
+        "missing": "Це поле обов'язкове",
+        "string_too_short": "Занадто коротко, мінімум {min_length} символів",
+        "value_error.email": "Некоректний email",
+        "value_error.any_str.min_length": "Мінімальна довжина {limit_value} симв.",
+        "type_error.integer": "Має бути цілим числом",
+        "email_already_registered": "Email вже зареєстрований",
+        "incorrect_credentials": "Невірний email або пароль",
+        "user_not_found": "Користувача не знайдено",
+        "invalid_token": "Недійсний токен",
+        "flood_control": "Занадто багато повідомлень. Будь ласка, зачекайте 10 секунд."
     },
     "en": {
         "missing": "Field is required",
@@ -28,7 +42,8 @@ ERROR_TRANSLATIONS = {
         "email_already_registered": "Email already registered",
         "incorrect_credentials": "Incorrect email or password",
         "user_not_found": "User not found",
-        "invalid_token": "Invalid or expired token"
+        "invalid_token": "Invalid or expired token",
+        "flood_control": "Too many messages. Please wait 10 seconds."
     }
 }
 

+ 17 - 1
backend/routers/chat.py

@@ -5,9 +5,13 @@ import db
 import auth_utils
 import datetime
 import schemas
+import locales
 
 router = APIRouter(tags=["chat"])
 
+# In-memory storage for flood control: {user_id: timestamp}
+last_message_times = {}
+
 @router.get("/orders/{order_id}/messages")
 async def get_order_messages(order_id: int, token: str = Depends(auth_utils.oauth2_scheme)):
     payload = auth_utils.decode_token(token)
@@ -26,6 +30,7 @@ async def get_order_messages(order_id: int, token: str = Depends(auth_utils.oaut
     messages = db.execute_query("SELECT id, is_from_admin, message, created_at FROM order_messages WHERE order_id = %s ORDER BY created_at ASC", (order_id,))
     for msg in messages:
         if msg.get('created_at'): msg['created_at'] = msg['created_at'].isoformat()
+        msg['is_from_admin'] = bool(msg['is_from_admin'])
         
     # Mark messages as read
     if role == 'admin':
@@ -38,9 +43,20 @@ async def get_order_messages(order_id: int, token: str = Depends(auth_utils.oaut
     return messages
 
 @router.post("/orders/{order_id}/messages")
-async def post_order_message(order_id: int, data: schemas.MessageCreate, token: str = Depends(auth_utils.oauth2_scheme)):
+async def post_order_message(order_id: int, data: schemas.MessageCreate, token: str = Depends(auth_utils.oauth2_scheme), lang: str = "en"):
     payload = auth_utils.decode_token(token)
     if not payload: raise HTTPException(status_code=401, detail="Invalid token")
+    role = payload.get("role")
+    user_id = payload.get("id")
+    
+    # Flood control for non-admin users
+    if role != 'admin':
+        now = datetime.datetime.utcnow().timestamp()
+        last_time = last_message_times.get(user_id, 0)
+        if now - last_time < 10:
+             raise HTTPException(status_code=429, detail=locales.translate_error("flood_control", lang))
+        last_message_times[user_id] = now
+
     message = data.message.strip()
     if not message: raise HTTPException(status_code=400, detail="Empty message")
     role = payload.get("role")

+ 202 - 0
scratch/fill_admin_locales.py

@@ -0,0 +1,202 @@
+import json
+import os
+
+def fill_gaps():
+    path = os.path.join("src", "locales", "translations.json")
+    with open(path, "r", encoding="utf-8") as f:
+        data = json.load(f)
+
+    def set_translations(key_path, ru, ua):
+        parts = key_path.split('.')
+        curr = data
+        for p in parts[:-1]:
+            curr = curr.get(p, {})
+        if parts[-1] in curr:
+            curr[parts[-1]]["ru"] = ru
+            curr[parts[-1]]["ua"] = ua
+
+    # Admin Actions
+    set_translations("admin.actions.cancel", "Отмена", "Скасувати")
+    set_translations("admin.actions.create", "Создать", "Створити")
+    set_translations("admin.actions.delete", "Удалить", "Видалити")
+    set_translations("admin.actions.deleteFile", "Удалить файл", "Видалити файл")
+    set_translations("admin.actions.edit", "Редактировать", "Редагувати")
+    set_translations("admin.actions.printInvoice", "Печать счета", "Друк рахунку")
+    set_translations("admin.actions.save", "Сохранить", "Зберегти")
+    set_translations("admin.actions.savePrice", "Сохранить цену", "Зберегти ціну")
+    set_translations("admin.actions.sending", "Отправка...", "Надсилання...")
+    set_translations("admin.actions.toggleAdminRole", "Переключить роль админа", "Перемкнути роль адміна")
+    set_translations("admin.actions.viewOriginal", "Оригинал", "Оригінал")
+    set_translations("admin.addNew", "Добавить", "Додати")
+    set_translations("admin.allStatuses", "Все статусы", "Усі статуси")
+    set_translations("admin.dashboard", "Дэшборд", "Дешборд")
+
+    # Admin Fields
+    set_translations("admin.fields.active", "Активен", "Активний")
+    set_translations("admin.fields.category", "Категория", "Категорія")
+    set_translations("admin.fields.colors", "Цвета", "Кольори")
+    set_translations("admin.fields.content", "Контент", "Контент")
+    set_translations("admin.fields.customColorInfo", "Введите HEX или название", "Введіть HEX або назву")
+    set_translations("admin.fields.defaultColor", "Цвет по умолчанию", "Колір за замовчуванням")
+    set_translations("admin.fields.description", "Описание", "Опис")
+    set_translations("admin.fields.email", "Email", "Email")
+    set_translations("admin.fields.estimated", "Оценка", "Оцінка")
+    set_translations("admin.fields.excerpt", "Краткое описание", "Короткий опис")
+    set_translations("admin.fields.externalLink", "Внешняя ссылка", "Зовнішнє посилання")
+    set_translations("admin.fields.finalPrice", "Финальная цена", "Фінальна ціна")
+    set_translations("admin.fields.firstName", "Имя", "Ім'я")
+    set_translations("admin.fields.imageUrl", "URL изображения", "URL зображення")
+    set_translations("admin.fields.lastName", "Фамилия", "Прізвище")
+    set_translations("admin.fields.name", "Название", "Назва")
+    set_translations("admin.fields.noPhotos", "Нет фото", "Немає фото")
+    set_translations("admin.fields.noPortfolio", "Портфолио пусто", "Портфоліо порожнє")
+    set_translations("admin.fields.noUsers", "Пользователи не найдены", "Користувачів не знайдено")
+    set_translations("admin.fields.notifyUser", "Уведомить клиента", "Повідомити клієнта")
+    set_translations("admin.fields.originalSnapshot", "Снимок заказа", "Знімок замовлення")
+    set_translations("admin.fields.password", "Пароль", "Пароль")
+    set_translations("admin.fields.phone", "Телефон", "Телефон")
+    set_translations("admin.fields.photoReport", "Фотоотчет", "Фотозвіт")
+    set_translations("admin.fields.portfolioAllowed", "Разрешить в портфолио", "Дозволити в портфоліо")
+    set_translations("admin.fields.price", "Цена", "Ціна")
+    set_translations("admin.fields.pricePerCm3", "Цена за см³", "Ціна за см³")
+    set_translations("admin.fields.projectNotes", "Заметки к проекту", "Нотатки до проєкту")
+    set_translations("admin.fields.publishImmediately", "Опубликовать сразу", "Опублікувати відразу")
+    set_translations("admin.fields.quantity", "Количество", "Кількість")
+    set_translations("admin.fields.selectColorStrict", "Строгий выбор цвета", "Суворий вибір кольору")
+    set_translations("admin.fields.selectMaterialStrict", "Строгий выбор материала", "Суворий вибір матеріалу")
+    set_translations("admin.fields.shippingAddress", "Адрес доставки", "Адреса доставки")
+    set_translations("admin.fields.slug", "Slug (URL)", "Slug (URL)")
+    set_translations("admin.fields.snapshotInfo", "Состояние заказа на момент создания", "Стан замовлення на момент створення")
+    set_translations("admin.fields.sourceFiles", "Исходные файлы", "Вихідні файли")
+    set_translations("admin.fields.strictSelectionInfo", "Клиент может выбирать только из списка", "Клієнт може вибирати тільки зі списку")
+    set_translations("admin.fields.techType", "Тип технологии", "Тип технології")
+    set_translations("admin.fields.title", "Заголовок", "Заголовок")
+
+    # Labels
+    set_translations("admin.labels.actions", "Действия", "Дії")
+    set_translations("admin.labels.chat", "Чат", "Чат")
+    set_translations("admin.labels.contact", "Контакт", "Контакт")
+    set_translations("admin.labels.registered", "Зарегистрирован", "Зареєстрований")
+    set_translations("admin.labels.role", "Роль", "Роль")
+    set_translations("admin.labels.user", "Пользователь", "Користувач")
+    set_translations("admin.managementCenter", "Центр управления", "Центр управління")
+
+    # Modals
+    set_translations("admin.modals.createMaterial", "Добавить материал", "Додати матеріал")
+    set_translations("admin.modals.createPost", "Новая запись", "Новий запис")
+    set_translations("admin.modals.createService", "Новая услуга", "Нова послуга")
+    set_translations("admin.modals.createUser", "Новый пользователь", "Новий користувач")
+    set_translations("admin.modals.editMaterial", "Редактировать материал", "Редагувати матеріал")
+    set_translations("admin.modals.editPost", "Редактировать запись", "Редагувати запис")
+    set_translations("admin.modals.editService", "Редактировать услугу", "Редагувати послугу")
+    set_translations("admin.modals.editUser", "Редактировать пользователя", "Редагувати користувача")
+    set_translations("admin.searchPlaceholder", "Поиск...", "Пошук...")
+    set_translations("admin.searchUsersPlaceholder", "Поиск пользователей...", "Пошук користувачів...")
+
+    # Toasts
+    set_translations("admin.toasts.chatDisabled", "Чат отключен", "Чат вимкнено")
+    set_translations("admin.toasts.chatEnabled", "Чат включен", "Чат увімкнено")
+    set_translations("admin.toasts.fileAttached", "Файл прикреплен", "Файл прикріплено")
+    set_translations("admin.toasts.fileDeleted", "Файл удален", "Файл видалено")
+    set_translations("admin.toasts.genericError", "Произошла ошибка", "Сталася помилка")
+    set_translations("admin.toasts.loadError", "Ошибка загрузки", "Помилка завантаження")
+    set_translations("admin.toasts.materialDeleted", "Материал удален", "Матеріал видалено")
+    set_translations("admin.toasts.materialSaved", "Материал сохранен", "Матеріал збережено")
+    set_translations("admin.toasts.noConsent", "Нет согласия", "Немає згоди")
+    set_translations("admin.toasts.paramsUpdated", "Параметры обновлены", "Параметри оновлено")
+    set_translations("admin.toasts.photoAdded", "Фото добавлено", "Фото додано")
+    set_translations("admin.toasts.postDeleted", "Запись удалена", "Запис видалено")
+    set_translations("admin.toasts.postSaved", "Запись сохранена", "Запис збережено")
+    set_translations("admin.toasts.priceUpdated", "Цена обновлена", "Ціна оновлена")
+    set_translations("admin.toasts.roleUpdated", "Роль обновлена", "Роль оновлена")
+    set_translations("admin.toasts.serviceDeleted", "Услуга удалена", "Послуга видалена")
+    set_translations("admin.toasts.serviceSaved", "Услуга сохранена", "Послугу збережено")
+    set_translations("admin.toasts.statusUpdated", "Статус обновлен", "Статус оновлено")
+    set_translations("admin.toasts.userCreated", "Пользователь создан", "Користувач створений")
+    set_translations("admin.toasts.userSaved", "Пользователь сохранен", "Користувача збережено")
+    set_translations("admin.total", "Всего", "Всього")
+
+    # Auth
+    set_translations("auth.fields.newPassword", "Новый пароль", "Новий пароль")
+    set_translations("auth.studio", "Студия 3D Печати", "Студія 3D Друку")
+    set_translations("auth.toasts.accountCreated", "Аккаунт создан! Теперь можно войти.", "Акаунт створено! Тепер можна увійти.")
+    set_translations("auth.toasts.passwordChanged", "Пароль успешно изменен!", "Пароль успішно змінено!")
+    set_translations("auth.toasts.passwordsNoMatch", "Пароли не совпадают", "Паролі не збігаються")
+    set_translations("auth.toasts.resetLinkSent", "Ссылка на сброс пароля отправлена на почту.", "Посилання на скидання пароля надіслано на пошту.")
+    set_translations("auth.toasts.socialSoon", "Вход через {provider} скоро появится!", "Вхід через {provider} скоро з'явиться!")
+    set_translations("auth.toasts.welcomeBack", "С возвращением!", "З поверненням!")
+
+    # Blog & Errors
+    set_translations("blog.exploreOther", "Посмотреть другие", "Переглянути інші")
+    set_translations("blog.loading", "Загрузка записей...", "Завантаження записів...")
+    set_translations("blog.loadingSingle", "Загрузка записи...", "Завантаження запису...")
+    set_translations("blog.notFound", "Запись не найдена", "Запис не знайдено")
+    set_translations("errors.404.button", "Вернуться на главную", "Повернутися на головну")
+    set_translations("errors.404.subtitle", "Страница не найдена", "Сторінка не знайдена")
+    set_translations("errors.404.title", "Ошибка 404", "Помилка 404")
+
+    # Footer & Nav
+    set_translations("footer.location", "Херцег-Нови, Черногория", "Херцег-Нові, Чорногорія")
+    set_translations("nav.admin", "Админ", "Адмін")
+    set_translations("nav.adminPanel", "Панель управления", "Панель управління")
+    set_translations("nav.loggedOut", "Вы успешно вышли", "Ви успішно вийшли")
+    set_translations("nav.nuances", "Нюансы", "Нюанси")
+    set_translations("nav.unreadMessages", "Непрочитанные сообщения", "Непрочитані повідомлення")
+    set_translations("nav.unreadTooltip", "У вас есть непрочитанные сообщения", "У вас є непрочитані повідомлення")
+
+    # Orders
+    set_translations("orders.labels.estimate", "Расчет", "Розрахунок")
+    set_translations("orders.labels.materialColor", "Материал и цвет", "Матеріал та колір")
+    set_translations("orders.labels.myNotes", "Мои заметки", "Мої замітки")
+    set_translations("orders.labels.progressReport", "Отчет о выполнении", "Звіт про виконання")
+    set_translations("orders.labels.projectFiles", "Файлы проекта", "Файли проєкту")
+    set_translations("orders.labels.quantity", "Кол-во", "К-сть")
+    set_translations("orders.labels.status", "Статус", "Статус")
+
+    # Portfolio & Privacy
+    set_translations("portfolio.emptyDesc", "Здесь скоро появятся наши новые работы.", "Тут скоро з'являться наші нові роботи.")
+    set_translations("portfolio.emptyTitle", "Портфолио пусто", "Портфоліо порожнє")
+    set_translations("portfolio.loading", "Загрузка портфолио...", "Завантаження портфоліо...")
+    set_translations("privacy.contactDesc", "Если у вас есть вопросы, наша команда всегда готова помочь.", "Якщо у вас є питання, наша команда завжди готова допомогти.")
+    set_translations("privacy.contactTitle", "Нужна помощь?", "Потрібна допомога?")
+    set_translations("privacy.responseNotice", "Мы отвечаем на все запросы в течение 48 часов.", "Ми відповідаємо на всі запити протягом 48 годин.")
+
+    # Upload
+    set_translations("upload.error", "Ошибка загрузки", "Помилка завантаження")
+    set_translations("upload.estimatedTotal", "Приблизительный итог", "Орієнтовний підсумок")
+    set_translations("upload.priceDisclaimer", "Финальная цена может измениться после проверки", "Фінальна ціна може змінитися після перевірки")
+    set_translations("upload.selectColor", "Выберите цвет", "Виберіть колір")
+
+    # Footer missing ones
+    def set_simple(path, lang, val):
+        parts = path.split('.')
+        curr = data
+        for p in parts[:-1]: curr = curr.get(p, {})
+        if parts[-1] in curr:
+            if isinstance(curr[parts[-1]], dict):
+                curr[parts[-1]][lang] = val
+            else:
+                pass
+
+    set_simple("footer.contactDesc", "me", "Ako imate bilo kakvih pitanja, slobodno nas kontaktirajte.")
+    set_simple("footer.contactDesc", "ru", "Если у вас есть вопросы, свяжитесь с нами.")
+    set_simple("footer.contactDesc", "ua", "Якщо у вас є питання, зв'яжіться з нами.")
+    set_simple("footer.contactTitle", "me", "Kontakt")
+    set_simple("footer.contactTitle", "ru", "Контакты")
+    set_simple("footer.contactTitle", "ua", "Контакти")
+    set_simple("footer.intro", "me", "Bavimo se digitalnom zanatom kroz 3D štampu u Crnoj Gori.")
+    set_simple("footer.intro", "ru", "Мы занимаемся цифровым ремеслом через 3D-печать в Черногории.")
+    set_simple("footer.intro", "ua", "Ми займаємося цифровим ремеслом через 3D-друк у Чорногорії.")
+
+    # Fix Portfolio missing EN/ME
+    set_simple("portfolio.empty", "en", "Portfolio is currently empty.")
+    set_simple("portfolio.empty", "me", "Portfolio je trenutno prazan.")
+    
+    # Fix Upload Quantity missing EN
+    set_simple("upload.quantity", "en", "Quantity")
+
+    with open(path, "w", encoding="utf-8") as f:
+        json.dump(data, f, ensure_ascii=False, indent=2)
+
+if __name__ == "__main__":
+    fill_gaps()

+ 39 - 0
scratch/fix_admin_translations.py

@@ -0,0 +1,39 @@
+import json
+import os
+
+def fix_admin_translations():
+    path = os.path.join("src", "locales", "translations.json")
+    with open(path, "r", encoding="utf-8") as f:
+        data = json.load(f)
+
+    if "admin" not in data: data["admin"] = {}
+    
+    # Tab
+    if "tabs" not in data["admin"]: data["admin"]["tabs"] = {}
+    data["admin"]["tabs"]["portfolio"] = {
+        "en": "Portfolio", "me": "Portfolio", "ru": "Портфолио", "ua": "Портфоліо"
+    }
+
+    # Questions
+    if "questions" not in data["admin"]: data["admin"]["questions"] = {}
+    data["admin"]["questions"]["deletePhoto"] = {
+        "en": "Are you sure you want to delete this photo?",
+        "me": "Da li ste sigurni da želite obrisati ovu fotografiju?",
+        "ru": "Вы уверены, что хотите удалить это фото?",
+        "ua": "Ви впевнені, що хочете видалити це фото?"
+    }
+
+    # Toasts
+    if "toasts" not in data["admin"]: data["admin"]["toasts"] = {}
+    data["admin"]["toasts"]["photoDeleted"] = {
+        "en": "Photo deleted successfully",
+        "me": "Fotografija uspješno obrisana",
+        "ru": "Фото успешно удалено",
+        "ua": "Фото успішно видалено"
+    }
+
+    with open(path, "w", encoding="utf-8") as f:
+        json.dump(data, f, ensure_ascii=False, indent=2)
+
+if __name__ == "__main__":
+    fix_admin_translations()

+ 33 - 0
scratch/fix_admin_ui.py

@@ -0,0 +1,33 @@
+import json
+import os
+
+def fix_admin_ui():
+    path = os.path.join("src", "locales", "translations.json")
+    with open(path, "r", encoding="utf-8") as f:
+        data = json.load(f)
+
+    if "admin" not in data: data["admin"] = {}
+    
+    # Statuses
+    data["admin"]["statuses"] = {
+        "pending": { "en": "Pending", "me": "Na čekanju", "ru": "Ожидание", "ua": "Очікування" },
+        "processing": { "en": "Processing", "me": "Obrada", "ru": "В работе", "ua": "В роботі" },
+        "shipped": { "en": "Shipped", "me": "Poslato", "ru": "Отправлено", "ua": "Відправлено" },
+        "completed": { "en": "Completed", "me": "Završeno", "ru": "Завершено", "ua": "Завершено" },
+        "cancelled": { "en": "Cancelled", "me": "Otkazano", "ru": "Отменено", "ua": "Скасовано" }
+    }
+
+    # Tabs
+    data["admin"]["tabs"] = {
+        "orders": { "en": "Orders", "me": "Narudžbe", "ru": "Заказы", "ua": "Замовлення" },
+        "materials": { "en": "Materials", "me": "Materijali", "ru": "Материалы", "ua": "Матеріали" },
+        "services": { "en": "Services", "me": "Usluge", "ru": "Услуги", "ua": "Послуги" },
+        "users": { "en": "Users", "me": "Korisnici", "ru": "Пользователи", "ua": "Користувачі" },
+        "posts": { "en": "Blog", "me": "Blog", "ru": "Блог", "ua": "Блог" }
+    }
+
+    with open(path, "w", encoding="utf-8") as f:
+        json.dump(data, f, ensure_ascii=False, indent=2)
+
+if __name__ == "__main__":
+    fix_admin_ui()

+ 49 - 0
scratch/fix_modal_locales.py

@@ -0,0 +1,49 @@
+import json
+import os
+
+def fix_modal_locales():
+    path = os.path.join("src", "locales", "translations.json")
+    with open(path, "r", encoding="utf-8") as f:
+        data = json.load(f)
+
+    if "admin" not in data: data["admin"] = {}
+    if "actions" not in data["admin"]: data["admin"]["actions"] = {}
+    if "modals" not in data["admin"]: data["admin"]["modals"] = {}
+    if "fields" not in data["admin"]: data["admin"]["fields"] = {}
+
+    # Title
+    data["admin"]["modals"]["changeParams"] = {
+        "en": "Change Material & Color",
+        "me": "Promijeni materijal i boju",
+        "ru": "Изменить материал и цвет",
+        "ua": "Змінити матеріал та колір"
+    }
+
+    # Buttons
+    data["admin"]["actions"]["saveChanges"] = {
+        "en": "Save Changes",
+        "me": "Sačuvaj promjene",
+        "ru": "Сохранить изменения",
+        "ua": "Зберегти зміни"
+    }
+
+    # Custom color messages
+    data["admin"]["fields"]["customColorDirInfo"] = {
+        "en": "Custom Color (No directory info)",
+        "me": "Prilagođena boja (nema info u direktorijumu)",
+        "ru": "Своя цвет (нет инфо в справочнике)",
+        "ua": "Свій колір (немає інфо в довіднику)"
+    }
+    
+    data["admin"]["fields"]["customColorPlaceholder"] = {
+        "en": "Custom color...",
+        "me": "Prilagođena boja...",
+        "ru": "Свой цвет...",
+        "ua": "Свій колір..."
+    }
+
+    with open(path, "w", encoding="utf-8") as f:
+        json.dump(data, f, ensure_ascii=False, indent=2)
+
+if __name__ == "__main__":
+    fix_modal_locales()

+ 29 - 0
scratch/restore_common.py

@@ -0,0 +1,29 @@
+import json
+import os
+
+def fix_common():
+    path = os.path.join("src", "locales", "translations.json")
+    with open(path, "r", encoding="utf-8") as f:
+        data = json.load(f)
+
+    if "common" not in data: data["common"] = {}
+    
+    def set_all(key, en, me, ru, ua):
+        data["common"][key] = {
+            "en": en,
+            "me": me,
+            "ru": ru,
+            "ua": ua
+        }
+
+    set_all("or", "or", "ili", "или", "або")
+    set_all("back", "Back", "Nazad", "Назад", "Назад")
+    set_all("pending", "Pending...", "Na čekanju...", "Ожидание...", "Очікування...")
+    set_all("default", "Default", "Podrazumijevano", "По умолчанию", "За замовчуванням")
+    set_all("orderId", "Order #{id}", "Narudžba #{id}", "Заказ #{id}", "Замовлення #{id}")
+
+    with open(path, "w", encoding="utf-8") as f:
+        json.dump(data, f, ensure_ascii=False, indent=2)
+
+if __name__ == "__main__":
+    fix_common()

+ 22 - 0
scratch/update_price_disclaimer.py

@@ -0,0 +1,22 @@
+import json
+import os
+
+def update_price_disclaimer():
+    path = os.path.join("src", "locales", "translations.json")
+    with open(path, "r", encoding="utf-8") as f:
+        data = json.load(f)
+
+    if "upload" not in data: data["upload"] = {}
+    
+    data["upload"]["priceDisclaimer"] = {
+        "en": "Approximate cost based on material. Complexity and labor are not included and will be factored in the final quote.",
+        "me": "Okviran trošak na bazi materijala. Složenost i rad nisu uključeni i biće dodati u konačnu ponudu.",
+        "ru": "Ориентировочная стоимость на основе материала. Сложность и трудоемкость будут учтены при финальной оценке администратором.",
+        "ua": "Орієнтовна вартість на основі матеріалу. Складність та трудомісткість будуть враховані при фінальній оцінці адміністратором."
+    }
+
+    with open(path, "w", encoding="utf-8") as f:
+        json.dump(data, f, ensure_ascii=False, indent=2)
+
+if __name__ == "__main__":
+    update_price_disclaimer()

+ 52 - 25
src/components/OrderChat.vue

@@ -5,7 +5,6 @@
       <div class="flex items-center gap-2">
         <MessageCircle class="w-4 h-4 text-primary" />
         <span class="text-sm font-bold">{{ t("chat.title") }}</span>
-        <span v-if="messages.length" class="text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded-full font-bold">{{ messages.length }}</span>
         <span class="w-2 h-2 rounded-full" :class="wsConnected ? 'bg-emerald-500' : 'bg-rose-500'" :title="wsConnected ? 'Connected' : 'Disconnected'" />
         <span v-if="otherPartyOnline" class="text-[10px] text-emerald-500 font-bold ml-2 flex items-center gap-1 animate-in fade-in zoom-in duration-300">
           <Eye class="w-3 h-3" /> Online
@@ -74,11 +73,12 @@
         />
         <button
           type="submit"
-          :disabled="!newMessage.trim() || isSending"
-          class="p-2.5 bg-primary text-primary-foreground rounded-xl hover:bg-primary/90 transition-all disabled:opacity-40 disabled:cursor-not-allowed flex-shrink-0"
+          :disabled="!newMessage.trim() || isSending || cooldownLeft > 0"
+          class="p-2.5 bg-primary text-primary-foreground rounded-xl hover:bg-primary/90 transition-all disabled:opacity-40 disabled:cursor-not-allowed flex-shrink-0 relative"
         >
-          <Send v-if="!isSending" class="w-4 h-4" />
-          <Loader2 v-else class="w-4 h-4 animate-spin" />
+          <Send v-if="!isSending && cooldownLeft === 0" class="w-4 h-4" />
+          <Loader2 v-else-if="isSending" class="w-4 h-4 animate-spin" />
+          <span v-else-if="cooldownLeft > 0" class="text-[10px] font-bold">{{ cooldownLeft }}s</span>
         </button>
       </form>
     </div>
@@ -102,17 +102,31 @@ const props = defineProps<{
 
 defineEmits<{ (e: 'close'): void }>();
 
-const { t } = useI18n();
+const { t, locale } = useI18n();
 const messages = ref<any[]>([]);
 const newMessage = ref("");
 const isLoading = ref(true);
 const isSending = ref(false);
+const cooldownLeft = ref(0);
 const wsConnected = ref(false);
 const messagesContainer = ref<HTMLElement | null>(null);
 const textareaRef = ref<HTMLTextAreaElement | null>(null);
 const otherPartyOnline = ref(false);
 const isOtherPartyTyping = ref(false);
 let typingTimeout: ReturnType<typeof setTimeout> | null = null;
+let cooldownInterval: ReturnType<typeof setInterval> | null = null;
+
+function startCooldown() {
+  if (authStore.user?.role === 'admin') return;
+  cooldownLeft.value = 10;
+  if (cooldownInterval) clearInterval(cooldownInterval);
+  cooldownInterval = setInterval(() => {
+    cooldownLeft.value--;
+    if (cooldownLeft.value <= 0) {
+      if (cooldownInterval) clearInterval(cooldownInterval);
+    }
+  }, 1000);
+}
 const authStore = useAuthStore();
 
 function playDing() {
@@ -172,19 +186,22 @@ function connectWebSocket() {
         return;
       }
       
-      // Avoid duplicates (we already optimistically added our own message)
-      if (!messages.value.find(m => m.id === msg.id)) {
-        messages.value.push(msg);
-        scrollToBottom();
-        
-        // Check if we should play a sound
-        const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
-        const isMsgFromMe = (msg.is_from_admin && myRole === 'admin') || (!msg.is_from_admin && myRole === 'user');
-        if (!isMsgFromMe) {
-          playDing();
-          // We indicate we have read it right away
-          ws?.send("read");
-          setTimeout(() => authStore.refreshUnreadCount(), 300);
+      // If it's a message (type 'message' or has essential fields)
+      if (msg.type === "message" || (msg.message && msg.id)) {
+        // Use lenient comparison (==) for ID to avoid string/int mismatches
+        const exists = messages.value.some(m => m.id == msg.id);
+        if (!exists) {
+          messages.value.push(msg);
+          scrollToBottom();
+          
+          // Audio & Read Receipt for incoming messages
+          const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
+          const isMsgFromMe = (msg.is_from_admin && myRole === 'admin') || (!msg.is_from_admin && myRole === 'user');
+          if (!isMsgFromMe) {
+            playDing();
+            ws?.send("read");
+            setTimeout(() => authStore.refreshUnreadCount(), 300);
+          }
         }
       }
     } catch (e) {
@@ -218,7 +235,7 @@ function disconnectWebSocket() {
 
 async function loadMessages() {
   try {
-    messages.value = await getOrderMessages(props.orderId);
+    messages.value = await getOrderMessages(props.orderId, locale.value);
     authStore.refreshUnreadCount();
   } catch (e) {
     console.error("Failed to load messages:", e);
@@ -233,16 +250,26 @@ async function handleSend() {
 
   isSending.value = true;
   try {
-    await sendOrderMessage(props.orderId, text);
+    const response = await sendOrderMessage(props.orderId, text, locale.value);
     newMessage.value = "";
     if (textareaRef.value) {
       textareaRef.value.style.height = "auto";
     }
-    // The server will broadcast back via WS — but also reload as fallback
-    if (!wsConnected.value) {
-      await loadMessages();
+    
+    // Add message to list only if it hasn't arrived via WS yet
+    if (!messages.value.some(m => m.id == response.id)) {
+      const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
+      messages.value.push({
+        id: response.id,
+        message: text,
+        is_from_admin: myRole === 'admin',
+        created_at: new Date().toISOString()
+      });
+      scrollToBottom();
     }
-    scrollToBottom();
+    
+    // Start flood control cooldown
+    startCooldown();
   } catch (e) {
     console.error("Failed to send message:", e);
   } finally {

+ 8 - 4
src/lib/api.ts

@@ -419,18 +419,18 @@ export const getOrderDetails = async (orderId: number) => {
   return response.json();
 };
 
-export const getOrderMessages = async (orderId: number) => {
+export const getOrderMessages = async (orderId: number, lang: string = i18n.global.locale.value) => {
   const token = localStorage.getItem("token");
-  const response = await fetch(`${API_BASE_URL}/orders/${orderId}/messages`, {
+  const response = await fetch(`${API_BASE_URL}/orders/${orderId}/messages?lang=${lang}`, {
     headers: { 'Authorization': `Bearer ${token}` }
   });
   if (!response.ok) throw new Error("Failed to fetch messages");
   return response.json();
 };
 
-export const sendOrderMessage = async (orderId: number, message: string) => {
+export const sendOrderMessage = async (orderId: number, message: string, lang: string = i18n.global.locale.value) => {
   const token = localStorage.getItem("token");
-  const response = await fetch(`${API_BASE_URL}/orders/${orderId}/messages`, {
+  const response = await fetch(`${API_BASE_URL}/orders/${orderId}/messages?lang=${lang}`, {
     method: 'POST',
     headers: {
       'Content-Type': 'application/json',
@@ -438,6 +438,10 @@ export const sendOrderMessage = async (orderId: number, message: string) => {
     },
     body: JSON.stringify({ message })
   });
+  if (response.status === 429) {
+     const err = await response.json();
+     throw new Error(err.detail || "Rate limit exceeded");
+  }
   if (!response.ok) throw new Error("Failed to send message");
   return response.json();
 };

+ 13 - 4
src/pages/Admin.vue

@@ -249,7 +249,7 @@
             </div>
           </div>
           <!-- Admin Chat Panel -->
-          <div v-if="adminChatId === order.id" class="border-t border-border/50">
+          <div v-if="adminChatId === order.id" :id="`admin-chat-${order.id}`" class="border-t border-border/50">
             <OrderChat :orderId="order.id" @close="adminChatId = null" closable />
           </div>
         </div>
@@ -723,7 +723,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, computed, watch, reactive, onMounted, onUnmounted } from "vue";
+import { ref, computed, watch, reactive, onMounted, onUnmounted, nextTick } from "vue";
 import { RouterLink, useRouter } from "vue-router";
 import { useI18n } from "vue-i18n";
 import { toast } from "vue-sonner";
@@ -755,8 +755,17 @@ const statusIcon  = (s: string) => STATUS_CONFIG[s]?.icon  ?? AlertCircle;
 const capitalize  = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
 
 const adminChatId = ref<number | null>(null);
-function toggleAdminChat(orderId: number) {
-  adminChatId.value = adminChatId.value === orderId ? null : orderId;
+async function toggleAdminChat(orderId: number) {
+  const isOpening = adminChatId.value !== orderId;
+  adminChatId.value = isOpening ? orderId : null;
+  
+  if (isOpening) {
+    await nextTick();
+    const el = document.getElementById(`admin-chat-${orderId}`);
+    if (el) {
+      el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+    }
+  }
 }
 
 const tabs: { id: Tab; label: string; icon: any }[] = [

+ 13 - 5
src/pages/Orders.vue

@@ -155,8 +155,7 @@
               </div>
             </div>
 
-            <!-- Expandable Chat -->
-            <div v-if="openChatId === order.id" class="mt-6 pt-4 border-t border-border/50 relative z-10">
+            <div v-if="openChatId === order.id" :id="`chat-${order.id}`" class="mt-6 pt-4 border-t border-border/50 relative z-10">
               <OrderChat :orderId="order.id" compact closable @close="openChatId = null" />
             </div>
           </div>
@@ -168,7 +167,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, defineComponent, h, watch } from "vue";
+import { ref, onMounted, defineComponent, h, watch, nextTick } from "vue";
 import { RouterLink, useRouter } from "vue-router";
 import { useI18n } from "vue-i18n";
 import { Package, Clock, ShieldCheck, Truck, XCircle, ArrowLeft, Loader2, ExternalLink, Hash, FileText, Image as ImageIcon, MessageCircle, FileBox, Download } from "lucide-vue-next";
@@ -187,8 +186,17 @@ const orders = ref<any[]>([]);
 const isLoading = ref(true);
 const openChatId = ref<number | null>(null);
 
-function toggleChat(orderId: number) {
-  openChatId.value = openChatId.value === orderId ? null : orderId;
+async function toggleChat(orderId: number) {
+  const isOpening = openChatId.value !== orderId;
+  openChatId.value = isOpening ? orderId : null;
+  
+  if (isOpening) {
+    await nextTick();
+    const el = document.getElementById(`chat-${orderId}`);
+    if (el) {
+      el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+    }
+  }
 }
 
 // StatusBadge component defined inline