Przeglądaj źródła

feat: real-time unread messages notification with audio ping

unknown 1 tydzień temu
rodzic
commit
e328b872d4

+ 11 - 0
.cursorrules

@@ -5,3 +5,14 @@
 - Если мы изменяли фронтенд, то обязательно запускай npm run build и анализируй вывод.
 - Если мы поменяли бэкэнд, то согласовываем тесты и запускаем тесты каждый раз.
 - Не используй powershell, используй cmd
+
+# Localization Management Rules
+
+- **Source of Truth**: `src/locales/translations.json` is the single source of truth for all frontend translations.
+- **Supported Languages**: English (`en`), Montenegrin (`me`), Russian (`ru`), and Ukrainian (**`ua`**).
+- **Sync Command**: After updating `translations.json`, run `python scripts/manage_locales.py split` to update the individual locale files (avoid `npm run` due to PowerShell restrictions).
+- **No Direct Edits**: DO NOT edit `en.json`, `me.json`, `ru.json`, or `ua.json` directly.
+- **Backend Schema**: When adding new languages, ensure the database columns and Pydantic schemas are updated with the `_` + code suffix (e.g., `name_ua`).
+- **Keys Hierarchy**: Maintain the hierarchical structure in `translations.json`. Group related keys under parent objects.
+- **Placeholders**: Use `{{variable_name}}` for i18next-style placeholders.
+- **Validation**: Ensure that all keys used in the code with `t()` actually exist in `translations.json`.

+ 1 - 0
backend/alter_db_chat.py

@@ -7,6 +7,7 @@ try:
         user_id INT,
         is_from_admin BOOLEAN DEFAULT FALSE,
         message TEXT NOT NULL,
+        is_read BOOLEAN DEFAULT FALSE,
         created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
         FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
     );

+ 16 - 2
backend/routers/auth.py

@@ -111,6 +111,20 @@ async def update_me(data: schemas.UserUpdate, token: str = Depends(auth_utils.oa
 @router.post("/ping")
 async def track_ping(token: str = Depends(auth_utils.oauth2_scheme)):
     payload = auth_utils.decode_token(token)
+    unread_count = 0
     if payload and payload.get("id"):
-        session_utils.track_user_ping(payload.get("id"))
-    return {"status": "ok"}
+        user_id = payload.get("id")
+        role = payload.get("role")
+        session_utils.track_user_ping(user_id)
+        
+        if role == 'admin':
+            row = db.execute_query("SELECT count(*) as cnt FROM order_messages WHERE is_from_admin = FALSE AND is_read = FALSE")
+        else:
+            row = db.execute_query(
+                "SELECT count(*) as cnt FROM order_messages om JOIN orders o ON om.order_id = o.id WHERE o.user_id = %s AND om.is_from_admin = TRUE AND om.is_read = FALSE",
+                (user_id,)
+            )
+        if row:
+            unread_count = row[0]['cnt']
+            
+    return {"status": "ok", "unread_count": unread_count}

+ 7 - 0
backend/routers/chat.py

@@ -18,6 +18,13 @@ 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()
+        
+    # Mark messages as read
+    if role == 'admin':
+        db.execute_commit("UPDATE order_messages SET is_read = TRUE WHERE order_id = %s AND is_from_admin = FALSE AND is_read = FALSE", (order_id,))
+    else:
+        db.execute_commit("UPDATE order_messages SET is_read = TRUE WHERE order_id = %s AND is_from_admin = TRUE AND is_read = FALSE", (order_id,))
+        
     return messages
 
 @router.post("/orders/{order_id}/messages")

+ 22 - 1
src/components/Header.vue

@@ -31,6 +31,17 @@
             Admin
           </RouterLink>
 
+          <!-- Unread Messages Badge -->
+          <RouterLink
+            v-if="isLoggedIn && authStore.unreadMessagesCount > 0"
+            to="/orders"
+            class="inline-flex items-center gap-1.5 px-3 py-1 bg-red-500 hover:bg-red-600 text-white text-xs font-bold rounded-full transition-all shadow-md animate-in fade-in"
+            title="Unread messages in chat"
+          >
+            <MessageSquare class="w-3.5 h-3.5 animate-pulse" />
+            <span class="tabular-nums">{{ authStore.unreadMessagesCount }}</span>
+          </RouterLink>
+
           <RouterLink
             v-if="isLoggedIn"
             to="/orders"
@@ -95,6 +106,16 @@
             <LayoutPanelTop class="w-4 h-4" />Admin Panel
           </RouterLink>
 
+          <RouterLink
+            v-if="isLoggedIn && authStore.unreadMessagesCount > 0"
+            to="/orders"
+            class="flex items-center gap-2 text-red-500 font-bold py-2"
+            @click="mobileOpen = false"
+          >
+            <MessageSquare class="w-4 h-4 animate-pulse" />
+            Unread Messages ({{ authStore.unreadMessagesCount }})
+          </RouterLink>
+
           <RouterLink
             v-if="isLoggedIn"
             to="/orders"
@@ -135,7 +156,7 @@ import { ref, computed } from "vue";
 import { RouterLink, useRouter } from "vue-router";
 import { useI18n } from "vue-i18n";
 import { toast } from "vue-sonner";
-import { Menu, X, LayoutPanelTop, LogOut, PackageCheck } from "lucide-vue-next";
+import { Menu, X, LayoutPanelTop, LogOut, PackageCheck, MessageSquare } from "lucide-vue-next";
 import Logo from "./Logo.vue";
 import LanguageSwitcher from "./LanguageSwitcher.vue";
 import Button from "./ui/button.vue";

+ 1 - 1
src/components/HeroSection.vue

@@ -3,7 +3,7 @@
     <div class="absolute inset-0 grid-pattern opacity-50" />
     <div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] bg-gradient-glow rounded-full blur-3xl" />
 
-    <div class="container mx-auto px-4 pt-10 sm:pt-16 lg:pt-0">
+    <div class="container mx-auto px-4 pt-20 sm:pt-24 lg:pt-0 lg:mt-8">
       <div class="grid lg:grid-cols-2 gap-12 items-center">
         <!-- Text -->
         <div class="text-center lg:text-left animate-slide-up">

+ 4 - 1
src/i18n.ts

@@ -4,9 +4,11 @@ import me from './locales/me.json';
 import ru from './locales/ru.json';
 import ua from './locales/ua.json';
 
+const savedLocale = localStorage.getItem('locale') || 'en';
+
 const i18n = createI18n({
   legacy: false,
-  locale: 'en',
+  locale: savedLocale,
   fallbackLocale: 'en',
   messages: {
     en,
@@ -18,6 +20,7 @@ const i18n = createI18n({
 
 export const setLanguage = (lang: string) => {
   (i18n.global as any).locale.value = lang;
+  localStorage.setItem('locale', lang);
 };
 export const currentLanguage = () => (i18n.global as any).locale.value as string;
 export default i18n;

+ 6 - 2
src/lib/api.ts

@@ -404,13 +404,17 @@ export const sendOrderMessage = async (orderId: number, message: string) => {
 
 export const authPing = async () => {
   const token = localStorage.getItem("token");
-  if (!token) return;
+  if (!token) return null;
   try {
-    await fetch(`${API_BASE_URL}/auth/ping?lang=${i18n.global.locale.value}`, {
+    const response = await fetch(`${API_BASE_URL}/auth/ping?lang=${i18n.global.locale.value}`, {
       method: "POST",
       headers: { "Authorization": `Bearer ${token}` }
     });
+    if (response.ok) {
+      return response.json();
+    }
   } catch (e) {
     // silently fail
   }
+  return null;
 };

+ 109 - 109
src/locales/translations.json

@@ -11,7 +11,7 @@
         "en": "Confirm Password",
         "me": "Potvrdi lozinku",
         "ru": "Подтвердите пароль",
-        "ua": "Підтвердіть пароль"
+        "ua": "Підтвердьте пароль"
       },
       "email": {
         "en": "Email",
@@ -31,31 +31,31 @@
         "en": "Forgot Password?",
         "me": "Zaboravljena lozinka?",
         "ru": "Забыли пароль?",
-        "ua": "Забыли пароль?"
+        "ua": "Забули свій пароль?"
       },
       "submit": {
         "en": "Send Reset Link",
         "me": "Pošalji link",
         "ru": "Отправить ссылку",
-        "ua": "Отправить ссылку"
+        "ua": "Надіслати посилання"
       },
       "subtitle": {
         "en": "Enter your email for reset instructions",
         "me": "Unesi svoj email i poslaćemo ti link",
         "ru": "Введите email, и мы отправим ссылку",
-        "ua": "Введите email, и мы отправим ссылку"
+        "ua": "Введіть email, і ми відправимо посилання"
       },
       "title": {
         "en": "Forgot Password?",
         "me": "Zaboravio si lozinku?",
         "ru": "Забыли пароль?",
-        "ua": "Забыли пароль?"
+        "ua": "Забули свій пароль?"
       },
       "toggle": {
         "en": "Back to Login",
         "me": "Nazad na prijavu",
         "ru": "Вернуться к входу",
-        "ua": "Вернуться к входу"
+        "ua": "Повернутись до входу"
       }
     },
     "login": {
@@ -81,7 +81,7 @@
         "en": "New here? Create an account",
         "me": "Nemaš nalog? Registruj se",
         "ru": "Нет аккаунта? Зарегистрируйтесь",
-        "ua": "Нет аккаунта? Зарегистрируйтесь"
+        "ua": "Немає облікового запису? Зареєструйтесь"
       }
     },
     "register": {
@@ -95,19 +95,19 @@
         "en": "Start printing your ideas today",
         "me": "Počni da štampaš svoje ideje danas",
         "ru": "Начните печатать свои идеи сегодня",
-        "ua": "Начните печатать свои идеи сегодня"
+        "ua": "Почніть друкувати свої ідеї сьогодні"
       },
       "title": {
         "en": "Join Us",
         "me": "Kreiraj nalog",
         "ru": "Создать аккаунт",
-        "ua": "Создать аккаунт"
+        "ua": "Створити обліковий запис"
       },
       "toggle": {
         "en": "Already have an account? Log In",
         "me": "Već imaš nalog? Prijavi se",
         "ru": "Уже есть аккаунт? Войдите",
-        "ua": "Уже есть аккаунт? Войдите"
+        "ua": "Вже є обліковий запис? Увійдіть"
       }
     },
     "reset": {
@@ -115,25 +115,25 @@
         "en": "Reset Password",
         "me": "Potvrdi novu lozinku",
         "ru": "Сбросить пароль",
-        "ua": "Сбросить пароль"
+        "ua": "Скинути пароль"
       },
       "subtitle": {
         "en": "Choose a strong new password",
         "me": "Kreiraj novu sigurnu lozinku",
         "ru": "Придумайте новый надежный пароль",
-        "ua": "Придумайте новый надежный пароль"
+        "ua": "Придумайте новий надійний пароль"
       },
       "title": {
         "en": "Reset Password",
         "me": "Nova lozinka",
         "ru": "Сброс пароля",
-        "ua": "Сброс пароля"
+        "ua": "Скидання пароля"
       },
       "token": {
         "en": "Code from email",
         "me": "Kod iz mejla",
         "ru": "Код из письма",
-        "ua": "Код из письма"
+        "ua": "Код із листа"
       }
     },
     "orContinueWith": {
@@ -148,13 +148,13 @@
       "en": "Support",
       "me": "Podrška",
       "ru": "Поддержка",
-      "ua": "Поддержка"
+      "ua": "Підтримка"
     },
     "empty": {
       "en": "No messages yet. Start a conversation!",
       "me": "Još nema poruka. Započnite razgovor!",
       "ru": "Сообщений пока нет. Начните диалог!",
-      "ua": "Сообщений пока нет. Начните диалог!"
+      "ua": "Повідомлень поки що немає. Почніть діалог!"
     },
     "open": {
       "en": "Chat",
@@ -166,19 +166,19 @@
       "en": "Type a message...",
       "me": "Upišite poruku...",
       "ru": "Напишите сообщение...",
-      "ua": "Напишите сообщение..."
+      "ua": "Напишіть повідомлення..."
     },
     "title": {
       "en": "Order Chat",
       "me": "Čat za narudžbu",
       "ru": "Чат по заказу",
-      "ua": "Чат по заказу"
+      "ua": "Чат на замовлення"
     },
     "unread": {
       "en": "New message",
       "me": "Nova poruka",
       "ru": "Новое сообщение",
-      "ua": "Новое сообщение"
+      "ua": "Нове повідомлення"
     }
   },
   "errors": {
@@ -186,38 +186,38 @@
       "en": "This field is required",
       "me": "Ovo polje je obavezno",
       "ru": "Это поле обязательно для заполнения",
-      "ua": "Это поле обязательно для заполнения"
+      "ua": "Це поле є обов'язковим для заповнення"
     },
     "missing": {
       "en": "Field is required",
       "me": "Ovo polje je obavezno",
       "ru": "Обязательное поле",
-      "ua": "Обязательное поле"
+      "ua": "Обов'язкове поле"
     },
     "string_too_short": {
       "en": "Too short, min {{min_length}} characters",
       "me": "Previše kratko, min {{min_length}} karaktera",
       "ru": "Слишком коротко, минимум {{min_length}} символов",
-      "ua": "Слишком коротко, минимум {{min_length}} символов"
+      "ua": "Дуже коротко, мінімум {{min_length}} символів"
     },
     "too_short": {
       "en": "Field too short",
       "me": "Polje je previše kratko",
       "ru": "Поле слишком короткое",
-      "ua": "Поле слишком короткое"
+      "ua": "Поле надто коротке"
     },
     "unknown": {
       "en": "Something went wrong",
       "me": "Nešto je pošlo po zlu",
       "ru": "Что-то пошло не так",
-      "ua": "Что-то пошло не так"
+      "ua": "Щось пішло не так"
     },
     "value_error": {
       "email": {
         "en": "Invalid email",
         "me": "Neispravan email",
         "ru": "Некорректный email",
-        "ua": "Некорректный email"
+        "ua": "Некоректний email"
       }
     }
   },
@@ -226,19 +226,19 @@
       "en": "About Us",
       "me": "O nama",
       "ru": "О нас",
-      "ua": "О нас"
+      "ua": "Про нас"
     },
     "allRightsReserved": {
       "en": "All rights reserved.",
       "me": "Sva prava zadržana.",
       "ru": "Все права защищены.",
-      "ua": "Все права защищены."
+      "ua": "Усі права захищені."
     },
     "api": {
       "en": "Documentation",
       "me": "Dokumentacija",
       "ru": "Документация",
-      "ua": "Документация"
+      "ua": "Документація"
     },
     "blog": {
       "en": "Blog",
@@ -250,67 +250,67 @@
       "en": "Careers",
       "me": "Karijere",
       "ru": "Вакансии",
-      "ua": "Вакансии"
+      "ua": "Вакансії"
     },
     "company": {
       "en": "Company",
       "me": "Kompanija",
       "ru": "Компания",
-      "ua": "Компания"
+      "ua": "Компанія"
     },
     "contact": {
       "en": "Contact",
       "me": "Kontakt",
       "ru": "Контакты",
-      "ua": "Контакты"
+      "ua": "Контакти"
     },
     "guidelines": {
       "en": "Guidelines",
       "me": "Uputstva",
       "ru": "Руководство",
-      "ua": "Руководство"
+      "ua": "Керівництво"
     },
     "help": {
       "en": "Help Center",
       "me": "Centar za pomoć",
       "ru": "Справочный центр",
-      "ua": "Справочный центр"
+      "ua": "Довідковий центр"
     },
     "materials": {
       "en": "Materials",
       "me": "Materijali",
       "ru": "Материалы",
-      "ua": "Материалы"
+      "ua": "Матеріали"
     },
     "privacy": {
       "en": "Privacy",
       "me": "Privatnost",
       "ru": "Конфиденциальность",
-      "ua": "Конфиденциальность"
+      "ua": "Конфіденційність"
     },
     "services": {
       "en": "Services",
       "me": "Usluge",
       "ru": "Услуги",
-      "ua": "Услуги"
+      "ua": "Послуги"
     },
     "support": {
       "en": "Support",
       "me": "Podrška",
       "ru": "Поддержка",
-      "ua": "Поддержка"
+      "ua": "Підтримка"
     },
     "tagline": {
       "en": "Radionica 3D — A service built on trust. We bring your ideas to life, you value our craftsmanship.",
       "me": "Radionica 3D — Servis izgrađen na povjerenju. Mi oživljavamo tvoje ideje, ti procjenjuješ naš rad.",
       "ru": "Radionica 3D — сервис, построенный на доверии. Мы воплощаем ваши идеи, вы оцениваете наш труд.",
-      "ua": "Radionica 3D — сервис, построенный на доверии. Мы воплощаем ваши идеи, вы оцениваете наш труд."
+      "ua": "Radionica 3D - сервіс, побудований на довірі. Ми втілюємо ваші ідеї, ви оцінюєте нашу працю."
     },
     "terms": {
       "en": "Terms",
       "me": "Uslovi",
       "ru": "Условия",
-      "ua": "Условия"
+      "ua": "Умови"
     }
   },
   "hero": {
@@ -318,26 +318,26 @@
       "en": "Trust in Every Layer",
       "me": "Povjerenje u svakom sloju",
       "ru": "Доверие в каждом слое",
-      "ua": "Доверие в каждом слое"
+      "ua": "Довіра у кожному шарі"
     },
     "description": {
       "en": "A unique 3D printing service: send us a model, receive it by mail, and pay what you think it's worth.",
       "me": "Jedinstveni servis 3D štampe: pošaljite model, dobijte gotov proizvod poštom i platite onoliko koliko smatrate da vrijedi.",
       "ru": "Уникальный сервис 3D-печати: пришлите модель, получите готовое изделие по почте и заплатите столько, сколько посчитаете нужным.",
-      "ua": "Уникальный сервис 3D-печати: пришлите модель, получите готовое изделие по почте и заплатите столько, сколько посчитаете нужным."
+      "ua": "Унікальний сервіс 3D-друку: надішліть модель, отримайте готовий виріб поштою та заплатіть стільки, скільки вважаєте за потрібне."
     },
     "pricingButton": {
       "en": "How It Works",
       "me": "Kako funkcioniše",
       "ru": "Как это работает",
-      "ua": "Как это работает"
+      "ua": "Як це працює"
     },
     "stats": {
       "materials": {
         "en": "Materials",
         "me": "Materijala",
         "ru": "Материалов",
-        "ua": "Материалов"
+        "ua": "матеріалів"
       },
       "materialsValue": {
         "en": "10+",
@@ -349,7 +349,7 @@
         "en": "Precision",
         "me": "Preciznost",
         "ru": "Точность",
-        "ua": "Точность"
+        "ua": "Точність"
       },
       "precisionValue": {
         "en": "0.1mm",
@@ -361,32 +361,32 @@
         "en": "Mail Delivery",
         "me": "Dostava poštom",
         "ru": "Доставка почтой",
-        "ua": "Доставка почтой"
+        "ua": "Доставка поштою"
       },
       "shippingValue": {
         "en": "Express",
         "me": "Ekspres",
         "ru": "Экспресс",
-        "ua": "Экспресс"
+        "ua": "Експрес"
       }
     },
     "title": {
       "en": "We Print —",
       "me": "Mi štampamo —",
       "ru": "Мы печатаем —",
-      "ua": "Мы печатаем —"
+      "ua": "Ми друкуємо"
     },
     "titleGradient": {
       "en": "You Value",
       "me": "Vi procjenjujete",
       "ru": "Вы оцениваете",
-      "ua": "Вы оцениваете"
+      "ua": "Ви оцінюєте"
     },
     "uploadButton": {
       "en": "Order Print",
       "me": "Naruči štampu",
       "ru": "Заказать печать",
-      "ua": "Заказать печать"
+      "ua": "Замовити друк"
     }
   },
   "nav": {
@@ -394,55 +394,55 @@
       "en": "How It Works",
       "me": "Kako funkcioniše",
       "ru": "Как это работает",
-      "ua": "Как это работает"
+      "ua": "Як це працює"
     },
     "logIn": {
       "en": "Log In",
       "me": "Prijavi se",
       "ru": "Войти",
-      "ua": "Войти"
+      "ua": "Увійти"
     },
     "logOut": {
       "en": "Log Out",
       "me": "Odjavi se",
       "ru": "Выйти",
-      "ua": "Выйти"
+      "ua": "Вийти"
     },
     "materials": {
       "en": "Materials",
       "me": "Materijali",
       "ru": "Материалы",
-      "ua": "Материалы"
+      "ua": "Матеріали"
     },
     "myOrders": {
       "en": "My Orders",
       "me": "Moje narudžbe",
       "ru": "Мои заказы",
-      "ua": "Мои заказы"
+      "ua": "Мої замовлення"
     },
     "portfolio": {
       "en": "Portfolio",
       "me": "Portfolio",
       "ru": "Портфолио",
-      "ua": "Портфолио"
+      "ua": "Портфоліо"
     },
     "philosophy": {
       "en": "Our Philosophy",
       "me": "Naš pristup",
       "ru": "Наш подход",
-      "ua": "Наш подход"
+      "ua": "Наш підхід"
     },
     "register": {
       "en": "Register",
       "me": "Registruj se",
       "ru": "Регистрация",
-      "ua": "Регистрация"
+      "ua": "Реєстрація"
     },
     "services": {
       "en": "Services",
       "me": "Usluge",
       "ru": "Услуги",
-      "ua": "Услуги"
+      "ua": "Послуги"
     }
   },
   "pricing": {
@@ -450,31 +450,31 @@
       "en": "Trust Policy",
       "me": "Politika povjerenja",
       "ru": "Политика доверия",
-      "ua": "Политика доверия"
+      "ua": "Політика довіри"
     },
     "description": {
       "en": "No upfront costs or complex calculators. You only pay for results you value.",
       "me": "Bez uplate unaprijed i komplikovanih kalkulatora. Plaćaš samo za rezultat u koji vjeruješ.",
       "ru": "Никаких предоплат и сложных калькуляторов. Вы платите только за результат, в который верите.",
-      "ua": "Никаких предоплат и сложных калькуляторов. Вы платите только за результат, в который верите."
+      "ua": "Жодних передоплат і складних калькуляторів. Ви платите лише за результат, у який вірите."
     },
     "materials": {
       "en": "Available Materials",
       "me": "Dostupni materijali",
       "ru": "Доступные материалы",
-      "ua": "Доступные материалы"
+      "ua": "Доступні матеріали"
     },
     "requestQuote": {
       "en": "Send Request",
       "me": "Pošalji zahtjev",
       "ru": "Отправить запрос",
-      "ua": "Отправить запрос"
+      "ua": "Надіслати запит"
     },
     "saveConfig": {
       "en": "Save",
       "me": "Sačuvaj",
       "ru": "Сохранить",
-      "ua": "Сохранить"
+      "ua": "Зберегти"
     },
     "title": {
       "en": "Payment",
@@ -486,32 +486,32 @@
       "en": "After Delivery",
       "me": "nakon isporuke",
       "ru": "после получения",
-      "ua": "после получения"
+      "ua": "після отримання"
     },
     "trustSteps": {
       "step1": {
         "en": "Send us an STL model or a link",
         "me": "Pošaljite nam STL model ili link",
         "ru": "Отправьте нам STL модель или ссылку",
-        "ua": "Отправьте нам STL модель или ссылку"
+        "ua": "Надішліть нам STL модель або посилання"
       },
       "step2": {
         "en": "We'll craft it using the best material",
         "me": "Mi ćemo ga izraditi od najboljeg materijala",
         "ru": "Мы изготовим ее из подходящего материала",
-        "ua": "Мы изготовим ее из подходящего материала"
+        "ua": "Ми виготовимо її з відповідного матеріалу"
       },
       "step3": {
         "en": "Receive the package at your address",
         "me": "Primite paket na navedenu adresu",
         "ru": "Получите посылку на указанный адрес",
-        "ua": "Получите посылку на указанный адрес"
+        "ua": "Отримайте посилку на вказану адресу"
       },
       "step4": {
         "en": "Evaluate our work and pay your price",
         "me": "Procijenite naš rad i platite svoju cijenu",
         "ru": "Оцените работу и оплатите удобным способом",
-        "ua": "Оцените работу и оплатите удобным способом"
+        "ua": "Оцініть роботу та сплатіть зручним способом"
       }
     }
   },
@@ -520,26 +520,26 @@
       "en": "Our Capabilities",
       "me": "Naše mogućnosti",
       "ru": "Наши возможности",
-      "ua": "Наши возможности"
+      "ua": "Наші можливості"
     },
     "description": {
       "en": "We'll choose the optimal printing method for your specific design.",
       "me": "Odabraćemo optimalnu metodu štampe za tvoj specifični dizajn.",
       "ru": "Мы подберем оптимальный метод печати для вашей задачи.",
-      "ua": "Мы подберем оптимальный метод печати для вашей задачи."
+      "ua": "Ми підберемо оптимальний метод друку для вашого завдання."
     },
     "fdm": {
       "description": {
         "en": "Durable parts made from engineering plastics.",
         "me": "Izdržljivi djelovi od industrijske plastike.",
         "ru": "Прочные детали из инженерных пластиков.",
-        "ua": "Прочные детали из инженерных пластиков."
+        "ua": "Міцні деталі із інженерних пластиків."
       },
       "title": {
         "en": "FDM Printing",
         "me": "FDM Štampa",
         "ru": "FDM печать",
-        "ua": "FDM печать"
+        "ua": "FDM друк"
       }
     },
     "sla": {
@@ -560,13 +560,13 @@
       "en": "Core",
       "me": "Glavne",
       "ru": "Технологии",
-      "ua": "Технологии"
+      "ua": "Технології"
     },
     "titleGradient": {
       "en": "Technologies",
       "me": "tehnologije",
       "ru": "реализации",
-      "ua": "реализации"
+      "ua": "реалізації"
     }
   },
   "portfolio": {
@@ -580,19 +580,19 @@
       "en": "Showcase",
       "me": "radova",
       "ru": "работ",
-      "ua": "работ"
+      "ua": "робіт"
     },
     "description": {
       "en": "Explore our successful 3D printing projects realized for our local customers in Montenegro.",
       "me": "Istražite naše uspješne projekte 3D štampe realizovane za naše klijente u Crnoj Gori.",
       "ru": "Ознакомьтесь с нашими успешными проектами 3D-печати, реализованными для клиентов в Черногории.",
-      "ua": "Ознакомьтесь с нашими успешными проектами 3D-печати, реализованными для клиентов в Черногории."
+      "ua": "Ознайомтеся з нашими успішними проектами 3D-друку, реалізованими для клієнтів у Чорногорії."
     },
     "empty": {
       "en": "Our gallery is growing. Check back soon!",
       "me": "Naša galerija raste. Navratite uskoro!",
       "ru": "Наша галерея пополняется. Заходите позже!",
-      "ua": "Наша галерея пополняется. Заходите позже!"
+      "ua": "Наша галерея поповнюється. Заходьте пізніше!"
     }
   },
   "upload": {
@@ -600,61 +600,61 @@
       "en": "City, ZIP, Address (free form)",
       "me": "Grad, Poštanski broj, Adresa (slobodna forma)",
       "ru": "Город, Индекс, Адрес (в свободной форме)",
-      "ua": "Город, Индекс, Адрес (в свободной форме)"
+      "ua": "Місто, Індекс, Адреса (у вільній формі)"
     },
     "badge": {
       "en": "Place Your Project",
       "me": "Kreiranje projekta",
       "ru": "Оформление заказа",
-      "ua": "Оформление заказа"
+      "ua": "Оформлення замовлення"
     },
     "allowPortfolio": {
       "en": "Allow featuring in public portfolio",
       "me": "Dozvoli objavljivanje u javnom portfoliju",
       "ru": "Разрешить публикацию в портфолио",
-      "ua": "Разрешить публикацию в портфолио"
+      "ua": "Дозволити публікацію в портфоліо"
     },
     "allowPortfolioDesc": {
       "en": "We'll show photos of your print to inspire other customers.",
       "me": "Prikazaćemo fotografije tvog modela kako bismo inspirisali druge kupce.",
       "ru": "Мы покажем фото вашего изделия, чтобы вдохновить других клиентов.",
-      "ua": "Мы покажем фото вашего изделия, чтобы вдохновить других клиентов."
+      "ua": "Ми покажемо фотографії вашого виробу, щоб надихнути інших клієнтів."
     },
     "selectMaterial": {
       "en": "Select Material",
       "me": "Odaberi materijal",
       "ru": "Выберите материал",
-      "ua": "Выберите материал"
+      "ua": "Виберіть матеріал"
     },
     "browse": {
       "en": "browse files",
       "me": "pretraži datoteke",
       "ru": "выбрать файлы",
-      "ua": "выбрать файлы"
+      "ua": "вибрати файли"
     },
     "continue": {
       "en": "Submit Request",
       "me": "Pošalji zahtjev",
       "ru": "Отправить заказ",
-      "ua": "Отправить заказ"
+      "ua": "Надіслати замовлення"
     },
     "description": {
       "en": "Upload a file or provide a link to a model (Thingiverse, Printables, etc.). We'll contact you for details.",
       "me": "Otpremite datoteku ili navedite link do modela (Thingiverse, Printables i dr.). Kontaktiraćemo vas radi detalja.",
       "ru": "Загрузите файл или укажите ссылку на модель (Thingiverse, Printables и др.). Мы свяжемся с вами для уточнения деталей.",
-      "ua": "Загрузите файл или укажите ссылку на модель (Thingiverse, Printables i др.). Мы свяжемся с вами для уточнения деталей."
+      "ua": "Завантажте файл або вкажіть посилання на модель (Thingiverse, Printables тощо). Ми зв'яжемося з вами для уточнення деталей."
     },
     "dropzone": {
       "en": "Upload files (STL, OBJ, STEP)",
       "me": "Otpremi datoteke (STL, OBJ, STEP)",
       "ru": "Загрузить файлы (STL, OBJ, STEP)",
-      "ua": "Загрузить файлы (STL, OBJ, STEP)"
+      "ua": "Завантажити файли (STL, OBJ, STEP)"
     },
     "dropzoneActive": {
       "en": "Drop your files here",
       "me": "Prevucite datoteke ovdje",
       "ru": "Переместите файлы сюда",
-      "ua": "Переместите файлы сюда"
+      "ua": "Перемістіть файли сюди"
     },
     "email": {
       "en": "Email Address",
@@ -666,19 +666,19 @@
       "en": "First Name",
       "me": "Ime",
       "ru": "Имя",
-      "ua": "Имя"
+      "ua": "Ім'я"
     },
     "lastName": {
       "en": "Last Name",
       "me": "Prezime",
       "ru": "Фамилия",
-      "ua": "Фамилия"
+      "ua": "Прізвище"
     },
     "modelLink": {
       "en": "Model Link (optional)",
       "me": "Link do modela (opciono)",
       "ru": "Ссылка на модель (необязательно)",
-      "ua": "Ссылка на модель (необязательно)"
+      "ua": "Посилання на модель (необов'язково)"
     },
     "modelLinkPlaceholder": {
       "en": "https://www.printables.com/model/...",
@@ -690,13 +690,13 @@
       "en": "Order Notes / Remarks",
       "me": "Napomene uz narudžbu",
       "ru": "Примечания к заказу",
-      "ua": "Примечания к заказу"
+      "ua": "Примітки до замовлення"
     },
     "notesPlaceholder": {
       "en": "Color preferences, specific requirements, or special instructions...",
       "me": "Želje za bojom, materijalom, specifičnim zahtjevima ili posebne instrukcije...",
       "ru": "Пожелания по цвету, материалу, толщине стенок или другие инструкции...",
-      "ua": "Пожелания по цвету, материалу, толщине стенок или другие инструкции..."
+      "ua": "Побажання щодо кольору, матеріалу, товщини стінок або інші інструкції."
     },
     "phone": {
       "en": "Phone Number",
@@ -708,43 +708,43 @@
       "en": "Number of Copies",
       "me": "Broj kopija",
       "ru": "Количество копий",
-      "ua": "Количество копий"
+      "ua": "Кількість копій"
     },
     "shippingAddress": {
       "en": "Shipping Address",
       "me": "Adresa isporuke",
       "ru": "Адрес доставки",
-      "ua": "Адрес доставки"
+      "ua": "Адреса доставки"
     },
     "submitting": {
       "en": "Sending...",
       "me": "Slanje...",
       "ru": "Отправка...",
-      "ua": "Отправка..."
+      "ua": "Надсилання..."
     },
     "success": {
       "en": "Order submitted successfully! We will contact you soon.",
       "me": "Zahtjev je uspješno poslat! Kontaktiraćemo vas uskoro.",
       "ru": "Заказ успешно отправлен! Мы свяжемся с вами в ближайшее время.",
-      "ua": "Заказ успешно отправлен! Мы свяжемся с вами в ближайшее время."
+      "ua": "Замовлення успішно надіслано! Ми зв'яжемося з вами найближчим часом."
     },
     "title": {
       "en": "Submit",
       "me": "Pošaljite",
       "ru": "Пришлите",
-      "ua": "Пришлите"
+      "ua": "Надішліть"
     },
     "titleGradient": {
       "en": "Your Idea",
       "me": "vašu ideju",
       "ru": "вашу идею",
-      "ua": "вашу идею"
+      "ua": "вашу ідею"
     },
     "uploadedFiles": {
       "en": "Selected Files",
       "me": "Odabrane datoteke",
       "ru": "Выбранные файлы",
-      "ua": "Выбранные файлы"
+      "ua": "Вибрані файли"
     }
   },
   "whyTrust": {
@@ -752,32 +752,32 @@
       "en": "We believe that high-quality 3D printing should be accessible, and the process as simple as possible. Our experience allows us to take on the risks: we are confident in our equipment and the quality of our materials.",
       "me": "Vjerujemo da kvalitetna 3D štampa treba da bude dostupna, a proces — maksimalno jednostavan. Naše iskustvo nam omogućava da preuzmemo rizike: sigurni smo u našu opremu i kvalitet materijala.",
       "ru": "Мы верим, что качественная 3D-печать должна быть доступной, а процесс — максимально простым. Наш опыт позволяет нам брать на себя риски: мы уверены в своем оборудовании и качестве материалов.",
-      "ua": "Мы верим, что качественная 3D-печать должна быть доступной, а процесс — максимально простым. Наш опыт позволяет нам брать на себя риски: мы уверены в своем оборудовании и качестве материалов."
+      "ua": "Ми віримо, що якісний 3D-друк має бути доступним, а процес — максимально простим. Наш досвід дозволяє нам брати на себе ризики: ми впевнені у своєму обладнанні та якості матеріалів."
     },
     "description2": {
       "en": "This approach removes the barriers of 'complex calculations' and gives you the opportunity to get exactly what you intended, evaluating the results yourself.",
       "me": "Ovaj pristup eliminiše barijere \"komplikovanih proračuna\" i daje vam mogućnost da dobijete upravo ono što ste zamislili, procjenjujući rezultat samostalno.",
       "ru": "Этот подход позволяет убрать барьеры \"сложных расчетов\" и дать вам возможность получить именно то, что вы задумали, оценив результат самостоятельно.",
-      "ua": "Этот подход позволяет убрать барьеры \"сложных расчетов\" и дать вам возможность получить именно то, что вы задумали, оценив результат самостоятельно."
+      "ua": "Цей підхід дозволяє усунути бар'єри \"складних розрахунків\" і дати вам можливість отримати саме те, що ви задумали, оцінивши результат самостійно."
     },
     "items": {
       "noCommissions": {
         "en": "No fees",
         "me": "Bez provizija",
         "ru": "Без комиссий",
-        "ua": "Без комиссий"
+        "ua": "Без комісій"
       },
       "noPrepayment": {
         "en": "No prepayment",
         "me": "Bez uplate unaprijed",
         "ru": "Bez предоплаты",
-        "ua": "Bez предоплаты"
+        "ua": "Без передоплати"
       },
       "shipping": {
         "en": "Mail delivery",
         "me": "Isporuka poštom",
         "ru": "Отправка почтой",
-        "ua": "Отправка почтой"
+        "ua": "Надсилання поштою"
       },
       "yourPrice": {
         "en": "Your price",
@@ -790,13 +790,13 @@
       "en": "Why we",
       "me": "Zašto nam",
       "ru": "Почему мы",
-      "ua": "Почему мы"
+      "ua": "Чому ми"
     },
     "titleItalic": {
       "en": "trust",
       "me": "vjeruju",
       "ru": "доверяем",
-      "ua": "доверяем"
+      "ua": "довіряємо"
     }
   },
   "privacy": {
@@ -980,19 +980,19 @@
       "en": "This site uses cookies to improve your experience and analyze traffic.",
       "ru": "Данный сайт использует файлы cookie для улучшения пользовательского опыта.",
       "me": "Ovaj sajt koristi kolačiće za pružanje boljeg korisničkog iskustva.",
-      "ua": "Данный сайт использует файлы cookie для улучшения пользовательского опыта."
+      "ua": "Цей сайт використовує файли cookie для покращення досвіду користувача."
     },
     "accept": {
       "en": "Accept",
       "ru": "Принять",
       "me": "Prihvati",
-      "ua": "Принять"
+      "ua": "Прийняти"
     },
     "leave": {
       "en": "Leave",
       "ru": "Уйти",
       "me": "Napusti",
-      "ua": "Уйти"
+      "ua": "Піти"
     }
   }
 }

+ 109 - 109
src/locales/ua.json

@@ -2,172 +2,172 @@
   "auth": {
     "back": "На головну",
     "fields": {
-      "confirmPassword": "Підтвердіть пароль",
+      "confirmPassword": "Підтвердьте пароль",
       "email": "Email",
       "password": "Пароль"
     },
     "forgot": {
-      "link": "Забыли пароль?",
-      "submit": "Отправить ссылку",
-      "subtitle": "Введите email, и мы отправим ссылку",
-      "title": "Забыли пароль?",
-      "toggle": "Вернуться к входу"
+      "link": "Забули свій пароль?",
+      "submit": "Надіслати посилання",
+      "subtitle": "Введіть email, і ми відправимо посилання",
+      "title": "Забули свій пароль?",
+      "toggle": "Повернутись до входу"
     },
     "login": {
       "submit": "Увійти",
       "subtitle": "Увійдіть у свій акаунт Radionica3D",
       "title": "З поверненням",
-      "toggle": "Нет аккаунта? Зарегистрируйтесь"
+      "toggle": "Немає облікового запису? Зареєструйтесь"
     },
     "register": {
       "submit": "Зареєструватися",
-      "subtitle": "Начните печатать свои идеи сегодня",
-      "title": "Создать аккаунт",
-      "toggle": "Уже есть аккаунт? Войдите"
+      "subtitle": "Почніть друкувати свої ідеї сьогодні",
+      "title": "Створити обліковий запис",
+      "toggle": "Вже є обліковий запис? Увійдіть"
     },
     "reset": {
-      "submit": "Сбросить пароль",
-      "subtitle": "Придумайте новый надежный пароль",
-      "title": "Сброс пароля",
-      "token": "Код из письма"
+      "submit": "Скинути пароль",
+      "subtitle": "Придумайте новий надійний пароль",
+      "title": "Скидання пароля",
+      "token": "Код із листа"
     },
     "orContinueWith": "Або продовжити через"
   },
   "chat": {
-    "admin": "Поддержка",
-    "empty": "Сообщений пока нет. Начните диалог!",
+    "admin": "Підтримка",
+    "empty": "Повідомлень поки що немає. Почніть діалог!",
     "open": "Чат",
-    "placeholder": "Напишите сообщение...",
-    "title": "Чат по заказу",
-    "unread": "Новое сообщение"
+    "placeholder": "Напишіть повідомлення...",
+    "title": "Чат на замовлення",
+    "unread": "Нове повідомлення"
   },
   "errors": {
-    "field_required": "Это поле обязательно для заполнения",
-    "missing": "Обязательное поле",
-    "string_too_short": "Слишком коротко, минимум {{min_length}} символов",
-    "too_short": "Поле слишком короткое",
-    "unknown": "Что-то пошло не так",
+    "field_required": "Це поле є обов'язковим для заповнення",
+    "missing": "Обов'язкове поле",
+    "string_too_short": "Дуже коротко, мінімум {{min_length}} символів",
+    "too_short": "Поле надто коротке",
+    "unknown": "Щось пішло не так",
     "value_error": {
-      "email": "Некорректный email"
+      "email": "Некоректний email"
     }
   },
   "footer": {
-    "about": "О нас",
-    "allRightsReserved": "Все права защищены.",
-    "api": "Документация",
+    "about": "Про нас",
+    "allRightsReserved": "Усі права захищені.",
+    "api": "Документація",
     "blog": "Блог",
-    "careers": "Вакансии",
-    "company": "Компания",
-    "contact": "Контакты",
-    "guidelines": "Руководство",
-    "help": "Справочный центр",
-    "materials": "Материалы",
-    "privacy": "Конфиденциальность",
-    "services": "Услуги",
-    "support": "Поддержка",
-    "tagline": "Radionica 3D — сервис, построенный на доверии. Мы воплощаем ваши идеи, вы оцениваете наш труд.",
-    "terms": "Условия"
+    "careers": "Вакансії",
+    "company": "Компанія",
+    "contact": "Контакти",
+    "guidelines": "Керівництво",
+    "help": "Довідковий центр",
+    "materials": "Матеріали",
+    "privacy": "Конфіденційність",
+    "services": "Послуги",
+    "support": "Підтримка",
+    "tagline": "Radionica 3D - сервіс, побудований на довірі. Ми втілюємо ваші ідеї, ви оцінюєте нашу працю.",
+    "terms": "Умови"
   },
   "hero": {
-    "badge": "Доверие в каждом слое",
-    "description": "Уникальный сервис 3D-печати: пришлите модель, получите готовое изделие по почте и заплатите столько, сколько посчитаете нужным.",
-    "pricingButton": "Как это работает",
+    "badge": "Довіра у кожному шарі",
+    "description": "Унікальний сервіс 3D-друку: надішліть модель, отримайте готовий виріб поштою та заплатіть стільки, скільки вважаєте за потрібне.",
+    "pricingButton": "Як це працює",
     "stats": {
-      "materials": "Материалов",
+      "materials": "матеріалів",
       "materialsValue": "10+",
-      "precision": "Точность",
+      "precision": "Точність",
       "precisionValue": "0.1мм",
-      "shipping": "Доставка почтой",
-      "shippingValue": "Экспресс"
+      "shipping": "Доставка поштою",
+      "shippingValue": "Експрес"
     },
-    "title": "Мы печатаем —",
-    "titleGradient": "Вы оцениваете",
-    "uploadButton": "Заказать печать"
+    "title": "Ми друкуємо",
+    "titleGradient": "Ви оцінюєте",
+    "uploadButton": "Замовити друк"
   },
   "nav": {
-    "howItWorks": "Как это работает",
-    "logIn": "Войти",
-    "logOut": "Выйти",
-    "materials": "Материалы",
-    "myOrders": "Мои заказы",
-    "portfolio": "Портфолио",
-    "philosophy": "Наш подход",
-    "register": "Регистрация",
-    "services": "Услуги"
+    "howItWorks": "Як це працює",
+    "logIn": "Увійти",
+    "logOut": "Вийти",
+    "materials": "Матеріали",
+    "myOrders": "Мої замовлення",
+    "portfolio": "Портфоліо",
+    "philosophy": "Наш підхід",
+    "register": "Реєстрація",
+    "services": "Послуги"
   },
   "pricing": {
-    "badge": "Политика доверия",
-    "description": "Никаких предоплат и сложных калькуляторов. Вы платите только за результат, в который верите.",
-    "materials": "Доступные материалы",
-    "requestQuote": "Отправить запрос",
-    "saveConfig": "Сохранить",
+    "badge": "Політика довіри",
+    "description": "Жодних передоплат і складних калькуляторів. Ви платите лише за результат, у який вірите.",
+    "materials": "Доступні матеріали",
+    "requestQuote": "Надіслати запит",
+    "saveConfig": "Зберегти",
     "title": "Оплата",
-    "titleGradient": "после получения",
+    "titleGradient": "після отримання",
     "trustSteps": {
-      "step1": "Отправьте нам STL модель или ссылку",
-      "step2": "Мы изготовим ее из подходящего материала",
-      "step3": "Получите посылку на указанный адрес",
-      "step4": "Оцените работу и оплатите удобным способом"
+      "step1": "Надішліть нам STL модель або посилання",
+      "step2": "Ми виготовимо її з відповідного матеріалу",
+      "step3": "Отримайте посилку на вказану адресу",
+      "step4": "Оцініть роботу та сплатіть зручним способом"
     }
   },
   "services": {
-    "badge": "Наши возможности",
-    "description": "Мы подберем оптимальный метод печати для вашей задачи.",
+    "badge": "Наші можливості",
+    "description": "Ми підберемо оптимальний метод друку для вашого завдання.",
     "fdm": {
-      "description": "Прочные детали из инженерных пластиков.",
-      "title": "FDM печать"
+      "description": "Міцні деталі із інженерних пластиків.",
+      "title": "FDM друк"
     },
     "sla": {
       "description": "Максимальная детализация and гладкость изделий.",
       "title": "SLA смола"
     },
-    "title": "Технологии",
-    "titleGradient": "реализации"
+    "title": "Технології",
+    "titleGradient": "реалізації"
   },
   "portfolio": {
     "title": "Галерея",
-    "titleGradient": "работ",
-    "description": "Ознакомьтесь с нашими успешными проектами 3D-печати, реализованными для клиентов в Черногории.",
-    "empty": "Наша галерея пополняется. Заходите позже!"
+    "titleGradient": "робіт",
+    "description": "Ознайомтеся з нашими успішними проектами 3D-друку, реалізованими для клієнтів у Чорногорії.",
+    "empty": "Наша галерея поповнюється. Заходьте пізніше!"
   },
   "upload": {
-    "addressPlaceholder": "Город, Индекс, Адрес (в свободной форме)",
-    "badge": "Оформление заказа",
-    "allowPortfolio": "Разрешить публикацию в портфолио",
-    "allowPortfolioDesc": "Мы покажем фото вашего изделия, чтобы вдохновить других клиентов.",
-    "selectMaterial": "Выберите материал",
-    "browse": "выбрать файлы",
-    "continue": "Отправить заказ",
-    "description": "Загрузите файл или укажите ссылку на модель (Thingiverse, Printables i др.). Мы свяжемся с вами для уточнения деталей.",
-    "dropzone": "Загрузить файлы (STL, OBJ, STEP)",
-    "dropzoneActive": "Переместите файлы сюда",
+    "addressPlaceholder": "Місто, Індекс, Адреса (у вільній формі)",
+    "badge": "Оформлення замовлення",
+    "allowPortfolio": "Дозволити публікацію в портфоліо",
+    "allowPortfolioDesc": "Ми покажемо фотографії вашого виробу, щоб надихнути інших клієнтів.",
+    "selectMaterial": "Виберіть матеріал",
+    "browse": "вибрати файли",
+    "continue": "Надіслати замовлення",
+    "description": "Завантажте файл або вкажіть посилання на модель (Thingiverse, Printables тощо). Ми зв'яжемося з вами для уточнення деталей.",
+    "dropzone": "Завантажити файли (STL, OBJ, STEP)",
+    "dropzoneActive": "Перемістіть файли сюди",
     "email": "Email",
-    "firstName": "Имя",
-    "lastName": "Фамилия",
-    "modelLink": "Ссылка на модель (необязательно)",
+    "firstName": "Ім'я",
+    "lastName": "Прізвище",
+    "modelLink": "Посилання на модель (необов'язково)",
     "modelLinkPlaceholder": "https://www.printables.com/model/...",
-    "notes": "Примечания к заказу",
-    "notesPlaceholder": "Пожелания по цвету, материалу, толщине стенок или другие инструкции...",
+    "notes": "Примітки до замовлення",
+    "notesPlaceholder": "Побажання щодо кольору, матеріалу, товщини стінок або інші інструкції.",
     "phone": "Телефон",
-    "quantity": "Количество копий",
-    "shippingAddress": "Адрес доставки",
-    "submitting": "Отправка...",
-    "success": "Заказ успешно отправлен! Мы свяжемся с вами в ближайшее время.",
-    "title": "Пришлите",
-    "titleGradient": "вашу идею",
-    "uploadedFiles": "Выбранные файлы"
+    "quantity": "Кількість копій",
+    "shippingAddress": "Адреса доставки",
+    "submitting": "Надсилання...",
+    "success": "Замовлення успішно надіслано! Ми зв'яжемося з вами найближчим часом.",
+    "title": "Надішліть",
+    "titleGradient": "вашу ідею",
+    "uploadedFiles": "Вибрані файли"
   },
   "whyTrust": {
-    "description1": "Мы верим, что качественная 3D-печать должна быть доступной, а процесс — максимально простым. Наш опыт позволяет нам брать на себя риски: мы уверены в своем оборудовании и качестве материалов.",
-    "description2": "Этот подход позволяет убрать барьеры \"сложных расчетов\" и дать вам возможность получить именно то, что вы задумали, оценив результат самостоятельно.",
+    "description1": "Ми віримо, що якісний 3D-друк має бути доступним, а процес — максимально простим. Наш досвід дозволяє нам брати на себе ризики: ми впевнені у своєму обладнанні та якості матеріалів.",
+    "description2": "Цей підхід дозволяє усунути бар'єри \"складних розрахунків\" і дати вам можливість отримати саме те, що ви задумали, оцінивши результат самостійно.",
     "items": {
-      "noCommissions": "Без комиссий",
-      "noPrepayment": "Bez предоплаты",
-      "shipping": "Отправка почтой",
+      "noCommissions": "Без комісій",
+      "noPrepayment": "Без передоплати",
+      "shipping": "Надсилання поштою",
       "yourPrice": "Ваша cena"
     },
-    "title": "Почему мы",
-    "titleItalic": "доверяем"
+    "title": "Чому ми",
+    "titleItalic": "довіряємо"
   },
   "privacy": {
     "title": "Політика конфіденційності",
@@ -221,8 +221,8 @@
     }
   },
   "cookies": {
-    "message": "Данный сайт использует файлы cookie для улучшения пользовательского опыта.",
-    "accept": "Принять",
-    "leave": "Уйти"
+    "message": "Цей сайт використовує файли cookie для покращення досвіду користувача.",
+    "accept": "Прийняти",
+    "leave": "Піти"
   }
 }

+ 39 - 3
src/stores/auth.ts

@@ -26,10 +26,46 @@ export const useAuthStore = defineStore("auth", () => {
   }
 
   let pingInterval: number | null = null;
+  const unreadMessagesCount = ref(0);
+
+  function playNotificationSound() {
+    try {
+      const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
+      if (!AudioCtx) return;
+      const ctx = new AudioCtx();
+      const osc = ctx.createOscillator();
+      const gainNode = ctx.createGain();
+      
+      osc.type = 'sine';
+      osc.frequency.setValueAtTime(880, ctx.currentTime); // A5
+      osc.frequency.exponentialRampToValueAtTime(1760, ctx.currentTime + 0.1); // Up to A6
+      
+      gainNode.gain.setValueAtTime(0, ctx.currentTime);
+      gainNode.gain.linearRampToValueAtTime(0.1, ctx.currentTime + 0.05);
+      gainNode.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.2);
+      
+      osc.connect(gainNode);
+      gainNode.connect(ctx.destination);
+      osc.start();
+      osc.stop(ctx.currentTime + 0.2);
+    } catch (e) {
+      console.warn("Audio disabled or not supported", e);
+    }
+  }
+
   function startPing() {
     import("@/lib/api").then(({ authPing }) => {
-      authPing(); // ping immediately
-      pingInterval = window.setInterval(authPing, 30000);
+      const doPing = async () => {
+        const res = await authPing();
+        if (res && res.unread_count !== undefined) {
+          if (res.unread_count > unreadMessagesCount.value) {
+            playNotificationSound();
+          }
+          unreadMessagesCount.value = res.unread_count;
+        }
+      };
+      doPing(); // ping immediately
+      pingInterval = window.setInterval(doPing, 30000);
     });
   }
   function stopPing() {
@@ -39,7 +75,6 @@ export const useAuthStore = defineStore("auth", () => {
     }
   }
 
-
   function init() {
     if (!initialized) {
       initialized = true;
@@ -60,6 +95,7 @@ export const useAuthStore = defineStore("auth", () => {
     user,
     isLoading,
     showCompleteProfile,
+    unreadMessagesCount,
     init,
     setUser,
     refreshUser,

+ 38 - 0
translate_ua.py

@@ -0,0 +1,38 @@
+import json
+import urllib.request
+import urllib.parse
+import time
+import sys
+
+def translate_ru_to_ua(text):
+    if not text:
+        return text
+    try:
+        url = 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=ru&tl=uk&dt=t&q=' + urllib.parse.quote(text)
+        req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
+        response = urllib.request.urlopen(req)
+        data = json.loads(response.read().decode('utf-8'))
+        translated_text = "".join([sentence[0] for sentence in data[0]])
+        return translated_text
+    except Exception as e:
+        print(f"Error translating '{text}': {e}", file=sys.stderr)
+        return text
+
+def process_dict(d):
+    for k, v in d.items():
+        if isinstance(v, dict):
+            if "ru" in v and "ua" in v:
+                if v["ru"] == v["ua"] or v["ua"] == "" or "язык" in v["ua"].lower() or "пароль" in v["ua"].lower() or "почт" in v["ua"].lower() or "забыли" in v["ua"].lower():
+                    # Check if actually same or mostly Russian wording
+                    if v["ru"]:
+                        v["ua"] = translate_ru_to_ua(v["ru"])
+                        time.sleep(0.3)
+            process_dict(v)
+
+if __name__ == "__main__":
+    with open('src/locales/translations.json', 'r', encoding='utf-8') as f:
+        data = json.load(f)
+    process_dict(data)
+    with open('src/locales/translations.json', 'w', encoding='utf-8') as f:
+        json.dump(data, f, ensure_ascii=False, indent=2)
+    print("Done")