Browse Source

feat: implement online status ping and fix build typescript errors

unknown 1 tuần trước cách đây
mục cha
commit
3ab7451fe5

+ 7 - 0
.cursorrules

@@ -0,0 +1,7 @@
+
+# Обязательные правила для ИИ:
+- После каждого успешного внедрения фичи или серии правок, делай `git add .` и `git commit -m "описание"`.
+- Проверяй работоспособность перед коммитом.
+- Если мы изменяли фронтенд, то обязательно запускай npm run build и анализируй вывод.
+- Если мы поменяли бэкэнд, то согласовываем тесты и запускаем тесты каждый раз.
+- Не используй powershell, используй cmd

+ 7 - 0
backend/routers/auth.py

@@ -107,3 +107,10 @@ async def update_me(data: schemas.UserUpdate, token: str = Depends(auth_utils.oa
         db.execute_commit(query, tuple(params))
     user = db.execute_query("SELECT id, email, first_name, last_name, phone, shipping_address, preferred_language, role, ip_address, created_at FROM users WHERE id = %s", (user_id,))
     return user[0]
+
+@router.post("/ping")
+async def track_ping(token: str = Depends(auth_utils.oauth2_scheme)):
+    payload = auth_utils.decode_token(token)
+    if payload and payload.get("id"):
+        session_utils.track_user_ping(payload.get("id"))
+    return {"status": "ok"}

+ 1 - 1
backend/routers/chat.py

@@ -54,7 +54,7 @@ async def ws_chat(websocket: WebSocket, order_id: int, token: str = Query(...)):
     if role != 'admin' and order[0]['user_id'] != user_id:
         await websocket.close(code=4003)
         return
-    await manager.connect(websocket, order_id)
+    await manager.connect(websocket, order_id, role)
     try:
         while True:
             await websocket.receive_text()

+ 18 - 4
backend/routers/orders.py

@@ -12,7 +12,7 @@ import hashlib
 import preview_utils
 import slicer_utils
 from fastapi import APIRouter, Request, Form, Depends, HTTPException, BackgroundTasks, UploadFile, File
-from services import pricing, order_processing
+from services import pricing, order_processing, event_hooks
 
 router = APIRouter(prefix="/orders", tags=["orders"])
 
@@ -79,6 +79,7 @@ async def create_order(
                 qty = parsed_quantities[idx] if idx < len(parsed_quantities) else 1
                 db.execute_commit("UPDATE order_files SET order_id = %s, quantity = %s WHERE id = %s", (order_insert_id, qty, f_id))
         background_tasks.add_task(order_processing.process_order_slicing, order_insert_id)
+        background_tasks.add_task(event_hooks.on_order_created, order_insert_id)
         return {"status": "success", "order_id": order_insert_id, "message": "Order submitted successfully"}
     except Exception as e:
         print(f"Error creating order: {e}")
@@ -141,7 +142,9 @@ async def get_admin_orders(token: str = Depends(auth_utils.oauth2_scheme)):
     ORDER BY o.created_at DESC
     """
     results = db.execute_query(query)
+    import session_utils
     for row in results:
+        row['is_online'] = session_utils.is_user_online(row['user_id']) if row.get('user_id') else False
         if row['files']:
             try: row['files'] = json.loads(f"[{row['files']}]")
             except: row['files'] = []
@@ -151,7 +154,12 @@ async def get_admin_orders(token: str = Depends(auth_utils.oauth2_scheme)):
     return results
 
 @router.patch("/{order_id}/admin")
-async def update_order_admin(order_id: int, data: schemas.AdminOrderUpdate, token: str = Depends(auth_utils.oauth2_scheme)):
+async def update_order_admin(
+    order_id: int, 
+    data: schemas.AdminOrderUpdate, 
+    background_tasks: BackgroundTasks,
+    token: str = Depends(auth_utils.oauth2_scheme)
+):
     payload = auth_utils.decode_token(token)
     if not payload or payload.get("role") != 'admin':
         raise HTTPException(status_code=403, detail="Admin role required")
@@ -159,9 +167,15 @@ async def update_order_admin(order_id: int, data: schemas.AdminOrderUpdate, toke
     update_fields = []
     params = []
     if data.status:
-        order_info = db.execute_query("SELECT email, first_name FROM orders WHERE id = %s", (order_id,))
+        order_info = db.execute_query("SELECT * FROM orders WHERE id = %s", (order_id,))
         if order_info:
-            notifications.notify_status_change(order_info[0]['email'], order_id, data.status, order_info[0]['first_name'])
+            background_tasks.add_task(
+                event_hooks.on_order_status_changed, 
+                order_id, 
+                data.status, 
+                order_info[0], 
+                data.send_notification
+            )
         update_fields.append("status = %s")
         params.append(data.status)
     if data.total_price is not None:

+ 1 - 0
backend/schemas.py

@@ -119,6 +119,7 @@ class UserResponse(BaseModel):
 class AdminOrderUpdate(BaseModel):
     status: Optional[str] = None
     total_price: Optional[float] = None
+    send_notification: Optional[bool] = False
 
 class EstimateRequest(BaseModel):
     material_id: int

+ 24 - 6
backend/services/chat_manager.py

@@ -7,19 +7,37 @@ from fastapi import WebSocket
 class ChatConnectionManager:
     def __init__(self):
         # order_id -> list of active websockets
-        self.active_connections: Dict[int, List[WebSocket]] = {}
+        # order_id -> list of dicts: {"ws": websocket, "role": str}
+        self.active_connections: Dict[int, List[Dict[str, Any]]] = {}
 
-    async def connect(self, websocket: WebSocket, order_id: int):
+    async def connect(self, websocket: WebSocket, order_id: int, role: str):
         await websocket.accept()
         if order_id not in self.active_connections:
             self.active_connections[order_id] = []
-        self.active_connections[order_id].append(websocket)
+        self.active_connections[order_id].append({"ws": websocket, "role": role})
+        await self.broadcast_presence(order_id)
 
     def disconnect(self, websocket: WebSocket, order_id: int):
         if order_id in self.active_connections:
-            self.active_connections[order_id].remove(websocket)
+            self.active_connections[order_id] = [c for c in self.active_connections[order_id] if c["ws"] != websocket]
             if not self.active_connections[order_id]:
                 del self.active_connections[order_id]
+            else:
+                import asyncio
+                try:
+                    asyncio.create_task(self.broadcast_presence(order_id))
+                except Exception:
+                    pass
+
+    async def broadcast_presence(self, order_id: int):
+        if order_id in self.active_connections:
+            roles = [c["role"] for c in self.active_connections[order_id]]
+            payload = json.dumps({"type": "presence", "online_roles": list(set(roles))})
+            for connection in self.active_connections[order_id]:
+                try:
+                    await connection["ws"].send_text(payload)
+                except:
+                    pass
 
     async def broadcast_to_order(self, order_id: int, message: Any):
         if order_id in self.active_connections:
@@ -28,12 +46,12 @@ class ChatConnectionManager:
                 if isinstance(message['created_at'], datetime.datetime):
                     message['created_at'] = message['created_at'].isoformat()
             
+            message["type"] = "message"
             payload = json.dumps(message)
             for connection in self.active_connections[order_id]:
                 try:
-                    await connection.send_text(payload)
+                    await connection["ws"].send_text(payload)
                 except:
-                    # Connection might be dead
                     pass
 
 manager = ChatConnectionManager()

+ 32 - 0
backend/services/event_hooks.py

@@ -0,0 +1,32 @@
+import json
+import db
+import config
+
+def on_order_created(order_id: int):
+    """
+    Hook triggered asynchronously when a new order is placed.
+    Users can add any notification logic here (e.g. email or telegram).
+    """
+    print(f"EVENT: Order {order_id} created.")
+    # Fetch order data if needed
+    order = db.execute_query("SELECT * FROM orders WHERE id = %s", (order_id,))
+    if order:
+        order_data = order[0]
+        # TODO: Add your notification logic here
+        pass
+
+def on_order_status_changed(order_id: int, status: str, order_data: dict, send_notification: bool):
+    """
+    Hook triggered asynchronously when the admin changes the order status.
+    Uses the send_notification flag explicitly.
+    """
+    print(f"EVENT: Order {order_id} status changed to {status}. Notify user: {send_notification}")
+    
+    if send_notification:
+        # TODO: Add your notification logic here (Email, Telegram, SMS, etc.)
+        # The order_data dictionary contains all the details of the order.
+        user_email = order_data.get('email')
+        first_name = order_data.get('first_name')
+        
+        print(f"--> Sending notification to {user_email} (User: {first_name})...")
+        pass

+ 9 - 0
backend/session_utils.py

@@ -28,3 +28,12 @@ def delete_session(session_id: str):
 def get_user_id_from_session(session_id: str):
     """Retrieve the user_id associated with a session"""
     return r.get(f"session:{session_id}")
+
+import time
+def track_user_ping(user_id: int):
+    """Track user online status with 60s expiration"""
+    r.setex(f"user_ping:{user_id}", 60, str(int(time.time())))
+
+def is_user_online(user_id: int) -> bool:
+    """Check if user has pinged within the last 60s"""
+    return r.exists(f"user_ping:{user_id}") == 1

+ 27 - 0
backend/start.bat

@@ -0,0 +1,27 @@
+@echo off
+echo =========================================
+echo  🧪 Running backend tests...
+echo =========================================
+
+if exist ".venv\Scripts\pytest.exe" (
+    set PYTEST_BIN=".venv\Scripts\pytest"
+) else (
+    set PYTEST_BIN="pytest"
+)
+
+%PYTEST_BIN% tests\ -v
+if %errorlevel% neq 0 (
+    echo.
+    echo ❌ Tests failed! Aborting server start! Please fix the errors.
+    exit /b %errorlevel%
+)
+
+echo.
+echo ✅ Tests passed successfully! Starting dev server...
+echo.
+
+if exist ".venv\Scripts\uvicorn.exe" (
+    .venv\Scripts\uvicorn main:app --host 127.0.0.1 --port 8000 --reload
+) else (
+    uvicorn main:app --host 127.0.0.1 --port 8000 --reload
+)

+ 22 - 0
backend/start.sh

@@ -0,0 +1,22 @@
+#!/bin/bash
+# Скрипт безопасного запуска бэкенда (Production / Linux)
+
+echo "🧪 Running backend tests..."
+# Ищем pytest в виртуальном окружении
+if [ -f ".venv/bin/pytest" ]; then
+    PYTEST_BIN=".venv/bin/pytest"
+else
+    PYTEST_BIN="pytest"
+fi
+
+$PYTEST_BIN tests/ -v
+TEST_STATUS=$?
+
+if [ $TEST_STATUS -ne 0 ]; then
+  echo "❌ Tests failed! Aborting server start to prevent broken deployments."
+  exit $TEST_STATUS
+fi
+
+echo "✅ Tests passed successfully! Starting Uvicorn backend..."
+# Запуск Uvicorn с рабочими процессами
+.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 --workers 4

+ 25 - 0
backend/tests/test_event_hooks.py

@@ -0,0 +1,25 @@
+import pytest
+from unittest.mock import patch, MagicMock
+from services import event_hooks
+
+def test_on_order_created_hook():
+    with patch('db.execute_query') as mock_query:
+        # Arrange
+        mock_query.return_value = [{"id": 99, "first_name": "Test", "email": "test@example.com"}]
+        
+        # Act
+        # Should execute without throwing errors
+        event_hooks.on_order_created(99)
+        
+        # Assert
+        mock_query.assert_called_once_with("SELECT * FROM orders WHERE id = %s", (99,))
+
+def test_on_order_status_changed_hook():
+    order_data = {"id": 99, "first_name": "Test", "email": "test@example.com"}
+    
+    # Act
+    # Should execute without throwing errors
+    event_hooks.on_order_status_changed(order_id=99, status="completed", order_data=order_data, send_notification=True)
+    
+    # Can also test disabled notifications
+    event_hooks.on_order_status_changed(order_id=99, status="shipped", order_data=order_data, send_notification=False)

+ 5 - 1
build_frontend.sh

@@ -12,7 +12,11 @@ npm ci
 echo "🧹 Очистка старых данных..."
 rm -rf dist
 
-# 3. Запускаем сборку Vue
+# 3. Генерируем локализации
+echo "🌐 Генерация файлов локализации..."
+python scripts/manage_locales.py split
+
+# 4. Запускаем сборку Vue
 echo "🔨 Сборка проекта (Vite / Vue 3)..."
 npm run build
 

+ 3 - 4
radionica-backend.service

@@ -1,6 +1,6 @@
 [Unit]
 Description=Radionica3D FastAPI Backend Service
-After=network.target
+After=network.target redis.service
 # Раскомментируйте следующую строку, если база данных MySQL тоже крутится на этом сервере
 # After=network.target mysql.service redis.service
 
@@ -12,9 +12,8 @@ Group=www-data
 # Путь до директории бэкенда
 WorkingDirectory=/var/www/radionica3d/backend
 
-# Путь к Python внутри виртуального окружения (.venv/bin/uvicorn)
-# Используем флаг --workers для запуска нескольких параллельных процессов в проде
-ExecStart=/var/www/radionica3d/backend/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 --workers 4
+# Путь к скрипту запуска, который сначала прогоняет тесты
+ExecStart=/bin/bash /var/www/radionica3d/backend/start.sh
 
 # Авторестарт при падении
 Restart=always

+ 1 - 3
src/components/CookieBanner.vue

@@ -36,8 +36,6 @@ const accept = () => {
 };
 
 const leave = () => {
-  // If user clicks leave, we can redirect them elsewhere
-  // Or just close the window
-  window.history.length > 1 ? window.history.back() : (window.location.href = "https://www.google.com");
+  window.location.href = "https://www.google.com";
 };
 </script>

+ 1 - 0
src/components/HeroSection.vue

@@ -7,6 +7,7 @@
       <div class="grid lg:grid-cols-2 gap-12 items-center">
         <!-- Text -->
         <div class="text-center lg:text-left animate-slide-up">
+          <div class="inline-flex items-center justify-center px-4 py-1.5 mb-6 rounded-full bg-primary/10 border border-primary/20">
             <span class="text-xs text-primary font-bold tracking-widest">{{ t("hero.badge") }}</span>
           </div>
 

+ 44 - 1
src/components/OrderChat.vue

@@ -7,6 +7,9 @@
         <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
+        </span>
       </div>
       <button v-if="closable" @click="$emit('close')" class="p-1 hover:bg-secondary rounded-lg transition-colors">
         <X class="w-4 h-4 text-muted-foreground" />
@@ -76,8 +79,9 @@
 <script setup lang="ts">
 import { ref, onMounted, onUnmounted, nextTick, watch } from "vue";
 import { useI18n } from "vue-i18n";
-import { MessageCircle, Send, Loader2, ShieldCheck, X } from "lucide-vue-next";
+import { MessageCircle, Send, Loader2, ShieldCheck, X, Eye } from "lucide-vue-next";
 import { getOrderMessages, sendOrderMessage } from "@/lib/api";
+import { useAuthStore } from "@/stores/auth";
 
 const WS_BASE_URL = "ws://localhost:8000";
 
@@ -97,6 +101,31 @@ const isSending = ref(false);
 const wsConnected = ref(false);
 const messagesContainer = ref<HTMLElement | null>(null);
 const textareaRef = ref<HTMLTextAreaElement | null>(null);
+const otherPartyOnline = ref(false);
+const authStore = useAuthStore();
+
+function playDing() {
+  try {
+    const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
+    if (!AudioContext) return;
+    const ctx = new AudioContext();
+    const osc = ctx.createOscillator();
+    const gainNode = ctx.createGain();
+    
+    osc.type = "sine";
+    osc.frequency.setValueAtTime(880, ctx.currentTime);
+    osc.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.1);
+    
+    gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
+    gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
+    
+    osc.connect(gainNode);
+    gainNode.connect(ctx.destination);
+    
+    osc.start();
+    osc.stop(ctx.currentTime + 0.3);
+  } catch(e) {}
+}
 
 let ws: WebSocket | null = null;
 let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -115,10 +144,24 @@ function connectWebSocket() {
   ws.onmessage = (event) => {
     try {
       const msg = JSON.parse(event.data);
+      if (msg.type === "presence") {
+        const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
+        const otherRole = myRole === 'admin' ? 'user' : 'admin';
+        otherPartyOnline.value = msg.online_roles.includes(otherRole);
+        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();
+        }
       }
     } catch (e) {
       console.error("WS parse error:", e);

+ 4 - 4
src/components/ServicesSection.vue

@@ -26,8 +26,8 @@
           <div class="w-12 h-12 bg-primary/5 rounded-xl flex items-center justify-center mb-4 group-hover:bg-primary group-hover:text-primary-foreground transition-all duration-500">
             <component :is="iconMap[service.tech_type] || iconMap.DEFAULT" class="w-6 h-6" />
           </div>
-          <h3 class="font-display text-lg font-bold mb-2">{{ service[`name_${locale}`] || service.name_en }}</h3>
-          <p class="text-foreground/50 text-sm leading-relaxed font-medium">{{ service[`desc_${locale}`] || service.desc_en }}</p>
+          <h3 class="font-display text-lg font-bold mb-2">{{ service[`name_${locale}` as keyof Service] || service.name_en }}</h3>
+          <p class="text-foreground/50 text-sm leading-relaxed font-medium">{{ service[`desc_${locale}` as keyof Service] || service.desc_en }}</p>
         </div>
       </div>
 
@@ -47,9 +47,9 @@
             :style="{ animationDelay: `${idx * 50}ms` }"
           >
             <div class="text-sm font-display font-extrabold text-foreground mb-1 group-hover:text-primary transition-colors uppercase tracking-wide">
-              {{ material[`name_${locale}`] || material.name_en }}
+              {{ material[`name_${locale}` as keyof Material] || material.name_en }}
             </div>
-            <p class="text-[11px] text-foreground/40 line-clamp-2 leading-tight font-medium">{{ material[`desc_${locale}`] || material.desc_en }}</p>
+            <p class="text-[11px] text-foreground/40 line-clamp-2 leading-tight font-medium">{{ material[`desc_${locale}` as keyof Material] || material.desc_en }}</p>
           </div>
         </div>
       </div>

+ 13 - 0
src/lib/api.ts

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

+ 0 - 0
src/locales/.cursorrules


+ 27 - 0
src/locales/en.json

@@ -167,5 +167,32 @@
     },
     "title": "Why we",
     "titleItalic": "trust"
+  },
+  "privacy": {
+    "title": "Privacy Policy",
+    "subtitle": "Your data is safe with us. We respect your privacy and follow GDPR guidelines.",
+    "sections": {
+      "collection": {
+        "title": "Data Collection",
+        "content": "We collect your personal information (name, email, phone number, and address) exclusively for the purpose of processing and delivering your 3D printing orders. We do not use this data for marketing or other unsolicited purposes."
+      },
+      "sharing": {
+        "title": "Data Sharing",
+        "content": "We do not share, sell, or disclose your personal data to any third parties, except where required by law (e.g., authorized government authorities)."
+      },
+      "retention": {
+        "title": "Retention & Deletion",
+        "content": "Your data is stored securely. You have the right to request the deletion of your personal information at any time. Automatically, data may be deleted after a 12-month period of inactivity following order completion."
+      },
+      "rights": {
+        "title": "GDPR Compliance",
+        "content": "Under GDPR, you have the right to access, rectify, or erase your data, as well as the right to data portability. Contact us at hello@radionica3d.me for any privacy-related requests."
+      }
+    }
+  },
+  "cookies": {
+    "message": "This site uses cookies to improve your experience and analyze traffic.",
+    "accept": "Accept",
+    "leave": "Leave"
   }
 }

+ 27 - 0
src/locales/me.json

@@ -167,5 +167,32 @@
     },
     "title": "Zašto nam",
     "titleItalic": "vjeruju"
+  },
+  "privacy": {
+    "title": "Politika privatnosti",
+    "subtitle": "Vaši podaci su bezbjedni. Poštujemo vašu privatnost i pratimo GDPR smjernice.",
+    "sections": {
+      "collection": {
+        "title": "Prikupljanje podataka",
+        "content": "Vaše lične podatke (ime, email, broj telefona i adresu) prikupljamo isključivo u svrhu obrade i dostave vaših porudžbina za 3D štampu. Ove podatke ne koristimo u marketinške ili druge nepredviđene svrhe."
+      },
+      "sharing": {
+        "title": "Dijeljenje podataka",
+        "content": "Vaše lične podatke ne dijelimo, ne prodajemo i ne otkrivamo trećim licima, osim u slučajevima predviđenim zakonom (npr. ovlašćenim državnim organima)."
+      },
+      "retention": {
+        "title": "Zadržavanje i brisanje",
+        "content": "Vaši podaci se čuvaju na siguran način. Imate pravo da zatražite brisanje svojih ličnih podataka u bilo kom trenutku. Podaci se automatski brišu nakon perioda od 12 mjeseci neaktivnosti nakon završetka porudžbine."
+      },
+      "rights": {
+        "title": "Usklađenost sa GDPR",
+        "content": "Prema GDPR regulativi, imate pravo na pristup, ispravku ili brisanje svojih podataka, kao i pravo na prenosivost podataka. Kontaktirajte nas na hello@radionica3d.me za sve upite u vezi sa privatnošću."
+      }
+    }
+  },
+  "cookies": {
+    "message": "Ovaj sajt koristi kolačiće za pružanje boljeg korisničkog iskustva.",
+    "accept": "Prihvati",
+    "leave": "Napusti"
   }
 }

+ 27 - 0
src/locales/ru.json

@@ -167,5 +167,32 @@
     },
     "title": "Почему мы",
     "titleItalic": "доверяем"
+  },
+  "privacy": {
+    "title": "Политика конфиденциальности",
+    "subtitle": "Ваши данные в безопасности. Мы уважаем вашу конфиденциальность и следуем принципам GDPR.",
+    "sections": {
+      "collection": {
+        "title": "Сбор данных",
+        "content": "Мы собираем вашу личную информацию (имя, адрес электронной почты, номер телефона и адрес) исключительно в целях обработки и доставки ваших заказов на 3D-печать. Мы не используем эти данные для маркетинга или других нежелательных целей."
+      },
+      "sharing": {
+        "title": "Передача данных",
+        "content": "Мы не передаем, не продаем и не раскрываем ваши личные данные третьим лицам, за исключением случаев, предусмотренных законом (например, уполномоченным государственным органам)."
+      },
+      "retention": {
+        "title": "Хранение и удаление",
+        "content": "Ваши данные хранятся в безопасности. Вы имеете право запросить удаление вашей личной информации в любое время. Данные могут быть автоматически удалены по истечении 12-месячного периода бездействия после завершения заказа."
+      },
+      "rights": {
+        "title": "Соответствие GDPR",
+        "content": "В соответствии с GDPR у вас есть право на доступ к своим данным, их исправление или удаление, а также право на переносимость данных. Свяжитесь с нами по адресу hello@radionica3d.me по любым вопросам, связанным с конфиденциальностью."
+      }
+    }
+  },
+  "cookies": {
+    "message": "Данный сайт использует файлы cookie для улучшения пользовательского опыта.",
+    "accept": "Принять",
+    "leave": "Уйти"
   }
 }

+ 1 - 0
src/locales/translations.json

@@ -727,6 +727,7 @@
           "ru": "В соответствии с GDPR у вас есть право на доступ к своим данным, их исправление или удаление, а также право на переносимость данных. Свяжитесь с нами по адресу hello@radionica3d.me по любым вопросам, связанным с конфиденциальностью.",
           "me": "Prema GDPR regulativi, imate pravo na pristup, ispravku ili brisanje svojih podataka, kao i pravo na prenosivost podataka. Kontaktirajte nas na hello@radionica3d.me za sve upite u vezi sa privatnošću."
         }
+      }
     }
   },
   "cookies": {

+ 15 - 3
src/pages/Admin.vue

@@ -60,7 +60,10 @@
                 </span>
               </div>
               <div class="space-y-1">
-                <h3 class="font-bold">{{ order.first_name }} {{ order.last_name }}</h3>
+                <div class="flex items-center gap-2">
+                  <h3 class="font-bold">{{ order.first_name }} {{ order.last_name }}</h3>
+                  <span :title="order.is_online ? 'Online' : 'Offline'" :class="['w-2 h-2 rounded-full', order.is_online ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.8)]' : 'bg-muted-foreground/30']"></span>
+                </div>
                 <p class="text-sm text-muted-foreground truncate">{{ order.email }}</p>
               </div>
               <div class="mt-4 pt-4 border-t border-border/50 text-xs text-muted-foreground">{{ new Date(order.created_at).toLocaleString() }}</div>
@@ -165,6 +168,10 @@
             </div>
             <!-- Pricing & Actions -->
             <div class="p-6 lg:w-1/4 bg-primary/5">
+              <div class="flex items-center gap-1.5 mb-3">
+                <input type="checkbox" :id="`notify-${order.id}`" v-model="notifyStatusMap[order.id]" class="w-3.5 h-3.5 rounded border-border" />
+                <label :id="`notify-${order.id}`" class="text-[9px] font-bold uppercase text-muted-foreground cursor-pointer">Notify User</label>
+              </div>
               <div class="grid grid-cols-2 gap-2 mb-6">
                 <button v-for="s in Object.keys(STATUS_CONFIG)" :key="s"
                   @click="handleUpdateStatus(order.id, s)"
@@ -378,6 +385,7 @@ const editingPrice    = ref<{ id: number; price: string } | null>(null);
 const editingMaterial = ref<any | null>(null);
 const editingService  = ref<any | null>(null);
 const showAddModal    = ref(false);
+const notifyStatusMap = ref<Record<number, boolean>>({});
 
 const matForm = reactive({ name_en: "", name_ru: "", name_me: "", desc_en: "", desc_ru: "", desc_me: "", price_per_cm3: 0, is_active: true });
 const svcForm = reactive({ name_key: "", description_key: "", tech_type: "", is_active: true });
@@ -392,7 +400,10 @@ const filteredOrders = computed(() => orders.value.filter(o => {
 async function fetchData() {
   isLoading.value = true;
   try {
-    if (activeTab.value === "orders")    orders.value    = await adminGetOrders();
+    if (activeTab.value === "orders") {
+      orders.value = await adminGetOrders();
+      orders.value.forEach(o => { if (notifyStatusMap.value[o.id] === undefined) notifyStatusMap.value[o.id] = true; });
+    }
     else if (activeTab.value === "materials") materials.value = await adminGetMaterials();
     else if (activeTab.value === "services")  services.value  = await adminGetServices();
   } catch { toast.error(`Failed to load ${activeTab.value}`); }
@@ -406,7 +417,8 @@ onMounted(async () => {
 });
 
 async function handleUpdateStatus(id: number, status: string) {
-  try { await adminUpdateOrder(id, { status }); toast.success(`Status → ${status}`); fetchData(); }
+  const notify = notifyStatusMap.value[id] ?? true; // True by default
+  try { await adminUpdateOrder(id, { status, send_notification: notify }); toast.success(`Status → ${status}`); fetchData(); }
   catch { toast.error("Failed to update status"); }
 }
 async function handleUpdatePrice() {

+ 17 - 0
src/stores/auth.ts

@@ -18,11 +18,28 @@ export const useAuthStore = defineStore("auth", () => {
     } catch {
       user.value = null;
       showCompleteProfile.value = false;
+      stopPing();
     } finally {
       isLoading.value = false;
+      if (user.value && !pingInterval) startPing();
     }
   }
 
+  let pingInterval: number | null = null;
+  function startPing() {
+    import("@/lib/api").then(({ authPing }) => {
+      authPing(); // ping immediately
+      pingInterval = window.setInterval(authPing, 30000);
+    });
+  }
+  function stopPing() {
+    if (pingInterval) {
+      clearInterval(pingInterval);
+      pingInterval = null;
+    }
+  }
+
+
   function init() {
     if (!initialized) {
       initialized = true;

+ 1 - 1
src/views/PrivacyPolicy.vue

@@ -12,7 +12,7 @@
 
       <div class="space-y-10">
         <section 
-          v-for="(section, idx) in tm('privacy.sections')" 
+          v-for="(section, idx) in (tm('privacy.sections') as any)" 
           :key="idx"
           class="animate-slide-up"
           :style="{ animationDelay: `${idx * 100}ms` }"