Bläddra i källkod

Admin enhancements: portfolio management, photo deletion, security fixes, performance optimizations (hash caching & browser cache), and SEO improvements (meta tags, sitemap, robots)

unknown 6 dagar sedan
förälder
incheckning
16bbb9645a
48 ändrade filer med 3120 tillägg och 453 borttagningar
  1. 27 0
      backend/add_can_chat.py
  2. 34 0
      backend/add_ua_cols.py
  3. 33 0
      backend/alter_db_params.py
  4. 36 0
      backend/alter_db_strict_materials.py
  5. 4 0
      backend/check_orders.py
  6. 4 0
      backend/check_schema.py
  7. 8 0
      backend/debug_db.py
  8. 35 0
      backend/debug_files.py
  9. 9 1
      backend/main.py
  10. 68 3
      backend/routers/auth.py
  11. 14 4
      backend/routers/blog.py
  12. 27 9
      backend/routers/catalog.py
  13. 19 1
      backend/routers/chat.py
  14. 14 2
      backend/routers/files.py
  15. 45 4
      backend/routers/orders.py
  16. 41 4
      backend/routers/portfolio.py
  17. 3 1
      backend/schema.sql
  18. 14 0
      backend/schemas.py
  19. 14 0
      backend/test_admin_api.py
  20. 22 0
      backend/test_admin_api_v2.py
  21. 10 0
      backend/test_query.py
  22. 27 0
      backend/test_users.py
  23. 17 3
      index.html
  24. 1 0
      package.json
  25. BIN
      public/favicon.png
  26. 6 0
      public/robots.txt
  27. 29 0
      public/sitemap.xml
  28. 25 1
      scripts/manage_locales.py
  29. 1 1
      src/components/Footer.vue
  30. 10 10
      src/components/Header.vue
  31. 49 9
      src/components/ModelUploadSection.vue
  32. 57 0
      src/lib/api.ts
  33. 196 10
      src/locales/en.json
  34. 198 12
      src/locales/me.json
  35. 189 3
      src/locales/ru.json
  36. 1002 188
      src/locales/translations.json
  37. 189 3
      src/locales/ua.json
  38. 537 120
      src/pages/Admin.vue
  39. 10 10
      src/pages/Auth.vue
  40. 3 3
      src/pages/Blog.vue
  41. 3 3
      src/pages/BlogPost.vue
  42. 5 3
      src/pages/NotFound.vue
  43. 26 14
      src/pages/Orders.vue
  44. 11 9
      src/pages/Portfolio.vue
  45. 2 2
      src/pages/Privacy.vue
  46. 21 15
      src/router/index.ts
  47. 24 4
      src/stores/auth.ts
  48. 1 1
      src/views/PrivacyPolicy.vue

+ 27 - 0
backend/add_can_chat.py

@@ -0,0 +1,27 @@
+import mysql.connector
+from db import DB_CONFIG
+
+def add_chat_flag():
+    try:
+        conn = mysql.connector.connect(**DB_CONFIG)
+        cursor = conn.cursor()
+        
+        query = "ALTER TABLE users ADD COLUMN can_chat BOOLEAN DEFAULT TRUE AFTER role"
+        try:
+            cursor.execute(query)
+            print(f"Executed: {query}")
+        except mysql.connector.Error as e:
+            print(f"Error: {e}")
+            
+        conn.commit()
+        print("Migration completed")
+        
+    except mysql.connector.Error as err:
+        print(f"Connection error: {err}")
+    finally:
+        if 'conn' in locals() and conn.is_connected():
+            cursor.close()
+            conn.close()
+
+if __name__ == "__main__":
+    add_chat_flag()

+ 34 - 0
backend/add_ua_cols.py

@@ -0,0 +1,34 @@
+import mysql.connector
+from db import DB_CONFIG
+
+def add_ua_columns():
+    try:
+        conn = mysql.connector.connect(**DB_CONFIG)
+        cursor = conn.cursor()
+        
+        queries = [
+            "ALTER TABLE materials ADD COLUMN name_ua VARCHAR(100) DEFAULT NULL AFTER name_ru",
+            "ALTER TABLE materials ADD COLUMN desc_ua TEXT DEFAULT NULL AFTER desc_ru",
+            "ALTER TABLE services ADD COLUMN name_ua VARCHAR(100) DEFAULT NULL AFTER name_ru",
+            "ALTER TABLE services ADD COLUMN desc_ua TEXT DEFAULT NULL AFTER desc_ru"
+        ]
+        
+        for q in queries:
+            try:
+                cursor.execute(q)
+                print(f"Executed: {q}")
+            except mysql.connector.Error as e:
+                print(f"Error executing {q}: {e}")
+                
+        conn.commit()
+        print("UA columns added successfully")
+        
+    except mysql.connector.Error as err:
+        print(f"Connection error: {err}")
+    finally:
+        if 'conn' in locals() and conn.is_connected():
+            cursor.close()
+            conn.close()
+
+if __name__ == "__main__":
+    add_ua_columns()

+ 33 - 0
backend/alter_db_params.py

@@ -0,0 +1,33 @@
+import mysql.connector
+from db import DB_CONFIG
+
+def alter_database():
+    try:
+        conn = mysql.connector.connect(**DB_CONFIG)
+        cursor = conn.cursor()
+        
+        # Add original_params and color_name columns
+        alter_queries = [
+            "ALTER TABLE orders ADD COLUMN original_params JSON DEFAULT NULL AFTER updated_at",
+            "ALTER TABLE orders ADD COLUMN color_name VARCHAR(100) DEFAULT NULL AFTER material_price"
+        ]
+        
+        for query in alter_queries:
+            try:
+                cursor.execute(query)
+                print(f"Executed: {query}")
+            except mysql.connector.Error as e:
+                print(f"Command error: {e}")
+                
+        conn.commit()
+        print("Database migration completed successfully")
+        
+    except mysql.connector.Error as err:
+        print(f"Error connecting to MySQL: {err}")
+    finally:
+        if 'conn' in locals() and conn.is_connected():
+            cursor.close()
+            conn.close()
+
+if __name__ == "__main__":
+    alter_database()

+ 36 - 0
backend/alter_db_strict_materials.py

@@ -0,0 +1,36 @@
+import mysql.connector
+from db import DB_CONFIG
+
+def alter_database():
+    try:
+        conn = mysql.connector.connect(**DB_CONFIG)
+        cursor = conn.cursor()
+        
+        alter_queries = [
+            # Add available_colors to materials
+            "ALTER TABLE materials ADD COLUMN available_colors JSON DEFAULT NULL AFTER price_per_cm3",
+            
+            # Add material_id to orders to link strictly to reference
+            "ALTER TABLE orders ADD COLUMN material_id INT DEFAULT NULL AFTER user_id",
+            "ALTER TABLE orders ADD CONSTRAINT fk_order_material FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE SET NULL"
+        ]
+        
+        for query in alter_queries:
+            try:
+                cursor.execute(query)
+                print(f"Executed: {query}")
+            except mysql.connector.Error as e:
+                print(f"Command error: {e}")
+                
+        conn.commit()
+        print("Database migration completed successfully")
+        
+    except mysql.connector.Error as err:
+        print(f"Error connecting to MySQL: {err}")
+    finally:
+        if 'conn' in locals() and conn.is_connected():
+            cursor.close()
+            conn.close()
+
+if __name__ == "__main__":
+    alter_database()

+ 4 - 0
backend/check_orders.py

@@ -0,0 +1,4 @@
+import db
+res = db.execute_query("SELECT id, email, status, created_at FROM orders ORDER BY created_at DESC LIMIT 5")
+for r in res:
+    print(r)

+ 4 - 0
backend/check_schema.py

@@ -0,0 +1,4 @@
+import db
+res = db.execute_query("DESCRIBE order_files")
+for r in res:
+    print(r)

+ 8 - 0
backend/debug_db.py

@@ -0,0 +1,8 @@
+import db
+res = db.execute_query("SELECT * FROM order_photos")
+print(f"Count: {len(res)}")
+for r in res:
+    print(r)
+print("---")
+res2 = db.execute_query("SELECT * FROM orders")
+print(f"Orders count: {len(res2)}")

+ 35 - 0
backend/debug_files.py

@@ -0,0 +1,35 @@
+import os
+import json
+import sys
+
+# Add current directory to path
+sys.path.append(os.getcwd())
+
+import db
+
+def test():
+    query = """
+    SELECT o.id, 
+           GROUP_CONCAT(JSON_OBJECT('id', f.id, 'filename', f.filename, 'file_path', f.file_path, 'file_size', f.file_size, 'quantity', f.quantity, 'preview_path', f.preview_path, 'print_time', f.print_time, 'filament_g', f.filament_g)) as files
+    FROM orders o
+    LEFT JOIN order_files f ON o.id = f.order_id
+    GROUP BY o.id
+    ORDER BY o.created_at DESC
+    LIMIT 2
+    """
+    results = db.execute_query(query)
+    for row in results:
+        print(f"Order ID: {row['id']}")
+        if row['files']:
+            try:
+                files_json = '[' + row['files'] + ']'
+                files = json.loads(files_json)
+                for f in files:
+                    print(f"  File: {f.get('filename')}, ID: {f.get('id')}")
+            except Exception as e:
+                print(f"  Error parsing JSON: {e}")
+        else:
+            print("  No files")
+
+if __name__ == "__main__":
+    test()

+ 9 - 1
backend/main.py

@@ -67,7 +67,15 @@ app.include_router(blog.router)
 # Mount Static Files
 if not os.path.exists("uploads"):
     os.makedirs("uploads")
-app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
+# Mount static files for uploads and previews with caching
+app.mount("/uploads", StaticFiles(directory="uploads", html=False), name="uploads")
+
+@app.middleware("http")
+async def add_cache_control_header(request, call_next):
+    response = await call_next(request)
+    if request.url.path.startswith("/uploads"):
+        response.headers["Cache-Control"] = "public, max-age=604800, immutable"
+    return response
 
 if __name__ == "__main__":
     import uvicorn

+ 68 - 3
backend/routers/auth.py

@@ -1,4 +1,5 @@
 from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket, WebSocketDisconnect, Query
+from typing import Optional, List
 from services.global_manager import global_manager
 import auth_utils
 import db
@@ -26,7 +27,7 @@ async def register(request: Request, user: schemas.UserCreate, lang: str = "en")
     params = (user.email, hashed_password, user.first_name, user.last_name, user.phone, user.shipping_address, user.preferred_language, 'user', ip_address)
     
     user_id = db.execute_commit(query, params)
-    new_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,))
+    new_user = db.execute_query("SELECT id, email, first_name, last_name, phone, shipping_address, preferred_language, role, can_chat, ip_address, created_at FROM users WHERE id = %s", (user_id,))
     return new_user[0]
 
 @router.post("/login", response_model=schemas.Token)
@@ -88,7 +89,7 @@ async def reset_password(request: schemas.ResetPassword):
 async def get_me(token: str = Depends(auth_utils.oauth2_scheme)):
     payload = auth_utils.decode_token(token)
     if not payload: raise HTTPException(status_code=401, detail="Invalid token")
-    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", (payload.get("id"),))
+    user = db.execute_query("SELECT id, email, first_name, last_name, phone, shipping_address, preferred_language, role, can_chat, ip_address, created_at FROM users WHERE id = %s", (payload.get("id"),))
     if not user: raise HTTPException(status_code=404, detail="User not found")
     return user[0]
 
@@ -106,7 +107,71 @@ async def update_me(data: schemas.UserUpdate, token: str = Depends(auth_utils.oa
         query = f"UPDATE users SET {', '.join(update_fields)} WHERE id = %s"
         params.append(user_id)
         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,))
+    user = db.execute_query("SELECT id, email, first_name, last_name, phone, shipping_address, preferred_language, role, can_chat, ip_address, created_at FROM users WHERE id = %s", (user_id,))
+    return user[0]
+
+@router.get("/admin/users")
+async def admin_get_users(page: int = 1, size: int = 50, search: Optional[str] = None, 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")
+    
+    offset = (page - 1) * size
+    base_query = "SELECT id, email, first_name, last_name, phone, role, can_chat, ip_address, created_at FROM users"
+    count_query = "SELECT COUNT(*) as total FROM users"
+    params = []
+    if search and search.strip():
+        where_clause = " WHERE email LIKE %s OR first_name LIKE %s OR last_name LIKE %s OR phone LIKE %s"
+        base_query += where_clause
+        count_query += where_clause
+        pattern = f"%{search.strip()}%"
+        params = [pattern] * 4
+        
+    base_query += " ORDER BY id DESC LIMIT %s OFFSET %s"
+    
+    users = db.execute_query(base_query, tuple(params + [size, offset]))
+    total = db.execute_query(count_query, tuple(params))[0]['total']
+    
+    return {"users": users, "total": total, "page": page, "size": size}
+
+@router.post("/admin/users", response_model=schemas.UserResponse)
+async def admin_create_user(data: schemas.UserCreate, 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")
+    
+    existing_user = db.execute_query("SELECT id FROM users WHERE email = %s", (data.email,))
+    if existing_user:
+        raise HTTPException(status_code=400, detail="Email already registered")
+        
+    hashed_password = auth_utils.get_password_hash(data.password)
+    user_id = db.execute_commit(
+        "INSERT INTO users (email, password_hash, first_name, last_name, phone, role, can_chat) VALUES (%s, %s, %s, %s, %s, %s, %s)",
+        (data.email, hashed_password, data.first_name, data.last_name, data.phone, 'user', True)
+    )
+    
+    user = db.execute_query("SELECT id, email, first_name, last_name, phone, role, can_chat, created_at FROM users WHERE id = %s", (user_id,))
+    return user[0]
+
+@router.patch("/users/{target_id}/admin", response_model=schemas.UserResponse)
+async def admin_update_user(target_id: int, data: schemas.UserUpdate, 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")
+    
+    update_fields = []
+    params = []
+    for field, value in data.dict(exclude_unset=True).items():
+        update_fields.append(f"{field} = %s")
+        params.append(value)
+    
+    if update_fields:
+        query = f"UPDATE users SET {', '.join(update_fields)} WHERE id = %s"
+        params.append(target_id)
+        db.execute_commit(query, tuple(params))
+        
+    user = db.execute_query("SELECT id, email, first_name, last_name, phone, shipping_address, preferred_language, role, can_chat, ip_address, created_at FROM users WHERE id = %s", (target_id,))
+    if not user: raise HTTPException(status_code=404, detail="User not found")
     return user[0]
 
 @router.websocket("/ws/global")

+ 14 - 4
backend/routers/blog.py

@@ -1,9 +1,10 @@
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Request
 from typing import List, Optional
 from pydantic import BaseModel
 from datetime import datetime
 import db
 import mysql.connector
+import auth_utils
 
 router = APIRouter(prefix="/blog", tags=["blog"])
 
@@ -61,7 +62,10 @@ async def get_post(id_or_slug: str):
     raise HTTPException(status_code=404, detail="Post not found")
 
 @router.post("/", response_model=Post)
-async def create_post(post: PostCreate):
+async def create_post(post: PostCreate, 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")
     query = """
     INSERT INTO posts (
         slug, title_en, title_me, title_ru, title_ua,
@@ -85,7 +89,10 @@ async def create_post(post: PostCreate):
         raise HTTPException(status_code=400, detail=str(err))
 
 @router.put("/{post_id}", response_model=Post)
-async def update_post(post_id: int, post: PostUpdate):
+async def update_post(post_id: int, post: PostUpdate, 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")
     query = """
     UPDATE posts SET 
         slug=%s, title_en=%s, title_me=%s, title_ru=%s, title_ua=%s,
@@ -108,7 +115,10 @@ async def update_post(post_id: int, post: PostUpdate):
     return res[0]
 
 @router.delete("/{post_id}")
-async def delete_post(post_id: int):
+async def delete_post(post_id: int, 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")
     # We don't easily get rowcount from execute_commit as I wrote it, 
     # but we can check existence first or modify execute_commit.
     # For now, let's just execute it.

+ 27 - 9
backend/routers/catalog.py

@@ -1,14 +1,20 @@
 from fastapi import APIRouter, Depends, HTTPException
-from typing import List
+from typing import List, Optional
 import db
 import schemas
 import auth_utils
+import json
 
 router = APIRouter(tags=["catalog"])
 
 @router.get("/materials", response_model=List[schemas.MaterialBase])
 async def get_materials():
-    return db.execute_query("SELECT * FROM materials WHERE is_active = TRUE")
+    rows = db.execute_query("SELECT * FROM materials WHERE is_active = TRUE")
+    for r in rows:
+        if r.get('available_colors') and isinstance(r['available_colors'], str):
+            try: r['available_colors'] = json.loads(r['available_colors'])
+            except: r['available_colors'] = []
+    return rows
 
 @router.get("/services", response_model=List[schemas.ServiceBase])
 async def get_services():
@@ -19,15 +25,21 @@ async def admin_get_materials(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")
-    return db.execute_query("SELECT * FROM materials ORDER BY id DESC")
+    rows = db.execute_query("SELECT * FROM materials ORDER BY id DESC")
+    for r in rows:
+        if r.get('available_colors') and isinstance(r['available_colors'], str):
+            try: r['available_colors'] = json.loads(r['available_colors'])
+            except: r['available_colors'] = []
+    return rows
 
 @router.post("/admin/materials")
 async def admin_create_material(data: schemas.MaterialCreate, 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")
-    query = "INSERT INTO materials (name_en, name_ru, name_me, desc_en, desc_ru, desc_me, price_per_cm3, is_active) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)"
-    params = (data.name_en, data.name_ru, data.name_me, data.desc_en, data.desc_ru, data.desc_me, data.price_per_cm3, data.is_active)
+    colors_json = json.dumps(data.available_colors) if data.available_colors else None
+    query = "INSERT INTO materials (name_en, name_ru, name_ua, name_me, desc_en, desc_ru, desc_ua, desc_me, price_per_cm3, available_colors, is_active) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
+    params = (data.name_en, data.name_ru, data.name_ua, data.name_me, data.desc_en, data.desc_ru, data.desc_ua, data.desc_me, data.price_per_cm3, colors_json, data.is_active)
     mat_id = db.execute_commit(query, params)
     return {"id": mat_id}
 
@@ -40,7 +52,10 @@ async def admin_update_material(mat_id: int, data: schemas.MaterialUpdate, token
     params = []
     for field, value in data.dict(exclude_unset=True).items():
         update_fields.append(f"{field} = %s")
-        params.append(value)
+        if isinstance(value, list):
+            params.append(json.dumps(value))
+        else:
+            params.append(value)
     if update_fields:
         query = f"UPDATE materials SET {', '.join(update_fields)} WHERE id = %s"
         params.append(mat_id)
@@ -67,8 +82,8 @@ async def admin_create_service(data: schemas.ServiceCreate, token: str = Depends
     payload = auth_utils.decode_token(token)
     if not payload or payload.get("role") != 'admin':
         raise HTTPException(status_code=403, detail="Admin role required")
-    query = "INSERT INTO services (name_en, name_ru, name_me, desc_en, desc_ru, desc_me, tech_type, is_active) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)"
-    srv_id = db.execute_commit(query, (data.name_en, data.name_ru, data.name_me, data.desc_en, data.desc_ru, data.desc_me, data.tech_type, data.is_active))
+    query = "INSERT INTO services (name_en, name_ru, name_ua, name_me, desc_en, desc_ru, desc_ua, desc_me, tech_type, is_active) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
+    srv_id = db.execute_commit(query, (data.name_en, data.name_ru, data.name_ua, data.name_me, data.desc_en, data.desc_ru, data.desc_ua, data.desc_me, data.tech_type, data.is_active))
     return {"id": srv_id}
 
 @router.patch("/admin/services/{srv_id}")
@@ -80,7 +95,10 @@ async def admin_update_service(srv_id: int, data: schemas.ServiceUpdate, token:
     params = []
     for field, value in data.dict(exclude_unset=True).items():
         update_fields.append(f"{field} = %s")
-        params.append(value)
+        if isinstance(value, list):
+            params.append(json.dumps(value))
+        else:
+            params.append(value)
     if update_fields:
         query = f"UPDATE services SET {', '.join(update_fields)} WHERE id = %s"
         params.append(srv_id)

+ 19 - 1
backend/routers/chat.py

@@ -14,9 +14,15 @@ async def get_order_messages(order_id: int, token: str = Depends(auth_utils.oaut
     if not payload: raise HTTPException(status_code=401, detail="Invalid token")
     role = payload.get("role")
     user_id = payload.get("id")
+    # Fetch user chat status
+    user_info = db.execute_query("SELECT can_chat FROM users WHERE id = %s", (user_id,))
+    can_chat = user_info[0]['can_chat'] if user_info else False
+
     order = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
     if not order: raise HTTPException(status_code=404, detail="Order not found")
-    if role != 'admin' and order[0]['user_id'] != user_id: raise HTTPException(status_code=403, detail="Not authorized")
+    if role != 'admin':
+        if order[0]['user_id'] != user_id: raise HTTPException(status_code=403, detail="Not authorized")
+        if not can_chat: raise HTTPException(status_code=403, detail="Chat access disabled for your account")
     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()
@@ -40,6 +46,12 @@ async def post_order_message(order_id: int, data: schemas.MessageCreate, token:
     role = payload.get("role")
     user_id = payload.get("id")
     is_admin = (role == 'admin')
+    
+    if not is_admin:
+        user_info = db.execute_query("SELECT can_chat FROM users WHERE id = %s", (user_id,))
+        if not user_info or not user_info[0]['can_chat']:
+            raise HTTPException(status_code=403, detail="Chat access disabled")
+
     order = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
     if not order: raise HTTPException(status_code=404, detail="Order not found")
     if not is_admin and order[0]['user_id'] != user_id: raise HTTPException(status_code=403, detail="Not authorized")
@@ -61,6 +73,12 @@ async def ws_chat(websocket: WebSocket, order_id: int, token: str = Query(...)):
         return
     role = payload.get("role")
     user_id = payload.get("id")
+    if role != 'admin':
+        user_info = db.execute_query("SELECT can_chat FROM users WHERE id = %s", (user_id,))
+        if not user_info or not user_info[0]['can_chat']:
+            await websocket.close(code=4003)
+            return
+
     order = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
     if not order:
         await websocket.close(code=4004)

+ 14 - 2
backend/routers/files.py

@@ -26,9 +26,21 @@ async def upload_files(files: List[UploadFile] = File(...)):
                 sha256_hash.update(chunk)
                 buffer.write(chunk)
         
+        file_hash = sha256_hash.hexdigest()
+        
+        # --- CACHE CHECK (Hash based) ---
         filament_g = None
         print_time = None
-        if config.SYNC_SLICING_ON_UPLOAD and file_ext.lower() == ".stl":
+        cached_record = db.execute_query(
+            "SELECT filament_g, print_time FROM order_files WHERE file_hash = %s AND print_time IS NOT NULL LIMIT 1",
+            (file_hash,)
+        )
+        if cached_record:
+            filament_g = cached_record[0]['filament_g']
+            print_time = cached_record[0]['print_time']
+        
+        # Only slice if not cached
+        if not print_time and config.SYNC_SLICING_ON_UPLOAD and file_ext.lower() == ".stl":
             import slicer_utils
             result = slicer_utils.slice_model(file_path)
             if result and result.get('success'):
@@ -44,7 +56,7 @@ async def upload_files(files: List[UploadFile] = File(...)):
             preview_utils.generate_stl_preview(file_path, preview_path)
 
         query = "INSERT INTO order_files (order_id, filename, file_path, file_size, quantity, file_hash, print_time, filament_g, preview_path) VALUES (NULL, %s, %s, %s, 1, %s, %s, %s, %s)"
-        f_id = db.execute_commit(query, (file.filename, db_file_path, file.size, sha256_hash.hexdigest(), print_time, filament_g, db_preview_path))
+        f_id = db.execute_commit(query, (file.filename, db_file_path, file.size, file_hash, print_time, filament_g, db_preview_path))
         
         uploaded_data.append({
             "id": f_id, "filename": file.filename, "size": file.size,

+ 45 - 4
backend/routers/orders.py

@@ -34,6 +34,7 @@ async def create_order(
     file_ids: str = Form("[]"),
     file_quantities: str = Form("[]"),
     quantity: int = Form(1),
+    color_name: Optional[str] = Form(None),
     token: str = Depends(auth_utils.oauth2_scheme_optional)
 ):
     user_id = None
@@ -66,11 +67,27 @@ async def create_order(
 
     estimated_price = pricing.calculate_estimated_price(material_id, file_sizes, parsed_quantities if parsed_quantities else None)
 
+    # Snapshoting initial parameters
+    original_params = json.dumps({
+        "material_name": mat_name,
+        "material_price": float(mat_price) if mat_price is not None else 0.0,
+        "estimated_price": float(estimated_price) if estimated_price is not None else 0.0,
+        "quantity": quantity,
+        "color_name": color_name,
+        "notes": notes,
+        "first_name": first_name,
+        "last_name": last_name,
+        "phone": phone,
+        "email": email,
+        "shipping_address": shipping_address,
+        "model_link": model_link
+    })
+
     order_query = """
-    INSERT INTO orders (user_id, first_name, last_name, phone, email, shipping_address, model_link, status, allow_portfolio, estimated_price, material_name, material_price, quantity, notes)
-    VALUES (%s, %s, %s, %s, %s, %s, %s, 'pending', %s, %s, %s, %s, %s, %s)
+    INSERT INTO orders (user_id, material_id, first_name, last_name, phone, email, shipping_address, model_link, status, allow_portfolio, estimated_price, material_name, material_price, color_name, quantity, notes, original_params)
+    VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', %s, %s, %s, %s, %s, %s, %s, %s)
     """
-    order_params = (user_id, first_name, last_name, phone, email, shipping_address, model_link, allow_portfolio, estimated_price, mat_name, mat_price, quantity, notes)
+    order_params = (user_id, material_id, first_name, last_name, phone, email, shipping_address, model_link, allow_portfolio, estimated_price, mat_name, mat_price, color_name, quantity, notes, original_params)
     
     try:
         order_insert_id = db.execute_commit(order_query, order_params)
@@ -135,10 +152,11 @@ async def get_admin_orders(token: str = Depends(auth_utils.oauth2_scheme)):
         raise HTTPException(status_code=403, detail="Admin role required")
     
     query = """
-    SELECT o.*, 
+    SELECT o.*, u.can_chat,
            (SELECT count(*) FROM order_messages om WHERE om.order_id = o.id AND om.is_from_admin = FALSE AND om.is_read = FALSE) as unread_count,
            GROUP_CONCAT(JSON_OBJECT('file_id', f.id, 'filename', f.filename, 'file_path', f.file_path, 'file_size', f.file_size, 'quantity', f.quantity, 'preview_path', f.preview_path, 'print_time', f.print_time, 'filament_g', f.filament_g)) as files
     FROM orders o
+    LEFT JOIN users u ON o.user_id = u.id
     LEFT JOIN order_files f ON o.id = f.order_id
     GROUP BY o.id
     ORDER BY o.created_at DESC
@@ -194,12 +212,35 @@ async def update_order_admin(
                 except Exception as e:
                     print(f"Failed to generate invoice PDF: {e}")
 
+    if data.status:
         update_fields.append("status = %s")
         params.append(data.status)
     if data.total_price is not None:
         update_fields.append("total_price = %s")
         params.append(data.total_price)
     
+    if data.material_id is not None:
+        update_fields.append("material_id = %s")
+        params.append(data.material_id)
+        # Also update snapshot names and prices from handbook
+        mat_info = db.execute_query("SELECT name_en, price_per_cm3 FROM materials WHERE id = %s", (data.material_id,))
+        if mat_info:
+            update_fields.append("material_name = %s")
+            params.append(mat_info[0]['name_en'])
+            update_fields.append("material_price = %s")
+            params.append(mat_info[0]['price_per_cm3'])
+    elif data.material_name is not None:
+        update_fields.append("material_name = %s")
+        params.append(data.material_name)
+    
+    if data.color_name is not None:
+        update_fields.append("color_name = %s")
+        params.append(data.color_name)
+    
+    if data.quantity is not None:
+        update_fields.append("quantity = %s")
+        params.append(data.quantity)
+    
     if update_fields:
         query = f"UPDATE orders SET {', '.join(update_fields)} WHERE id = %s"
         params.append(order_id)

+ 41 - 4
backend/routers/portfolio.py

@@ -20,6 +20,20 @@ async def get_public_portfolio():
     """
     return db.execute_query(query)
 
+@router.get("/admin/all-photos")
+async def admin_get_all_photos(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")
+    query = """
+    SELECT p.id, p.file_path, p.is_public, p.order_id, o.allow_portfolio, 
+           o.first_name, o.last_name, COALESCE(o.material_name, 'Manual') as material_name
+    FROM order_photos p
+    JOIN orders o ON p.order_id = o.id
+    ORDER BY p.created_at DESC
+    """
+    return db.execute_query(query)
+
 @router.post("/admin/orders/{order_id}/photos")
 async def admin_upload_order_photo(
     order_id: int, 
@@ -36,12 +50,15 @@ async def admin_upload_order_photo(
          raise HTTPException(status_code=400, detail="Cannot make public: User did not consent to portfolio usage")
     if not file.filename: raise HTTPException(status_code=400, detail="Invalid file")
     unique_filename = f"{uuid.uuid4()}{os.path.splitext(file.filename)[1]}"
-    file_path = os.path.join(config.UPLOAD_DIR, unique_filename).replace("\\", "/")
-    with open(file_path, "wb") as buffer:
+    disk_path = os.path.join(config.UPLOAD_DIR, unique_filename)
+    db_file_path = f"uploads/{unique_filename}"
+    
+    with open(disk_path, "wb") as buffer:
         shutil.copyfileobj(file.file, buffer)
+        
     query = "INSERT INTO order_photos (order_id, file_path, is_public) VALUES (%s, %s, %s)"
-    photo_id = db.execute_commit(query, (order_id, file_path, is_public))
-    return {"id": photo_id, "file_path": file_path, "is_public": is_public}
+    photo_id = db.execute_commit(query, (order_id, db_file_path, is_public))
+    return {"id": photo_id, "file_path": db_file_path, "is_public": is_public}
 
 @router.patch("/admin/photos/{photo_id}")
 async def admin_update_photo_status(photo_id: int, data: schemas.PhotoUpdate, token: str = Depends(auth_utils.oauth2_scheme)):
@@ -55,3 +72,23 @@ async def admin_update_photo_status(photo_id: int, data: schemas.PhotoUpdate, to
         raise HTTPException(status_code=400, detail="Cannot make public: User did not consent to portfolio usage")
     db.execute_commit("UPDATE order_photos SET is_public = %s WHERE id = %s", (data.is_public, photo_id))
     return {"id": photo_id, "is_public": data.is_public}
+
+@router.delete("/admin/photos/{photo_id}")
+async def admin_delete_photo(photo_id: int, 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")
+    
+    photo = db.execute_query("SELECT file_path FROM order_photos WHERE id = %s", (photo_id,))
+    if not photo:
+        raise HTTPException(status_code=404, detail="Photo not found")
+        
+    try:
+        path = os.path.join(config.BASE_DIR, photo[0]['file_path'])
+        if os.path.exists(path):
+            os.remove(path)
+    except Exception as e:
+        print(f"Error deleting photo file: {e}")
+        
+    db.execute_commit("DELETE FROM order_photos WHERE id = %s", (photo_id,))
+    return {"id": photo_id, "status": "deleted"}

+ 3 - 1
backend/schema.sql

@@ -91,9 +91,11 @@ CREATE TABLE IF NOT EXISTS order_files (
     filament_g FLOAT,
     preview_path VARCHAR(512),
     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-    FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
+    FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
+    INDEX (file_hash)
 );
 
+
 -- Order Photos table (for reports and portfolio)
 CREATE TABLE IF NOT EXISTS order_photos (
     id INT AUTO_INCREMENT PRIMARY KEY,

+ 14 - 0
backend/schemas.py

@@ -14,6 +14,7 @@ class MaterialBase(BaseModel):
     desc_ua: Optional[str] = None
     desc_me: Optional[str] = None
     price_per_cm3: float
+    available_colors: Optional[List[str]] = None
     is_active: bool
 
 class ServiceBase(BaseModel):
@@ -40,6 +41,7 @@ class MaterialCreate(BaseModel):
     desc_ua: str
     desc_me: str
     price_per_cm3: float
+    available_colors: Optional[List[str]] = None
     is_active: bool = True
 
 class MaterialUpdate(BaseModel):
@@ -52,6 +54,7 @@ class MaterialUpdate(BaseModel):
     desc_ua: Optional[str] = None
     desc_me: Optional[str] = None
     price_per_cm3: Optional[float] = None
+    available_colors: Optional[List[str]] = None
     is_active: Optional[bool] = None
 
 class ServiceCreate(BaseModel):
@@ -92,6 +95,8 @@ class OrderCreate(BaseModel):
     model_link: Optional[str] = None
     quantity: int = Field(1, ge=1)
     notes: Optional[str] = None
+    material_id: int
+    color_name: Optional[str] = None
 
 # User models
 class UserCreate(BaseModel):
@@ -109,6 +114,7 @@ class UserUpdate(BaseModel):
     phone: Optional[str] = None
     shipping_address: Optional[str] = None
     preferred_language: Optional[str] = None
+    can_chat: Optional[bool] = None
 
 class UserLogin(BaseModel):
     email: EmailStr
@@ -123,6 +129,7 @@ class UserResponse(BaseModel):
     shipping_address: Optional[str] = None
     preferred_language: str = "en"
     role: str
+    can_chat: bool
     ip_address: Optional[str] = None
     created_at: datetime
     
@@ -131,6 +138,10 @@ class UserResponse(BaseModel):
 class AdminOrderUpdate(BaseModel):
     status: Optional[str] = None
     total_price: Optional[float] = None
+    material_id: Optional[int] = None
+    material_name: Optional[str] = None
+    color_name: Optional[str] = None
+    quantity: Optional[int] = None
     send_notification: Optional[bool] = False
 
 class EstimateRequest(BaseModel):
@@ -168,6 +179,9 @@ class OrderResponse(OrderCreate):
     estimated_price: Optional[float] = None
     material_name: Optional[str] = None
     material_price: Optional[float] = None
+    material_id: Optional[int] = None
+    color_name: Optional[str] = None
+    original_params: Optional[str] = None
     created_at: datetime
     
     model_config = ConfigDict(from_attributes=True)

+ 14 - 0
backend/test_admin_api.py

@@ -0,0 +1,14 @@
+import auth_utils
+import db
+
+# Get admin user
+res = db.execute_query("SELECT id, email, role FROM users WHERE email='admin@radionica3d.com'")
+admin = res[0]
+token = auth_utils.create_access_token({"id": admin['id'], "role": admin['role'], "email": admin['email']})
+
+print(f"Token: {token}")
+
+import requests
+resp = requests.get("http://localhost:8000/orders/admin/list?lang=en", headers={"Authorization": f"Bearer {token}"})
+print(f"Status: {resp.status_code}")
+print(f"Body: {resp.text}")

+ 22 - 0
backend/test_admin_api_v2.py

@@ -0,0 +1,22 @@
+import auth_utils
+import db
+import requests
+import json
+
+try:
+    # Get admin user
+    res = db.execute_query("SELECT id, email, role FROM users WHERE email='admin@radionica3d.com'")
+    if not res:
+        with open("api_test_out.txt", "w") as f: f.write("Admin not found")
+        exit()
+    admin = res[0]
+    token = auth_utils.create_access_token({"id": admin['id'], "role": admin['role'], "email": admin['email']})
+
+    resp = requests.get("http://localhost:8000/orders/admin/list?lang=en", headers={"Authorization": f"Bearer {token}"})
+    
+    with open("api_test_out.txt", "w") as f:
+        f.write(f"Status: {resp.status_code}\n")
+        f.write(f"Body: {resp.text}\n")
+except Exception as e:
+    with open("api_test_out.txt", "w") as f:
+        f.write(f"Error: {str(e)}\n")

+ 10 - 0
backend/test_query.py

@@ -0,0 +1,10 @@
+import db
+query = """
+SELECT p.id, p.file_path, COALESCE(o.material_name, 'Showcase') as material_name, p.order_id
+FROM order_photos p
+LEFT JOIN orders o ON p.order_id = o.id
+WHERE p.is_public = TRUE AND (o.id IS NULL OR o.allow_portfolio = TRUE)
+ORDER BY p.created_at DESC
+"""
+res = db.execute_query(query)
+print(res)

+ 27 - 0
backend/test_users.py

@@ -0,0 +1,27 @@
+import db
+import json
+
+def test_get_users():
+    search = ""
+    page = 1
+    size = 50
+    offset = (page - 1) * size
+    
+    base_query = "SELECT id, email, first_name, last_name, phone, role, can_chat, ip_address, created_at FROM users"
+    count_query = "SELECT COUNT(*) as total FROM users"
+    params = []
+    
+    if search and search.strip():
+        # logic...
+        pass
+        
+    base_query += " ORDER BY id DESC LIMIT %s OFFSET %s"
+    
+    users = db.execute_query(base_query, tuple(params + [size, offset]))
+    total = db.execute_query(count_query, tuple(params))[0]['total']
+    
+    print(f"Total: {total}")
+    print(f"Users: {json.dumps(users, indent=2, default=str)}")
+
+if __name__ == "__main__":
+    test_get_users()

+ 17 - 3
index.html

@@ -2,10 +2,24 @@
 <html lang="en">
   <head>
     <meta charset="UTF-8" />
-    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <link rel="icon" type="image/png" href="/favicon.png" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Radionica 3D | Modern 3D Printing Services</title>
-    <meta name="description" content="Professional 3D printing and rapid prototyping services with instant quotes and high-quality materials." />
+    <title>Radionica 3D | Professional 3D Printing in Montenegro</title>
+    <meta name="description" content="Professional 3D printing and rapid prototyping services in Montenegro. Instant quotes, industrial materials (PLA, ABS, PETG, Resin), and high-precision results." />
+    
+    <!-- Open Graph / Facebook -->
+    <meta property="og:type" content="website" />
+    <meta property="og:url" content="https://radionica3d.com/" />
+    <meta property="og:title" content="Radionica 3D | Professional 3D Printing in Montenegro" />
+    <meta property="og:description" content="Instant 3D printing quotes and high-quality prototyping. Fast shipping and industrial materials." />
+    <meta property="og:image" content="https://radionica3d.com/og-image.png" />
+
+    <!-- Twitter -->
+    <meta property="twitter:card" content="summary_large_image" />
+    <meta property="twitter:url" content="https://radionica3d.com/" />
+    <meta property="twitter:title" content="Radionica 3D | Professional 3D Printing in Montenegro" />
+    <meta property="twitter:description" content="Instant 3D printing quotes and high-quality prototyping." />
+    <meta property="twitter:image" content="https://radionica3d.com/og-image.png" />
   </head>
   <body>
     <div id="root"></div>

+ 1 - 0
package.json

@@ -8,6 +8,7 @@
     "build": "npm run i18n:generate && vue-tsc && vite build",
     "i18n:generate": "python scripts/manage_locales.py split",
     "i18n:merge": "python scripts/manage_locales.py merge",
+    "i18n:check": "python scripts/manage_locales.py missing",
     "lint": "eslint . --ext ts,vue --report-unused-disable-directives --max-warnings 0",
     "preview": "vite preview",
     "test": "vitest run"

BIN
public/favicon.png


+ 6 - 0
public/robots.txt

@@ -0,0 +1,6 @@
+User-agent: *
+Allow: /
+Disallow: /admin
+Disallow: /orders
+
+Sitemap: https://radionica3d.com/sitemap.xml

+ 29 - 0
public/sitemap.xml

@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+  <url>
+    <loc>https://radionica3d.com/</loc>
+    <lastmod>2026-01-01</lastmod>
+    <changefreq>weekly</changefreq>
+    <priority>1.0</priority>
+  </url>
+  <url>
+    <loc>https://radionica3d.com/portfolio</loc>
+    <lastmod>2026-01-01</lastmod>
+    <changefreq>daily</changefreq>
+    <priority>0.8</priority>
+  </url>
+  <url>
+    <loc>https://radionica3d.com/blog</loc>
+    <lastmod>2026-01-01</lastmod>
+    <changefreq>weekly</changefreq>
+    <priority>0.7</priority>
+  </url>
+  <url>
+    <loc>https://radionica3d.com/about</loc>
+    <priority>0.5</priority>
+  </url>
+  <url>
+    <loc>https://radionica3d.com/contact</loc>
+    <priority>0.5</priority>
+  </url>
+</urlset>

+ 25 - 1
scripts/manage_locales.py

@@ -94,9 +94,31 @@ def split():
             json.dump(lang_data, f, ensure_ascii=False, indent=2)
         print(f"Generated {output_path}")
 
+def list_missing():
+    """List all keys with missing translations in translations.json"""
+    if not MASTER_FILE.exists():
+        print(f"Error: {MASTER_FILE} not found")
+        return
+
+    with open(MASTER_FILE, "r", encoding="utf-8") as f:
+        master_data = json.load(f)
+
+    flat_keys = get_nested_keys(master_data)
+    missing_found = False
+
+    for key, translations in sorted(flat_keys.items()):
+        if isinstance(translations, dict):
+            missing_langs = [lang for lang in LANGUAGES if not translations.get(lang)]
+            if missing_langs:
+                print(f"Key: {key} - Missing: {', '.join(missing_langs)}")
+                missing_found = True
+    
+    if not missing_found:
+        print("All translations are complete!")
+
 if __name__ == "__main__":
     if len(sys.argv) < 2:
-        print("Usage: python manage_locales.py [merge|split]")
+        print("Usage: python manage_locales.py [merge|split|missing]")
         sys.exit(1)
     
     cmd = sys.argv[1].lower()
@@ -104,5 +126,7 @@ if __name__ == "__main__":
         merge()
     elif cmd == "split":
         split()
+    elif cmd == "missing":
+        list_missing()
     else:
         print(f"Unknown command: {cmd}")

+ 1 - 1
src/components/Footer.vue

@@ -11,7 +11,7 @@
               <Mail class="w-3.5 h-3.5" />hello@radionica3d.com
             </a>
             <div class="flex items-center gap-2 text-xs font-bold text-foreground/40">
-              <MapPin class="w-3.5 h-3.5" />Herceg Novi, Montenegro
+              <MapPin class="w-3.5 h-3.5" />{{ t("footer.location") }}
             </div>
           </div>
         </div>

+ 10 - 10
src/components/Header.vue

@@ -27,7 +27,7 @@
             class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary hover:bg-primary/10 rounded-lg transition-colors"
           >
             <LayoutPanelTop class="w-4 h-4" />
-            Admin
+            {{ t("nav.admin") }}
           </RouterLink>
 
           <!-- Unread Messages Badge -->
@@ -35,7 +35,7 @@
             v-if="isLoggedIn && authStore.unreadMessagesCount > 0"
             :to="isAdmin ? '/admin' : '/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"
+            :title="t('nav.unreadTooltip')"
           >
             <MessageSquare class="w-3.5 h-3.5 animate-pulse" />
             <span class="tabular-nums">{{ authStore.unreadMessagesCount }}</span>
@@ -47,13 +47,13 @@
             class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium hover:bg-secondary rounded-lg transition-colors"
           >
             <PackageCheck class="w-4 h-4" />
-            {{ t("nav.myOrders") || "My Orders" }}
+            {{ t("nav.myOrders") }}
           </RouterLink>
 
           <template v-if="isLoggedIn">
             <Button variant="ghost" size="sm" class="group" @click="handleLogout">
               <LogOut class="w-4 h-4 mr-2 group-hover:text-destructive transition-colors" />
-              {{ t("nav.logOut") || "Log Out" }}
+              {{ t("nav.logOut") }}
             </Button>
           </template>
           <template v-else>
@@ -106,7 +106,7 @@
             class="flex items-center gap-2 text-primary py-2"
             @click="mobileOpen = false"
           >
-            <LayoutPanelTop class="w-4 h-4" />Admin Panel
+            <LayoutPanelTop class="w-4 h-4" />{{ t("nav.adminPanel") }}
           </RouterLink>
 
           <RouterLink
@@ -116,7 +116,7 @@
             @click="mobileOpen = false"
           >
             <MessageSquare class="w-4 h-4 animate-pulse" />
-            Unread Messages ({{ authStore.unreadMessagesCount }})
+            {{ t("nav.unreadMessages") }} ({{ authStore.unreadMessagesCount }})
           </RouterLink>
 
           <RouterLink
@@ -126,13 +126,13 @@
             @click="mobileOpen = false"
           >
             <PackageCheck class="w-4 h-4" />
-            {{ t("nav.myOrders") || "My Orders" }}
+            {{ t("nav.myOrders") }}
           </RouterLink>
 
           <div class="flex flex-col gap-2 pt-4 border-t border-border/50">
             <template v-if="isLoggedIn">
               <Button variant="ghost" class="justify-start" @click="handleLogout">
-                <LogOut class="w-4 h-4 mr-2" />{{ t("nav.logOut") || "Log Out" }}
+                <LogOut class="w-4 h-4 mr-2" />{{ t("nav.logOut") }}
               </Button>
             </template>
             <template v-else>
@@ -173,13 +173,13 @@ const navLinks = computed(() => [
   { label: t("nav.services"), href: "/#services", isInternal: false },
   { label: t("nav.materials"), href: "/#materials", isInternal: false },
   { label: t("nav.howItWorks"), href: "/#process", isInternal: false },
-  { label: t("nav.portfolio") || "Portfolio", href: "/portfolio", isInternal: true },
+  { label: t("nav.portfolio"), href: "/portfolio", isInternal: true },
   { label: t("nav.philosophy"), href: "/#philosophy", isInternal: false },
 ]);
 
 async function handleLogout() {
   await authStore.logout();
-  toast.success("Successfully logged out");
+  toast.success(t("nav.loggedOut"));
   router.push("/");
   mobileOpen.value = false;
 }

+ 49 - 9
src/components/ModelUploadSection.vue

@@ -50,7 +50,7 @@
         <!-- Material Selection -->
         <div class="space-y-4 p-5 bg-secondary/30 rounded-[2rem] border border-black/[0.02]">
           <h3 class="text-[10px] font-bold uppercase tracking-[0.2em] text-foreground/40 flex items-center gap-2 px-1">
-            {{ t("upload.selectMaterial") || "Select Material" }}
+            {{ t("upload.selectMaterial") }}
           </h3>
           <div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
             <button
@@ -71,6 +71,31 @@
           </div>
         </div>
 
+        <!-- Color Selection (Strict) -->
+        <Transition enter-active-class="transition duration-300" enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100">
+          <div v-if="selectedMaterialColors.length" class="space-y-4 p-5 bg-secondary/30 rounded-[2rem] border border-black/[0.02]">
+            <h3 class="text-[10px] font-bold uppercase tracking-[0.2em] text-foreground/40 flex items-center gap-2 px-1">
+              {{ t("upload.selectColor") }}
+            </h3>
+            <div class="flex flex-wrap gap-2">
+              <button
+                v-for="color in selectedMaterialColors"
+                :key="color"
+                type="button"
+                @click="selectedColor = color"
+                :class="[
+                  'px-6 py-2.5 rounded-2xl border text-xs font-bold transition-all duration-300 capitalize',
+                  selectedColor === color
+                    ? 'bg-primary text-primary-foreground border-primary shadow-glow scale-105'
+                    : 'bg-white border-black/[0.05] text-foreground/60 hover:border-primary/30 hover:text-primary'
+                ]"
+              >
+                {{ color }}
+              </button>
+            </div>
+          </div>
+        </Transition>
+
         <!-- Quantity & Model Link -->
         <div class="grid sm:grid-cols-5 gap-6">
           <div class="space-y-1.5 sm:col-span-3">
@@ -113,7 +138,7 @@
                 {{ isDragging ? t("upload.dropzoneActive") : t("upload.dropzone") }}
               </p>
               <p class="text-xs font-medium text-foreground/40">
-                or <span class="text-primary underline cursor-pointer">{{ t("upload.browse") }}</span>
+                {{ t("common.or") }} <span class="text-primary underline cursor-pointer">{{ t("upload.browse") }}</span>
               </p>
             </div>
           </div>
@@ -179,8 +204,8 @@
             <Check v-if="allowPortfolio" class="w-3.5 h-3.5 text-primary-foreground" />
           </div>
           <div class="flex-1 space-y-1">
-            <p class="text-sm font-medium leading-none">{{ t("upload.allowPortfolio") || "Allow featuring in public portfolio" }}</p>
-            <p class="text-xs text-muted-foreground leading-relaxed">{{ t("upload.allowPortfolioDesc") || "We'll show photos of your print to inspire other customers." }}</p>
+            <p class="text-sm font-medium leading-none">{{ t("upload.allowPortfolio") }}</p>
+            <p class="text-xs text-muted-foreground leading-relaxed">{{ t("upload.allowPortfolioDesc") }}</p>
           </div>
           <ShieldCheck :class="['w-5 h-5 transition-colors', allowPortfolio ? 'text-primary' : 'text-muted-foreground/30']" />
         </div>
@@ -190,8 +215,8 @@
           <div v-if="estimatedPrice !== null"
             class="p-6 bg-gradient-to-br from-primary/20 via-primary/5 to-background border border-primary/30 rounded-2xl flex items-center justify-between">
             <div>
-              <p class="text-[10px] font-bold uppercase tracking-widest text-primary/80 mb-1">Estimated Total</p>
-              <p class="text-xs text-muted-foreground">Final price will be confirmed by admin.</p>
+              <p class="text-[10px] font-bold uppercase tracking-widest text-primary/80 mb-1">{{ t("upload.estimatedTotal") }}</p>
+              <p class="text-xs text-muted-foreground">{{ t("upload.priceDisclaimer") }}</p>
             </div>
             <div class="text-right">
               <span class="text-3xl font-display font-bold text-primary">{{ estimatedPrice }}</span>
@@ -203,7 +228,7 @@
         <Button variant="hero" class="w-full mt-8 shadow-lg hover:shadow-primary/20"
           :disabled="!isFormValid || isSubmitting" @click="handleSubmit">
           <div v-if="isSubmitting" class="flex items-center gap-2">
-            <Loader2 class="w-5 h-5 animate-spin" />{{ t("upload.submitting") || "Sending..." }}
+            <Loader2 class="w-5 h-5 animate-spin" />{{ t("upload.submitting") }}
           </div>
           <span v-else>{{ t("upload.continue") }}</span>
         </Button>
@@ -240,6 +265,20 @@ const email = ref("");
 const allowPortfolio = ref(false);
 const materials = ref<any[]>([]);
 const selectedMaterial = ref("1");
+const selectedColor = ref("");
+const selectedMaterialColors = computed(() => {
+  const mat = materials.value.find(m => String(m.id) === selectedMaterial.value);
+  return mat?.available_colors || [];
+});
+
+watch(selectedMaterial, (newVal) => {
+  const mat = materials.value.find(m => String(m.id) === newVal);
+  if (mat?.available_colors?.length) {
+    selectedColor.value = mat.available_colors[0];
+  } else {
+    selectedColor.value = "";
+  }
+});
 const estimatedPrice = computed(() => {
   if (files.value.length === 0) return null;
   const total = files.value.reduce((acc, f) => acc + ((f.basePrice || 0) * f.quantity), 0);
@@ -353,18 +392,19 @@ async function handleSubmit() {
   fd.append("allow_portfolio", String(allowPortfolio.value));
   fd.append("notes", notes.value);
   fd.append("material_id", selectedMaterial.value);
+  if (selectedColor.value) fd.append("color_name", selectedColor.value);
   
   const uploadedFiles = files.value.filter(f => f.dbId);
   fd.append("file_ids", JSON.stringify(uploadedFiles.map(f => f.dbId)));
   fd.append("file_quantities", JSON.stringify(uploadedFiles.map(f => f.quantity)));
   try {
     await submitOrder(fd);
-    toast.success(t("upload.success") || "Order submitted successfully!");
+    toast.success(t("upload.success"));
     files.value = []; firstName.value = ""; lastName.value = ""; phone.value = "";
     email.value = ""; address.value = ""; modelLink.value = "";
     allowPortfolio.value = false; notes.value = "";
   } catch (err: any) {
-    toast.error(err.message || "Failed to submit order.");
+    toast.error(err.message || t("upload.error"));
   } finally {
     isSubmitting.value = false;
   }

+ 57 - 0
src/lib/api.ts

@@ -349,6 +349,25 @@ export const adminUploadOrderPhoto = async (orderId: number, formData: FormData)
   return response.json();
 };
 
+export const adminDeletePhoto = async (photoId: number) => {
+  const token = localStorage.getItem("token");
+  const response = await fetch(`${API_BASE_URL}/admin/photos/${photoId}?lang=${i18n.global.locale.value}`, {
+    method: 'DELETE',
+    headers: { 'Authorization': `Bearer ${token}` }
+  });
+  if (!response.ok) throw new Error("Failed to delete photo");
+  return response.json();
+};
+
+export const adminGetAllPhotos = async () => {
+  const token = localStorage.getItem("token");
+  const response = await fetch(`${API_BASE_URL}/admin/all-photos?lang=${i18n.global.locale.value}`, {
+    headers: { 'Authorization': `Bearer ${token}` }
+  });
+  if (!response.ok) throw new Error("Failed to fetch all photos");
+  return response.json();
+};
+
 export const adminAttachFile = async (orderId: number, formData: FormData) => {
   const token = localStorage.getItem("token");
   const response = await fetch(`${API_BASE_URL}/orders/${orderId}/attach-file?lang=${i18n.global.locale.value}`, {
@@ -490,3 +509,41 @@ export const adminDeletePost = async (id: number) => {
   if (!response.ok) throw new Error("Failed to delete blog post");
   return response.json();
 };
+export const adminGetUsers = async (page = 1, size = 50, search = "") => {
+  const token = localStorage.getItem("token");
+  const query = new URLSearchParams({ page: page.toString(), size: size.toString(), search });
+  const response = await fetch(`${API_BASE_URL}/auth/admin/users?${query.toString()}`, {
+    headers: { 'Authorization': `Bearer ${token}` }
+  });
+  if (!response.ok) throw new Error("Failed to fetch users");
+  return response.json();
+};
+export const adminCreateUser = async (data: any) => {
+  const token = localStorage.getItem("token");
+  const response = await fetch(`${API_BASE_URL}/auth/admin/users`, {
+    method: 'POST',
+    headers: { 
+      'Content-Type': 'application/json',
+      'Authorization': `Bearer ${token}`
+    },
+    body: JSON.stringify(data)
+  });
+  if (!response.ok) {
+    const err = await response.json();
+    throw new Error(err.detail || "Failed to create user");
+  }
+  return response.json();
+};
+export const adminUpdateUser = async (userId: number, data: any) => {
+  const token = localStorage.getItem("token");
+  const response = await fetch(`${API_BASE_URL}/auth/users/${userId}/admin?lang=${i18n.global.locale.value}`, {
+    method: 'PATCH',
+    headers: { 
+      'Content-Type': 'application/json',
+      'Authorization': `Bearer ${token}`
+    },
+    body: JSON.stringify(data)
+  });
+  if (!response.ok) throw new Error("Failed to update user");
+  return response.json();
+};

+ 196 - 10
src/locales/en.json

@@ -44,11 +44,138 @@
       }
     }
   },
+  "admin": {
+    "actions": {
+      "cancel": "Cancel",
+      "create": "Create",
+      "delete": "Delete",
+      "deleteFile": "Delete attached file",
+      "edit": "Edit",
+      "printInvoice": "Print Uplatnica",
+      "save": "Save Changes",
+      "savePrice": "Save Price",
+      "sending": "Sending...",
+      "toggleAdminRole": "Toggle Admin role",
+      "viewOriginal": "View Original Snapshot",
+      "saveChanges": "Save Changes"
+    },
+    "addNew": "Add New",
+    "allStatuses": "All Statuses",
+    "dashboard": "Dashboard",
+    "fields": {
+      "active": "Active and Visible",
+      "category": "Category",
+      "colors": "Available Colors",
+      "content": "Content",
+      "customColorInfo": "Custom Color (No directory info)",
+      "defaultColor": "Default Color",
+      "description": "Description",
+      "email": "Email Address",
+      "estimated": "Estimated",
+      "excerpt": "Excerpt",
+      "externalLink": "External Model Link",
+      "finalPrice": "Final Price",
+      "firstName": "First Name",
+      "imageUrl": "Image URL",
+      "lastName": "Last Name",
+      "name": "Name",
+      "noPhotos": "No photos yet",
+      "noPortfolio": "No Portfolio",
+      "noUsers": "No users found",
+      "notifyUser": "Notify User",
+      "originalSnapshot": "Original Snapshot",
+      "password": "Password",
+      "phone": "Phone Number",
+      "photoReport": "Photo Report",
+      "portfolioAllowed": "Portfolio Allowed",
+      "price": "Price per cm³",
+      "pricePerCm3": "Price / cm³",
+      "projectNotes": "Project Notes",
+      "publishImmediately": "Publish immediately",
+      "quantity": "Quantity",
+      "selectColorStrict": "Select Color (Strict)",
+      "selectMaterialStrict": "Select Material (Strict)",
+      "shippingAddress": "Shipping Address",
+      "slug": "Slug (URL)",
+      "snapshotInfo": "These are the parameters recorded at the moment of order submission.",
+      "sourceFiles": "Source Files",
+      "strictSelectionInfo": "Strict selection ensures consistency with the material handbook.",
+      "techType": "Technology Type",
+      "title": "Title",
+      "customColorDirInfo": "Custom Color (No directory info)",
+      "customColorPlaceholder": "Custom color..."
+    },
+    "labels": {
+      "actions": "Actions",
+      "chat": "Chat",
+      "contact": "Contact",
+      "registered": "Registered",
+      "role": "Role",
+      "user": "User"
+    },
+    "managementCenter": "Management Center",
+    "modals": {
+      "createMaterial": "Create New Material",
+      "createPost": "Create New Post",
+      "createService": "Create New Service",
+      "createUser": "Create New User",
+      "editMaterial": "Edit Material",
+      "editPost": "Edit Blog Post",
+      "editService": "Edit Service",
+      "editUser": "Edit User",
+      "changeParams": "Change Material & Color"
+    },
+    "searchPlaceholder": "Search {tab}...",
+    "searchUsersPlaceholder": "Search by name, email, phone...",
+    "toasts": {
+      "chatDisabled": "Chat for User #{id} is now Disabled",
+      "chatEnabled": "Chat for User #{id} is now Enabled",
+      "fileAttached": "File attached and preview generated",
+      "fileDeleted": "File deleted successfully",
+      "genericError": "Operation failed",
+      "loadError": "Failed to load {tab}",
+      "materialDeleted": "Material deleted",
+      "materialSaved": "Material saved",
+      "noConsent": "User did not consent to portfolio",
+      "paramsUpdated": "Parameters updated",
+      "photoAdded": "Photo added",
+      "postDeleted": "Article deleted",
+      "postSaved": "Article saved",
+      "priceUpdated": "Price updated",
+      "roleUpdated": "Role for User #{id} updated to {role}",
+      "serviceDeleted": "Service deleted",
+      "serviceSaved": "Service saved",
+      "statusUpdated": "Status → {status}",
+      "userCreated": "User created successfully",
+      "userSaved": "User saved",
+      "photoDeleted": "Photo deleted successfully"
+    },
+    "total": "Total",
+    "statuses": {
+      "pending": "Pending",
+      "processing": "Processing",
+      "shipped": "Shipped",
+      "completed": "Completed",
+      "cancelled": "Cancelled"
+    },
+    "tabs": {
+      "orders": "Orders",
+      "materials": "Materials",
+      "services": "Services",
+      "users": "Users",
+      "posts": "Blog",
+      "portfolio": "Portfolio"
+    },
+    "questions": {
+      "deletePhoto": "Are you sure you want to delete this photo?"
+    }
+  },
   "auth": {
     "back": "Back to Home",
     "fields": {
       "confirmPassword": "Confirm Password",
       "email": "Email",
+      "newPassword": "New Password",
       "password": "Password"
     },
     "forgot": {
@@ -76,6 +203,15 @@
       "subtitle": "Choose a strong new password",
       "title": "Reset Password",
       "token": "Code from email"
+    },
+    "studio": "3D Printing Studio",
+    "toasts": {
+      "accountCreated": "Account created! Please log in.",
+      "passwordChanged": "Password changed successfully!",
+      "passwordsNoMatch": "Passwords do not match",
+      "resetLinkSent": "Password reset link has been sent to your email.",
+      "socialSoon": "{provider} login coming soon!",
+      "welcomeBack": "Welcome back!"
     }
   },
   "blog": {
@@ -89,18 +225,22 @@
       "tutorials": "Tutorials"
     },
     "dateFormat": "{date}",
+    "exploreOther": "Explore other articles",
     "featured": "Featured",
     "featuredPost": {
       "excerpt": "How digital manufacturing is changing the local craft business.",
       "title": "The Future of 3D Printing in Montenegro"
     },
     "latestPosts": "Latest Posts",
+    "loading": "Loading articles...",
+    "loadingSingle": "Loading article...",
     "newsletter": {
       "content": "Stay in loop.",
       "placeholder": "Email",
       "subscribe": "Subscribe",
       "title": "Newsletter"
     },
+    "notFound": "Post not found",
     "post1": {
       "excerpt": "Choosing the right tech.",
       "title": "FDM vs SLA"
@@ -187,7 +327,12 @@
     "unread": "New message"
   },
   "common": {
-    "save_continue": "Save and Continue"
+    "save_continue": "Save and Continue",
+    "or": "or",
+    "back": "Back",
+    "pending": "Pending...",
+    "default": "Default",
+    "orderId": "Order #{id}"
   },
   "contact": {
     "form": {
@@ -211,6 +356,11 @@
     "message": "This site uses cookies to improve your experience and analyze traffic."
   },
   "errors": {
+    "404": {
+      "button": "Return to Home",
+      "subtitle": "Oops! Page not found",
+      "title": "404"
+    },
     "field_required": "This field is required",
     "missing": "Field is required",
     "string_too_short": "Too short, min {{min_length}} characters",
@@ -228,8 +378,12 @@
     "careers": "Careers",
     "company": "Company",
     "contact": "Contact",
+    "contactDesc": "If you have any questions or wish to exercise your data rights, please reach out to our dedicated team.",
+    "contactTitle": "Need assistance?",
     "guidelines": "Guidelines",
     "help": "Help Center",
+    "intro": "At Radionica3D, we are committed to protecting your personal data and ensuring transparency in how we handle it. This policy outlines our practices for users in Montenegro and internationally.",
+    "location": "Herceg Novi, Montenegro",
     "materials": "Materials",
     "privacy": "Privacy",
     "services": "Services",
@@ -450,23 +604,29 @@
     "uploadButton": "Order Print"
   },
   "nav": {
-    "howItWorks": "How It Works",
+    "admin": "Admin",
+    "adminPanel": "Admin Panel",
+    "howItWorks": "How it works",
     "logIn": "Log In",
     "logOut": "Log Out",
+    "loggedOut": "Successfully logged out",
     "materials": "Materials",
     "myOrders": "My Orders",
-    "philosophy": "Our Philosophy",
+    "nuances": "Nuances",
+    "philosophy": "Philosophy",
     "portfolio": "Portfolio",
     "register": "Register",
-    "services": "Services"
+    "services": "Services",
+    "unreadMessages": "Unread Messages",
+    "unreadTooltip": "Unread messages in chat"
   },
   "nuances": {
+    "aiDisclaimer": "All explanatory photos are AI-generated for illustrative purposes.",
     "description": "Products made using the FDM (Fused Deposition Modeling) method have a number of visual and tactile features that are normal for the technology and are not considered defects.",
     "disclaimer": {
       "text": "These features are due to the very nature of FDM printing technology and do not affect the functionality of the product.",
       "title": "Quality Assurance"
     },
-    "aiDisclaimer": "All explanatory photos are AI-generated for illustrative purposes.",
     "items": {
       "colorVariations": {
         "description": "Slight shade variations.",
@@ -512,9 +672,28 @@
     "subtitle": "What to expect",
     "title": "3D Printing Nuances"
   },
+  "orders": {
+    "labels": {
+      "estimate": "Estimate",
+      "materialColor": "Material & Color",
+      "myNotes": "My Notes",
+      "progressReport": "Progress Report",
+      "projectFiles": "My Project Files",
+      "quantity": "Quantity",
+      "status": "Status"
+    },
+    "loading": "Loading order history...",
+    "noOrders": "No orders yet",
+    "startProject": "Start New Project",
+    "startProjectDesc": "Once you start a 3D printing project, you'll be able to track its progress right here.",
+    "titleSubtitle": "Track your 3D printing projects"
+  },
   "portfolio": {
     "description": "Explore our successful 3D printing projects realized for our local customers in Montenegro.",
-    "empty": "Our gallery is growing. Check back soon!",
+    "empty": "Portfolio is currently empty.",
+    "emptyDesc": "Check back soon for more amazing prints!",
+    "emptyTitle": "Our gallery is growing",
+    "loading": "Loading gallery...",
     "title": "Project",
     "titleGradient": "Showcase"
   },
@@ -534,7 +713,10 @@
     }
   },
   "privacy": {
+    "contactDesc": "If you have any questions or wish to exercise your data rights, please reach out to our dedicated team.",
+    "contactTitle": "Need assistance?",
     "intro": "This Privacy Policy describes:\n• what data we collect\n• how and why we use it\n• where and how it is stored or transferred\n• your rights regarding your data\n• how to contact us about privacy\n\nBy visiting our site, contacting us, or using our services, you agree to this policy.",
+    "responseNotice": "We respond to all privacy requests within 48 hours.",
     "sections": {
       "01_data": {
         "content": "We may collect and process:\n• Contact details: name, email, phone number, company\n• Project data & uploads: messages, briefs, 3D models, images, sketches\n• Transaction data: quotes, invoices, payments\n• Website & technical data: IP address, browser type, cookies, analytics\n\nWe do not collect sensitive categories (e.g. race, health, religious beliefs).",
@@ -697,6 +879,8 @@
     "dropzone": "Upload files (STL, OBJ, STEP)",
     "dropzoneActive": "Drop your files here",
     "email": "Email Address",
+    "error": "Failed to submit order.",
+    "estimatedTotal": "Estimated Total",
     "firstName": "First Name",
     "lastName": "Last Name",
     "modelLink": "Model Link (optional)",
@@ -704,14 +888,16 @@
     "notes": "Order Notes / Remarks",
     "notesPlaceholder": "Color preferences, specific requirements, or special instructions...",
     "phone": "Phone Number",
-    "quantity": "Number of Copies",
+    "priceDisclaimer": "Approximate cost based on material. Complexity and labor are not included and will be factored in the final quote.",
+    "quantity": "Quantity",
+    "selectColor": "Select Color",
     "selectMaterial": "Select Material",
     "shippingAddress": "Shipping Address",
-    "submitting": "Sending...",
-    "success": "Order submitted successfully! We will contact you soon.",
+    "submitting": "Submitting...",
+    "success": "Order submitted successfully!",
     "title": "Submit",
     "titleGradient": "Your Idea",
-    "uploadedFiles": "Selected Files"
+    "uploadedFiles": "Files to print"
   },
   "whyTrust": {
     "description1": "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.",

+ 198 - 12
src/locales/me.json

@@ -44,11 +44,138 @@
       }
     }
   },
+  "admin": {
+    "actions": {
+      "cancel": "Otkaži",
+      "create": "Kreiraj",
+      "delete": "Obriši",
+      "deleteFile": "Obriši zakačeni fajl",
+      "edit": "Uredi",
+      "printInvoice": "Odštampaj uplatnicu",
+      "save": "Sačuvaj izmjene",
+      "savePrice": "Sačuvaj cijenu",
+      "sending": "Slanje...",
+      "toggleAdminRole": "Promijeni admin ulogu",
+      "viewOriginal": "Vidi originalne parametre",
+      "saveChanges": "Sačuvaj promjene"
+    },
+    "addNew": "Dodaj novo",
+    "allStatuses": "Svi statusi",
+    "dashboard": "Panel",
+    "fields": {
+      "active": "Aktivan i vidljiv",
+      "category": "Kategorija",
+      "colors": "Dostupne boje",
+      "content": "Sadržaj",
+      "customColorInfo": "Prilagođena boja (bez informacija iz kataloga)",
+      "defaultColor": "Podrazumijevana boja",
+      "description": "Opis",
+      "email": "Email adresa",
+      "estimated": "Procjena",
+      "excerpt": "Kratak opis",
+      "externalLink": "Link ka modelu",
+      "finalPrice": "Konačna cijena",
+      "firstName": "Ime",
+      "imageUrl": "URL slike",
+      "lastName": "Prezime",
+      "name": "Naziv",
+      "noPhotos": "Nema fotografija",
+      "noPortfolio": "Portfolio nije dozvoljen",
+      "noUsers": "Korisnici nisu pronađeni",
+      "notifyUser": "Obavijesti korisnika",
+      "originalSnapshot": "Originalni parametri",
+      "password": "Lozinka",
+      "phone": "Broj telefona",
+      "photoReport": "Foto izvještaj",
+      "portfolioAllowed": "Portfolio dozvoljen",
+      "price": "Cijena po cm³",
+      "pricePerCm3": "Cijena / cm³",
+      "projectNotes": "Napomene o projektu",
+      "publishImmediately": "Objavi odmah",
+      "quantity": "Količina",
+      "selectColorStrict": "Izaberi boju",
+      "selectMaterialStrict": "Izaberi materijal",
+      "shippingAddress": "Adresa za dostavu",
+      "slug": "Slug (URL)",
+      "snapshotInfo": "Ovo su parametri zabilježeni u trenutku slanja narudžbe.",
+      "sourceFiles": "Izvorni fajlovi",
+      "strictSelectionInfo": "Striktni izbor osigurava dosljednost sa katalogom materijala.",
+      "techType": "Tip tehnologije",
+      "title": "Naslov",
+      "customColorDirInfo": "Prilagođena boja (nema info u direktorijumu)",
+      "customColorPlaceholder": "Prilagođena boja..."
+    },
+    "labels": {
+      "actions": "Akcije",
+      "chat": "Čat",
+      "contact": "Kontakt",
+      "registered": "Registracija",
+      "role": "Uloga",
+      "user": "Korisnik"
+    },
+    "managementCenter": "Centar za upravljanje",
+    "modals": {
+      "createMaterial": "Novi materijal",
+      "createPost": "Novi članak",
+      "createService": "Nova usluga",
+      "createUser": "Novi korisnik",
+      "editMaterial": "Uredi materijal",
+      "editPost": "Uredi članak",
+      "editService": "Uredi uslugu",
+      "editUser": "Uredi korisnika",
+      "changeParams": "Promijeni materijal i boju"
+    },
+    "searchPlaceholder": "Traži {tab}...",
+    "searchUsersPlaceholder": "Traži po imenu, emailu, telefonu...",
+    "toasts": {
+      "chatDisabled": "Čat za korisnika #{id} je ONEMOGUĆEN",
+      "chatEnabled": "Čat za korisnika #{id} je OMOGUĆEN",
+      "fileAttached": "Fajl okačen i pregled generisan",
+      "fileDeleted": "Fajl uspješno obrisan",
+      "genericError": "Operacija nije uspjela",
+      "loadError": "Greška pri učitavanju {tab}",
+      "materialDeleted": "Materijal obrisan",
+      "materialSaved": "Materijal sačuvan",
+      "noConsent": "Korisnik nije dao saglasnost za portfolio",
+      "paramsUpdated": "Parametri ažurirani",
+      "photoAdded": "Fotografija dodata",
+      "postDeleted": "Članak obrisan",
+      "postSaved": "Članak sačuvan",
+      "priceUpdated": "Cijena ažurirana",
+      "roleUpdated": "Uloga korisnika #{id} promijenjena u {role}",
+      "serviceDeleted": "Usluga obrisana",
+      "serviceSaved": "Usluga sačuvana",
+      "statusUpdated": "Status → {status}",
+      "userCreated": "Korisnik uspješno kreiran",
+      "userSaved": "Korisnik sačuvan",
+      "photoDeleted": "Fotografija uspješno obrisana"
+    },
+    "total": "Ukupno",
+    "statuses": {
+      "pending": "Na čekanju",
+      "processing": "Obrada",
+      "shipped": "Poslato",
+      "completed": "Završeno",
+      "cancelled": "Otkazano"
+    },
+    "tabs": {
+      "orders": "Narudžbe",
+      "materials": "Materijali",
+      "services": "Usluge",
+      "users": "Korisnici",
+      "posts": "Blog",
+      "portfolio": "Portfolio"
+    },
+    "questions": {
+      "deletePhoto": "Da li ste sigurni da želite obrisati ovu fotografiju?"
+    }
+  },
   "auth": {
     "back": "Nazad na početnu",
     "fields": {
       "confirmPassword": "Potvrdi lozinku",
       "email": "Email",
+      "newPassword": "Nova lozinka",
       "password": "Lozinka"
     },
     "forgot": {
@@ -76,6 +203,15 @@
       "subtitle": "Kreiraj novu sigurnu lozinku",
       "title": "Nova lozinka",
       "token": "Kod iz mejla"
+    },
+    "studio": "3D Printing Studio",
+    "toasts": {
+      "accountCreated": "Nalog je kreiran! Molimo prijavite se.",
+      "passwordChanged": "Lozinka uspješno promijenjena!",
+      "passwordsNoMatch": "Lozinke se ne podudaraju",
+      "resetLinkSent": "Link za resetovanje lozinke je poslat na vaš email.",
+      "socialSoon": "{provider} prijava stiže uskoro!",
+      "welcomeBack": "Dobrodošao nazad!"
     }
   },
   "blog": {
@@ -89,18 +225,22 @@
       "tutorials": "Vodiči"
     },
     "dateFormat": "{date}",
+    "exploreOther": "Istražite druge članke",
     "featured": "Izdvojeno",
     "featuredPost": {
       "excerpt": "Kako digitalna proizvodnja mijenja lokalni zanat.",
       "title": "Budućnost 3D štampe u Crnoj Gori"
     },
     "latestPosts": "Najnoviji članci",
+    "loading": "Učitavanje članaka...",
+    "loadingSingle": "Učitavanje članka...",
     "newsletter": {
       "content": "Budite u toku.",
       "placeholder": "Email",
       "subscribe": "Prijavi se",
       "title": "Bilten"
     },
+    "notFound": "Članak nije pronađen",
     "post1": {
       "excerpt": "Odabir tehnologije.",
       "title": "FDM i SLA"
@@ -187,7 +327,12 @@
     "unread": "Nova poruka"
   },
   "common": {
-    "save_continue": "Sačuvaj i nastavi"
+    "save_continue": "Sačuvaj i nastavi",
+    "or": "ili",
+    "back": "Nazad",
+    "pending": "Na čekanju...",
+    "default": "Podrazumijevano",
+    "orderId": "Narudžba #{id}"
   },
   "contact": {
     "form": {
@@ -211,6 +356,11 @@
     "message": "Ovaj sajt koristi kolačiće za pružanje boljeg korisničkog iskustva."
   },
   "errors": {
+    "404": {
+      "button": "Povratak na početnu",
+      "subtitle": "Ups! Stranica nije pronađena",
+      "title": "404"
+    },
     "field_required": "Ovo polje je obavezno",
     "missing": "Ovo polje je obavezno",
     "string_too_short": "Previše kratko, min {{min_length}} karaktera",
@@ -228,8 +378,12 @@
     "careers": "Karijere",
     "company": "Kompanija",
     "contact": "Kontakt",
+    "contactDesc": "Ako imate bilo kakvih pitanja, slobodno nas kontaktirajte.",
+    "contactTitle": "Kontakt",
     "guidelines": "Uputstva",
     "help": "Centar za pomoć",
+    "intro": "Bavimo se digitalnom zanatom kroz 3D štampu u Crnoj Gori.",
+    "location": "Herceg Novi, Crna Gora",
     "materials": "Materijali",
     "privacy": "Privatnost",
     "services": "Usluge",
@@ -450,23 +604,29 @@
     "uploadButton": "Naruči štampu"
   },
   "nav": {
-    "howItWorks": "Kako funkcioniše",
+    "admin": "Admin",
+    "adminPanel": "Admin panel",
+    "howItWorks": "Kako to funkcioniše",
     "logIn": "Prijavi se",
     "logOut": "Odjavi se",
+    "loggedOut": "Uspješno ste se odjavili",
     "materials": "Materijali",
     "myOrders": "Moje narudžbe",
-    "philosophy": "Naš pristup",
+    "nuances": "Nijanse",
+    "philosophy": "Filozofija",
     "portfolio": "Portfolio",
-    "register": "Registruj se",
-    "services": "Usluge"
+    "register": "Registracija",
+    "services": "Usluge",
+    "unreadMessages": "Nepročitane poruke",
+    "unreadTooltip": "Nepročitane poruke u chatu"
   },
   "nuances": {
+    "aiDisclaimer": "Sve fotografije objašnjenja su generisane pomoću vještačke inteligencije u ilustrativne svrhe.",
     "description": "Proizvodi napravljeni FDM metodom (slojevito topljenje plastike) imaju niz vizuelnih i taktilnih karakteristika koje su normalne za tehnologiju i ne smatramo ih defektima.",
     "disclaimer": {
       "text": "Ove karakteristike su posledica same prirode tehnologije FDM štampe.",
       "title": "Garancija kvaliteta"
     },
-    "aiDisclaimer": "Sve fotografije objašnjenja su generisane pomoću vještačke inteligencije u ilustrativne svrhe.",
     "items": {
       "colorVariations": {
         "description": "Blage varijacije nijanse.",
@@ -512,9 +672,28 @@
     "subtitle": "Šta očekivati",
     "title": "Nijanse 3D štampe"
   },
+  "orders": {
+    "labels": {
+      "estimate": "Procjena",
+      "materialColor": "Materijal i boja",
+      "myNotes": "Moje bilješke",
+      "progressReport": "Izvještaj o napretku",
+      "projectFiles": "Fajlovi projekta",
+      "quantity": "Količina",
+      "status": "Status"
+    },
+    "loading": "Učitavanje istorije narudžbi...",
+    "noOrders": "Još nema narudžbi",
+    "startProject": "Započni novi projekat",
+    "startProjectDesc": "Kada započnete projekat 3D štampe, ovdje ćete moći pratiti njegov napredak.",
+    "titleSubtitle": "Pratite tvoje projekte 3D štampe"
+  },
   "portfolio": {
     "description": "Istražite naše uspješne projekte 3D štampe realizovane za naše klijente u Crnoj Gori.",
-    "empty": "Naša galerija raste. Navratite uskoro!",
+    "empty": "Portfolio je trenutno prazan.",
+    "emptyDesc": "Navratite uskoro za više nevjerovatnih radova!",
+    "emptyTitle": "Naša galerija raste",
+    "loading": "Učitavanje galerije...",
     "title": "Galerija",
     "titleGradient": "radova"
   },
@@ -534,7 +713,10 @@
     }
   },
   "privacy": {
+    "contactDesc": "Ako imate bilo kakvih pitanja ili želite da ostvarite svoja prava na podatke, obratite se našem timu.",
+    "contactTitle": "Potrebna pomoć?",
     "intro": "Ova Politika privatnosti opisuje:\n• koje podatke prikupljamo\n• kako i zašto ih koristimo\n• gdje se čuvaju i kako se prenose\n• vaša prava u vezi sa vašim podacima\n• kako da nas kontaktirate u vezi sa privatnošću\n\nPosjetom našem sajtu, stupanjem u kontakt sa nama ili korišćenjem naših usluga, prihvatate ovu politiku.",
+    "responseNotice": "Odgovaramo na sve zahtjeve za privatnost u roku od 48 sati.",
     "sections": {
       "01_data": {
         "content": "Možemo prikupljati i obrađivati:\n• Kontakt podatke: ime, email, broj telefona, kompanija\n• Podatke o projektima i otpremanje: poruke, brifove, 3D modele, slike, skice\n• Podatke o transakcijama: ponude, fakture, plaćanja\n• Tehničke podatke: IP adresa, tip pretraživača, kolačići, analitika\n\nNe prikupljamo osjetljive kategorije (npr. rasa, zdravlje, vjerska uvjerenja).",
@@ -688,8 +870,8 @@
   },
   "upload": {
     "addressPlaceholder": "Grad, Poštanski broj, Adresa (slobodna forma)",
-    "allowPortfolio": "Dozvoli objavljivanje u javnom portfoliju",
-    "allowPortfolioDesc": "Prikazaćemo fotografije tvog modela kako bismo inspirisali druge kupce.",
+    "allowPortfolio": "Dozvoli prikazivanje u javnom portfoliju",
+    "allowPortfolioDesc": "Prikazaćemo fotografije vašeg modela kako bismo inspirisali druge klijente.",
     "badge": "Kreiranje projekta",
     "browse": "pretraži datoteke",
     "continue": "Pošalji zahtjev",
@@ -697,6 +879,8 @@
     "dropzone": "Otpremi datoteke (STL, OBJ, STEP)",
     "dropzoneActive": "Prevucite datoteke ovdje",
     "email": "Email adresa",
+    "error": "Slanje narudžbe nije uspjelo.",
+    "estimatedTotal": "Procijenjeni ukupni iznos",
     "firstName": "Ime",
     "lastName": "Prezime",
     "modelLink": "Link do modela (opciono)",
@@ -704,14 +888,16 @@
     "notes": "Napomene uz narudžbu",
     "notesPlaceholder": "Želje za bojom, materijalom, specifičnim zahtjevima ili posebne instrukcije...",
     "phone": "Broj telefona",
+    "priceDisclaimer": "Okviran trošak na bazi materijala. Složenost i rad nisu uključeni i biće dodati u konačnu ponudu.",
     "quantity": "Broj kopija",
-    "selectMaterial": "Odaberi materijal",
+    "selectColor": "Izaberite boju",
+    "selectMaterial": "Izaberite materijal",
     "shippingAddress": "Adresa isporuke",
     "submitting": "Slanje...",
-    "success": "Zahtjev je uspješno poslat! Kontaktiraćemo vas uskoro.",
+    "success": "Narudžba je uspješno poslata!",
     "title": "Pošaljite",
     "titleGradient": "vašu ideju",
-    "uploadedFiles": "Odabrane datoteke"
+    "uploadedFiles": "Fajlovi za štampu"
   },
   "whyTrust": {
     "description1": "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.",

+ 189 - 3
src/locales/ru.json

@@ -44,11 +44,138 @@
       }
     }
   },
+  "admin": {
+    "actions": {
+      "cancel": "Отмена",
+      "create": "Создать",
+      "delete": "Удалить",
+      "deleteFile": "Удалить файл",
+      "edit": "Редактировать",
+      "printInvoice": "Печать счета",
+      "save": "Сохранить",
+      "savePrice": "Сохранить цену",
+      "sending": "Отправка...",
+      "toggleAdminRole": "Переключить роль админа",
+      "viewOriginal": "Оригинал",
+      "saveChanges": "Сохранить изменения"
+    },
+    "addNew": "Добавить",
+    "allStatuses": "Все статусы",
+    "dashboard": "Дэшборд",
+    "fields": {
+      "active": "Активен",
+      "category": "Категория",
+      "colors": "Цвета",
+      "content": "Контент",
+      "customColorInfo": "Введите HEX или название",
+      "defaultColor": "Цвет по умолчанию",
+      "description": "Описание",
+      "email": "Email",
+      "estimated": "Оценка",
+      "excerpt": "Краткое описание",
+      "externalLink": "Внешняя ссылка",
+      "finalPrice": "Финальная цена",
+      "firstName": "Имя",
+      "imageUrl": "URL изображения",
+      "lastName": "Фамилия",
+      "name": "Название",
+      "noPhotos": "Нет фото",
+      "noPortfolio": "Портфолио пусто",
+      "noUsers": "Пользователи не найдены",
+      "notifyUser": "Уведомить клиента",
+      "originalSnapshot": "Снимок заказа",
+      "password": "Пароль",
+      "phone": "Телефон",
+      "photoReport": "Фотоотчет",
+      "portfolioAllowed": "Разрешить в портфолио",
+      "price": "Цена",
+      "pricePerCm3": "Цена за см³",
+      "projectNotes": "Заметки к проекту",
+      "publishImmediately": "Опубликовать сразу",
+      "quantity": "Количество",
+      "selectColorStrict": "Строгий выбор цвета",
+      "selectMaterialStrict": "Строгий выбор материала",
+      "shippingAddress": "Адрес доставки",
+      "slug": "Slug (URL)",
+      "snapshotInfo": "Состояние заказа на момент создания",
+      "sourceFiles": "Исходные файлы",
+      "strictSelectionInfo": "Клиент может выбирать только из списка",
+      "techType": "Тип технологии",
+      "title": "Заголовок",
+      "customColorDirInfo": "Своя цвет (нет инфо в справочнике)",
+      "customColorPlaceholder": "Свой цвет..."
+    },
+    "labels": {
+      "actions": "Действия",
+      "chat": "Чат",
+      "contact": "Контакт",
+      "registered": "Зарегистрирован",
+      "role": "Роль",
+      "user": "Пользователь"
+    },
+    "managementCenter": "Центр управления",
+    "modals": {
+      "createMaterial": "Добавить материал",
+      "createPost": "Новая запись",
+      "createService": "Новая услуга",
+      "createUser": "Новый пользователь",
+      "editMaterial": "Редактировать материал",
+      "editPost": "Редактировать запись",
+      "editService": "Редактировать услугу",
+      "editUser": "Редактировать пользователя",
+      "changeParams": "Изменить материал и цвет"
+    },
+    "searchPlaceholder": "Поиск...",
+    "searchUsersPlaceholder": "Поиск пользователей...",
+    "toasts": {
+      "chatDisabled": "Чат отключен",
+      "chatEnabled": "Чат включен",
+      "fileAttached": "Файл прикреплен",
+      "fileDeleted": "Файл удален",
+      "genericError": "Произошла ошибка",
+      "loadError": "Ошибка загрузки",
+      "materialDeleted": "Материал удален",
+      "materialSaved": "Материал сохранен",
+      "noConsent": "Нет согласия",
+      "paramsUpdated": "Параметры обновлены",
+      "photoAdded": "Фото добавлено",
+      "postDeleted": "Запись удалена",
+      "postSaved": "Запись сохранена",
+      "priceUpdated": "Цена обновлена",
+      "roleUpdated": "Роль обновлена",
+      "serviceDeleted": "Услуга удалена",
+      "serviceSaved": "Услуга сохранена",
+      "statusUpdated": "Статус обновлен",
+      "userCreated": "Пользователь создан",
+      "userSaved": "Пользователь сохранен",
+      "photoDeleted": "Фото успешно удалено"
+    },
+    "total": "Всего",
+    "statuses": {
+      "pending": "Ожидание",
+      "processing": "В работе",
+      "shipped": "Отправлено",
+      "completed": "Завершено",
+      "cancelled": "Отменено"
+    },
+    "tabs": {
+      "orders": "Заказы",
+      "materials": "Материалы",
+      "services": "Услуги",
+      "users": "Пользователи",
+      "posts": "Блог",
+      "portfolio": "Портфолио"
+    },
+    "questions": {
+      "deletePhoto": "Вы уверены, что хотите удалить это фото?"
+    }
+  },
   "auth": {
     "back": "На главную",
     "fields": {
       "confirmPassword": "Подтвердите пароль",
       "email": "Email",
+      "newPassword": "Новый пароль",
       "password": "Пароль"
     },
     "forgot": {
@@ -76,6 +203,15 @@
       "subtitle": "Придумайте новый надежный пароль",
       "title": "Сброс пароля",
       "token": "Код из письма"
+    },
+    "studio": "Студия 3D Печати",
+    "toasts": {
+      "accountCreated": "Аккаунт создан! Теперь можно войти.",
+      "passwordChanged": "Пароль успешно изменен!",
+      "passwordsNoMatch": "Пароли не совпадают",
+      "resetLinkSent": "Ссылка на сброс пароля отправлена на почту.",
+      "socialSoon": "Вход через {provider} скоро появится!",
+      "welcomeBack": "С возвращением!"
     }
   },
   "blog": {
@@ -89,18 +225,22 @@
       "tutorials": "Уроки"
     },
     "dateFormat": "{date}",
+    "exploreOther": "Посмотреть другие",
     "featured": "Рекомендуемое",
     "featuredPost": {
       "excerpt": "Как цифровое производство меняет местный ремесленный бизнес.",
       "title": "Будущее 3D-печати в Черногории"
     },
     "latestPosts": "Последние записи",
+    "loading": "Загрузка записей...",
+    "loadingSingle": "Загрузка записи...",
     "newsletter": {
       "content": "Будьте в курсе.",
       "placeholder": "Email",
       "subscribe": "Подписаться",
       "title": "Рассылка"
     },
+    "notFound": "Запись не найдена",
     "post1": {
       "excerpt": "Выбор технологии.",
       "title": "FDM против SLA"
@@ -187,7 +327,12 @@
     "unread": "Новое сообщение"
   },
   "common": {
-    "save_continue": "Сохранить и продолжить"
+    "save_continue": "Сохранить и продолжить",
+    "or": "или",
+    "back": "Назад",
+    "pending": "Ожидание...",
+    "default": "По умолчанию",
+    "orderId": "Заказ #{id}"
   },
   "contact": {
     "form": {
@@ -211,6 +356,11 @@
     "message": "Данный сайт использует файлы cookie для улучшения пользовательского опыта."
   },
   "errors": {
+    "404": {
+      "button": "Вернуться на главную",
+      "subtitle": "Страница не найдена",
+      "title": "Ошибка 404"
+    },
     "field_required": "Это поле обязательно для заполнения",
     "missing": "Обязательное поле",
     "string_too_short": "Слишком коротко, минимум {{min_length}} символов",
@@ -228,8 +378,12 @@
     "careers": "Вакансии",
     "company": "Компания",
     "contact": "Контакты",
+    "contactDesc": "Если у вас есть вопросы, свяжитесь с нами.",
+    "contactTitle": "Контакты",
     "guidelines": "Руководство",
     "help": "Справочный центр",
+    "intro": "Мы занимаемся цифровым ремеслом через 3D-печать в Черногории.",
+    "location": "Херцег-Нови, Черногория",
     "materials": "Материалы",
     "privacy": "Конфиденциальность",
     "services": "Услуги",
@@ -450,23 +604,29 @@
     "uploadButton": "Заказать печать"
   },
   "nav": {
+    "admin": "Админ",
+    "adminPanel": "Панель управления",
     "howItWorks": "Как это работает",
     "logIn": "Войти",
     "logOut": "Выйти",
+    "loggedOut": "Вы успешно вышли",
     "materials": "Материалы",
     "myOrders": "Мои заказы",
+    "nuances": "Нюансы",
     "philosophy": "Наш подход",
     "portfolio": "Портфолио",
     "register": "Регистрация",
-    "services": "Услуги"
+    "services": "Услуги",
+    "unreadMessages": "Непрочитанные сообщения",
+    "unreadTooltip": "У вас есть непрочитанные сообщения"
   },
   "nuances": {
+    "aiDisclaimer": "Все поясняющие фотографии сгенерированы ИИ для наглядности.",
     "description": "Изделия, изготовленные методом FDM (послойного наплавления пластика), имеют ряд визуальных и тактильных особенностей, которые являются нормой технологии и не считаются дефектами.",
     "disclaimer": {
       "text": "Эти особенности обусловлены самой природой технологии FDM-печати.",
       "title": "Гарантия качества"
     },
-    "aiDisclaimer": "Все поясняющие фотографии сгенерированы ИИ для наглядности.",
     "items": {
       "colorVariations": {
         "description": "Незначительные вариации цвета.",
@@ -512,9 +672,28 @@
     "subtitle": "Чего ожидать",
     "title": "Нюансы 3D-печати"
   },
+  "orders": {
+    "labels": {
+      "estimate": "Расчет",
+      "materialColor": "Материал и цвет",
+      "myNotes": "Мои заметки",
+      "progressReport": "Отчет о выполнении",
+      "projectFiles": "Файлы проекта",
+      "quantity": "Кол-во",
+      "status": "Статус"
+    },
+    "loading": "Загрузка истории заказов...",
+    "noOrders": "Заказов пока нет",
+    "startProject": "Создать новый проект",
+    "startProjectDesc": "Как только вы создадите проект на 3D-печать, вы сможете отслеживать его прогресс здесь.",
+    "titleSubtitle": "Отслеживайте свои проекты 3D-печати"
+  },
   "portfolio": {
     "description": "Ознакомьтесь с нашими успешными проектами 3D-печати, реализованными для клиентов в Черногории.",
     "empty": "Наша галерея пополняется. Заходите позже!",
+    "emptyDesc": "Здесь скоро появятся наши новые работы.",
+    "emptyTitle": "Портфолио пусто",
+    "loading": "Загрузка портфолио...",
     "title": "Галерея",
     "titleGradient": "работ"
   },
@@ -534,7 +713,10 @@
     }
   },
   "privacy": {
+    "contactDesc": "Если у вас есть вопросы, наша команда всегда готова помочь.",
+    "contactTitle": "Нужна помощь?",
     "intro": "Эта Политика конфиденциальности описывает:\n• какие данные мы собираем\n• как и почему мы их используем\n• где и как они хранятся или передаются\n• ваши права в отношении ваших данных\n• как связаться с нами по вопросам конфиденциальности\n\nПосещая наш сайт, связываясь с нами или пользуясь нашими услугами, вы соглашаетесь с этой политикой.",
+    "responseNotice": "Мы отвечаем на все запросы в течение 48 часов.",
     "sections": {
       "01_data": {
         "content": "Мы можем собирать и обрабатывать:\n• Контактные данные: имя, email, номер телефона, компания\n• Данные проекта и загрузки: сообщения, брифы, 3D-модели, изображения, эскизы\n• Данные о транзакциях: сметы, счета, платежи\n• Технические данные сайта: IP-адрес, тип браузера, файлы cookie, аналитика\n\nМы не собираем чувствительные категории данных (например, раса, состояние здоровья, религиозные убеждения).",
@@ -697,6 +879,8 @@
     "dropzone": "Загрузить файлы (STL, OBJ, STEP)",
     "dropzoneActive": "Переместите файлы сюда",
     "email": "Email",
+    "error": "Ошибка загрузки",
+    "estimatedTotal": "Приблизительный итог",
     "firstName": "Имя",
     "lastName": "Фамилия",
     "modelLink": "Ссылка на модель (необязательно)",
@@ -704,7 +888,9 @@
     "notes": "Примечания к заказу",
     "notesPlaceholder": "Пожелания по цвету, материалу, толщине стенок или другие инструкции...",
     "phone": "Телефон",
+    "priceDisclaimer": "Ориентировочная стоимость на основе материала. Сложность и трудоемкость будут учтены при финальной оценке администратором.",
     "quantity": "Количество копий",
+    "selectColor": "Выберите цвет",
     "selectMaterial": "Выберите материал",
     "shippingAddress": "Адрес доставки",
     "submitting": "Отправка...",

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 1002 - 188
src/locales/translations.json


+ 189 - 3
src/locales/ua.json

@@ -44,11 +44,138 @@
       }
     }
   },
+  "admin": {
+    "actions": {
+      "cancel": "Скасувати",
+      "create": "Створити",
+      "delete": "Видалити",
+      "deleteFile": "Видалити файл",
+      "edit": "Редагувати",
+      "printInvoice": "Друк рахунку",
+      "save": "Зберегти",
+      "savePrice": "Зберегти ціну",
+      "sending": "Надсилання...",
+      "toggleAdminRole": "Перемкнути роль адміна",
+      "viewOriginal": "Оригінал",
+      "saveChanges": "Зберегти зміни"
+    },
+    "addNew": "Додати",
+    "allStatuses": "Усі статуси",
+    "dashboard": "Дешборд",
+    "fields": {
+      "active": "Активний",
+      "category": "Категорія",
+      "colors": "Кольори",
+      "content": "Контент",
+      "customColorInfo": "Введіть HEX або назву",
+      "defaultColor": "Колір за замовчуванням",
+      "description": "Опис",
+      "email": "Email",
+      "estimated": "Оцінка",
+      "excerpt": "Короткий опис",
+      "externalLink": "Зовнішнє посилання",
+      "finalPrice": "Фінальна ціна",
+      "firstName": "Ім'я",
+      "imageUrl": "URL зображення",
+      "lastName": "Прізвище",
+      "name": "Назва",
+      "noPhotos": "Немає фото",
+      "noPortfolio": "Портфоліо порожнє",
+      "noUsers": "Користувачів не знайдено",
+      "notifyUser": "Повідомити клієнта",
+      "originalSnapshot": "Знімок замовлення",
+      "password": "Пароль",
+      "phone": "Телефон",
+      "photoReport": "Фотозвіт",
+      "portfolioAllowed": "Дозволити в портфоліо",
+      "price": "Ціна",
+      "pricePerCm3": "Ціна за см³",
+      "projectNotes": "Нотатки до проєкту",
+      "publishImmediately": "Опублікувати відразу",
+      "quantity": "Кількість",
+      "selectColorStrict": "Суворий вибір кольору",
+      "selectMaterialStrict": "Суворий вибір матеріалу",
+      "shippingAddress": "Адреса доставки",
+      "slug": "Slug (URL)",
+      "snapshotInfo": "Стан замовлення на момент створення",
+      "sourceFiles": "Вихідні файли",
+      "strictSelectionInfo": "Клієнт може вибирати тільки зі списку",
+      "techType": "Тип технології",
+      "title": "Заголовок",
+      "customColorDirInfo": "Свій колір (немає інфо в довіднику)",
+      "customColorPlaceholder": "Свій колір..."
+    },
+    "labels": {
+      "actions": "Дії",
+      "chat": "Чат",
+      "contact": "Контакт",
+      "registered": "Зареєстрований",
+      "role": "Роль",
+      "user": "Користувач"
+    },
+    "managementCenter": "Центр управління",
+    "modals": {
+      "createMaterial": "Додати матеріал",
+      "createPost": "Новий запис",
+      "createService": "Нова послуга",
+      "createUser": "Новий користувач",
+      "editMaterial": "Редагувати матеріал",
+      "editPost": "Редагувати запис",
+      "editService": "Редагувати послугу",
+      "editUser": "Редагувати користувача",
+      "changeParams": "Змінити матеріал та колір"
+    },
+    "searchPlaceholder": "Пошук...",
+    "searchUsersPlaceholder": "Пошук користувачів...",
+    "toasts": {
+      "chatDisabled": "Чат вимкнено",
+      "chatEnabled": "Чат увімкнено",
+      "fileAttached": "Файл прикріплено",
+      "fileDeleted": "Файл видалено",
+      "genericError": "Сталася помилка",
+      "loadError": "Помилка завантаження",
+      "materialDeleted": "Матеріал видалено",
+      "materialSaved": "Матеріал збережено",
+      "noConsent": "Немає згоди",
+      "paramsUpdated": "Параметри оновлено",
+      "photoAdded": "Фото додано",
+      "postDeleted": "Запис видалено",
+      "postSaved": "Запис збережено",
+      "priceUpdated": "Ціна оновлена",
+      "roleUpdated": "Роль оновлена",
+      "serviceDeleted": "Послуга видалена",
+      "serviceSaved": "Послугу збережено",
+      "statusUpdated": "Статус оновлено",
+      "userCreated": "Користувач створений",
+      "userSaved": "Користувача збережено",
+      "photoDeleted": "Фото успішно видалено"
+    },
+    "total": "Всього",
+    "statuses": {
+      "pending": "Очікування",
+      "processing": "В роботі",
+      "shipped": "Відправлено",
+      "completed": "Завершено",
+      "cancelled": "Скасовано"
+    },
+    "tabs": {
+      "orders": "Замовлення",
+      "materials": "Матеріали",
+      "services": "Послуги",
+      "users": "Користувачі",
+      "posts": "Блог",
+      "portfolio": "Портфоліо"
+    },
+    "questions": {
+      "deletePhoto": "Ви впевнені, що хочете видалити це фото?"
+    }
+  },
   "auth": {
     "back": "На головну",
     "fields": {
       "confirmPassword": "Підтвердьте пароль",
       "email": "Email",
+      "newPassword": "Новий пароль",
       "password": "Пароль"
     },
     "forgot": {
@@ -76,6 +203,15 @@
       "subtitle": "Придумайте новий надійний пароль",
       "title": "Скидання пароля",
       "token": "Код із листа"
+    },
+    "studio": "Студія 3D Друку",
+    "toasts": {
+      "accountCreated": "Акаунт створено! Тепер можна увійти.",
+      "passwordChanged": "Пароль успішно змінено!",
+      "passwordsNoMatch": "Паролі не збігаються",
+      "resetLinkSent": "Посилання на скидання пароля надіслано на пошту.",
+      "socialSoon": "Вхід через {provider} скоро з'явиться!",
+      "welcomeBack": "З поверненням!"
     }
   },
   "blog": {
@@ -89,18 +225,22 @@
       "tutorials": "Уроки"
     },
     "dateFormat": "{date}",
+    "exploreOther": "Переглянути інші",
     "featured": "Рекомендоване",
     "featuredPost": {
       "excerpt": "Як цифрове виробництво змінює місцевий ремісничий бізнес.",
       "title": "Майбутнє 3D-друку в Чорногорії"
     },
     "latestPosts": "Останні записи",
+    "loading": "Завантаження записів...",
+    "loadingSingle": "Завантаження запису...",
     "newsletter": {
       "content": "Будьте в курсі.",
       "placeholder": "Email",
       "subscribe": "Підписатися",
       "title": "Розсилка"
     },
+    "notFound": "Запис не знайдено",
     "post1": {
       "excerpt": "Вибір технології.",
       "title": "FDM проти SLA"
@@ -187,7 +327,12 @@
     "unread": "Нове повідомлення"
   },
   "common": {
-    "save_continue": "Зберегти та продовжити"
+    "save_continue": "Зберегти та продовжити",
+    "or": "або",
+    "back": "Назад",
+    "pending": "Очікування...",
+    "default": "За замовчуванням",
+    "orderId": "Замовлення #{id}"
   },
   "contact": {
     "form": {
@@ -211,6 +356,11 @@
     "message": "Цей сайт використовує файли cookie для покращення досвіду користувача."
   },
   "errors": {
+    "404": {
+      "button": "Повернутися на головну",
+      "subtitle": "Сторінка не знайдена",
+      "title": "Помилка 404"
+    },
     "field_required": "Це поле є обов'язковим для заповнення",
     "missing": "Обов'язкове поле",
     "string_too_short": "Дуже коротко, мінімум {{min_length}} символів",
@@ -228,8 +378,12 @@
     "careers": "Вакансії",
     "company": "Компанія",
     "contact": "Контакти",
+    "contactDesc": "Якщо у вас є питання, зв'яжіться з нами.",
+    "contactTitle": "Контакти",
     "guidelines": "Керівництво",
     "help": "Довідковий центр",
+    "intro": "Ми займаємося цифровим ремеслом через 3D-друк у Чорногорії.",
+    "location": "Херцег-Нові, Чорногорія",
     "materials": "Матеріали",
     "privacy": "Конфіденційність",
     "services": "Послуги",
@@ -450,23 +604,29 @@
     "uploadButton": "Замовити друк"
   },
   "nav": {
+    "admin": "Адмін",
+    "adminPanel": "Панель управління",
     "howItWorks": "Як це працює",
     "logIn": "Увійти",
     "logOut": "Вийти",
+    "loggedOut": "Ви успішно вийшли",
     "materials": "Матеріали",
     "myOrders": "Мої замовлення",
+    "nuances": "Нюанси",
     "philosophy": "Наш підхід",
     "portfolio": "Портфоліо",
     "register": "Реєстрація",
-    "services": "Послуги"
+    "services": "Послуги",
+    "unreadMessages": "Непрочитані повідомлення",
+    "unreadTooltip": "У вас є непрочитані повідомлення"
   },
   "nuances": {
+    "aiDisclaimer": "Усі пояснювальні фотографії згенеровані ШІ для наочності.",
     "description": "Вироби, виготовлені методом FDM (пошарового наплавлення пластику), мають ряд візуальних і тактильних особливостей, які є нормою технології та не вважаються дефектами.",
     "disclaimer": {
       "text": "Ці особливості обумовлені самою природою технології FDM-друку.",
       "title": "Гарантія якості"
     },
-    "aiDisclaimer": "Усі пояснювальні фотографії згенеровані ШІ для наочності.",
     "items": {
       "colorVariations": {
         "description": "Незначні варіації кольору.",
@@ -512,9 +672,28 @@
     "subtitle": "Чого очікувати",
     "title": "Нюанси 3D-друку"
   },
+  "orders": {
+    "labels": {
+      "estimate": "Розрахунок",
+      "materialColor": "Матеріал та колір",
+      "myNotes": "Мої замітки",
+      "progressReport": "Звіт про виконання",
+      "projectFiles": "Файли проєкту",
+      "quantity": "К-сть",
+      "status": "Статус"
+    },
+    "loading": "Завантаження історії замовлень...",
+    "noOrders": "Замовлення поки відсутні",
+    "startProject": "Створити новий проєкт",
+    "startProjectDesc": "Як тільки ви створите проєкт на 3D-друк, ви зможете відстежувати його прогрес тут.",
+    "titleSubtitle": "Відстежуйте свої проєкти 3D-друку"
+  },
   "portfolio": {
     "description": "Ознайомтеся з нашими успішними проектами 3D-друку, реалізованими для клієнтів у Чорногорії.",
     "empty": "Наша галерея поповнюється. Заходьте пізніше!",
+    "emptyDesc": "Тут скоро з'являться наші нові роботи.",
+    "emptyTitle": "Портфоліо порожнє",
+    "loading": "Завантаження портфоліо...",
     "title": "Галерея",
     "titleGradient": "робіт"
   },
@@ -534,7 +713,10 @@
     }
   },
   "privacy": {
+    "contactDesc": "Якщо у вас є питання, наша команда завжди готова допомогти.",
+    "contactTitle": "Потрібна допомога?",
     "intro": "Ця Політика конфіденційності описує:\n• які дані ми збираємо\n• як і чому ми їх використовуємо\n• де і як вони зберігаються або передаються\n• ваші права щодо ваших даних\n• як зв'язатися з нами щодо конфіденційності\n\nВідвідуючи наш сайт, зв'язуючись з нами або користуючись нашими послугами, вы погоджуєтеся з цією політикою.",
+    "responseNotice": "Ми відповідаємо на всі запити протягом 48 годин.",
     "sections": {
       "01_data": {
         "content": "Ми можемо збирати та обробляти:\n• Контактні дані: ім'я, email, номер телефону, компанія\n• Дані проекту та завантаження: повідомлення, брифи, 3D-моделі, зображення, ескізи\n• Дані про транзакції: кошториси, рахунки, платежі\n• Технічні дані: IP-адреса, тип браузера, файли cookie, аналітика\n\nМи не збираємо чутливі категорії даних (наприклад, раса, стан здоров'я, релігійні переконання).",
@@ -697,6 +879,8 @@
     "dropzone": "Завантажити файли (STL, OBJ, STEP)",
     "dropzoneActive": "Перемістіть файли сюди",
     "email": "Email",
+    "error": "Помилка завантаження",
+    "estimatedTotal": "Орієнтовний підсумок",
     "firstName": "Ім'я",
     "lastName": "Прізвище",
     "modelLink": "Посилання на модель (необов'язково)",
@@ -704,7 +888,9 @@
     "notes": "Примітки до замовлення",
     "notesPlaceholder": "Побажання щодо кольору, матеріалу, товщини стінок або інші інструкції.",
     "phone": "Телефон",
+    "priceDisclaimer": "Орієнтовна вартість на основі матеріалу. Складність та трудомісткість будуть враховані при фінальній оцінці адміністратором.",
     "quantity": "Кількість копій",
+    "selectColor": "Виберіть колір",
     "selectMaterial": "Виберіть матеріал",
     "shippingAddress": "Адреса доставки",
     "submitting": "Надсилання...",

+ 537 - 120
src/pages/Admin.vue

@@ -11,8 +11,8 @@
       <!-- Header -->
       <div class="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-12">
         <div>
-          <span class="text-primary font-display text-sm tracking-widest uppercase mb-2 block">Management Center</span>
-          <h1 class="font-display text-4xl font-bold">Admin <span class="text-gradient">Dashboard</span></h1>
+          <span class="text-primary font-display text-sm tracking-widest uppercase mb-2 block">{{ t("admin.managementCenter") }}</span>
+          <h1 class="font-display text-4xl font-bold">Admin <span class="text-gradient">{{ t("admin.dashboard") }}</span></h1>
         </div>
         <div class="flex bg-card/40 backdrop-blur-md border border-border/50 rounded-2xl p-1 gap-1">
           <button v-for="tab in tabs" :key="tab.id" @click="activeTab = tab.id" :class="[
@@ -28,16 +28,16 @@
       <div class="flex flex-col sm:flex-row gap-4 mb-8">
         <div class="relative flex-1">
           <Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
-          <input type="text" v-model="searchQuery" :placeholder="`Search ${activeTab}...`"
+          <input type="text" v-model="searchQuery" :placeholder="t('admin.searchPlaceholder', { tab: activeTab })"
             class="w-full bg-card/50 border border-border/50 rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all text-sm" />
         </div>
         <select v-if="activeTab === 'orders'" v-model="statusFilter"
           class="bg-card/50 border border-border/50 rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/20 text-sm min-w-[140px]">
-          <option value="all">All Statuses</option>
+          <option value="all">{{ t("admin.allStatuses") }}</option>
           <option v-for="s in Object.keys(STATUS_CONFIG)" :key="s" :value="s">{{ capitalize(s) }}</option>
         </select>
         <Button v-if="activeTab !== 'orders'" variant="hero" class="gap-2 sm:px-8" @click="handleAddNew">
-          <Plus class="w-4 h-4" />Add New
+          <Plus class="w-4 h-4" />{{ t("admin.addNew") }}
         </Button>
       </div>
 
@@ -49,60 +49,96 @@
       <!-- ORDERS -->
       <div v-else-if="activeTab === 'orders'" class="grid gap-6">
         <div v-for="order in filteredOrders" :key="order.id"
-          class="group relative bg-card/40 backdrop-blur-md border border-border/50 rounded-3xl overflow-hidden hover:border-primary/30 transition-all duration-300">
+          @mouseenter="focusedOrderId = order.id"
+          @mouseleave="focusedOrderId = null"
+          :class="[
+            'group relative bg-card/40 backdrop-blur-md border rounded-3xl overflow-hidden transition-all duration-300',
+            focusedOrderId === order.id ? 'border-primary ring-1 ring-primary/20 shadow-glow' : 'border-border/50'
+          ]">
+          <!-- Paste Indicator -->
+          <div v-if="focusedOrderId === order.id" class="absolute top-2 right-2 z-50 pointer-events-none animate-pulse">
+            <div class="px-2 py-1 bg-primary/20 backdrop-blur-md border border-primary/50 rounded-lg flex items-center gap-1.5 shadow-lg">
+              <span class="text-[9px] font-black text-primary uppercase tracking-widest">Ctrl+V — Photo Report</span>
+            </div>
+          </div>
           <div class="flex flex-col lg:flex-row divide-y lg:divide-y-0 lg:divide-x divide-border/50">
             <!-- Info -->
             <div class="p-6 lg:w-1/4">
               <div class="flex items-center justify-between mb-4">
-                <span class="text-xs font-bold text-muted-foreground border border-border/50 rounded-full px-2 py-0.5 uppercase tracking-tighter">Order #{{ order.id }}</span>
+                <span class="text-xs font-bold text-muted-foreground border border-border/50 rounded-full px-2 py-0.5 uppercase tracking-tighter">{{ t("common.orderId", { id: order.id }) }}</span>
                 <span :class="`flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider ${statusColor(order.status)}`">
-                  <component :is="statusIcon(order.status)" class="w-3.5 h-3.5" />{{ order.status }}
+                  <component :is="statusIcon(order.status)" class="w-3.5 h-3.5" />{{ t('admin.statuses.' + order.status) }}
                 </span>
               </div>
-              <div class="space-y-1">
                 <div class="flex items-center gap-2">
-                  <h3 class="font-bold">{{ order.first_name }} {{ order.last_name }}</h3>
+                  <div class="flex flex-col">
+                    <h3 class="font-bold leading-none">{{ order.first_name }} {{ order.last_name }}</h3>
+                    <p class="text-[10px] text-muted-foreground mt-1">{{ order.email }}</p>
+                  </div>
                   <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>
+                  
+                  <!-- Chat Permission Toggle -->
+                  <button v-if="order.user_id" 
+                    @click="handleToggleUserChat(order.user_id, order.can_chat)"
+                    class="ml-auto p-1.5 rounded-lg hover:bg-muted transition-colors group/chat-perm"
+                    :title="order.can_chat ? 'Forbid Chat for this user' : 'Allow Chat for this user'">
+                    <component :is="order.can_chat ? ToggleRight : ToggleLeft" 
+                      class="w-5 h-5 transition-transform group-hover/chat-perm:scale-110" 
+                      :class="order.can_chat ? 'text-emerald-500' : 'text-muted-foreground'" />
+                  </button>
                 </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>
             </div>
             <!-- Details -->
             <div class="p-6 lg:w-1/4 space-y-4">
               <div class="flex justify-between items-start">
-                <div>
-                  <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">Selected Material</span>
-                  <div class="flex items-center gap-2">
-                    <Layers class="w-3.5 h-3.5 text-primary" />
-                    <p class="text-sm font-bold uppercase">{{ order.material_name || "unknown" }}</p>
-                    <span class="text-[10px] text-muted-foreground">(@ {{ order.material_price || "0.00" }})</span>
+                <div class="relative group/params">
+                  <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">Material & Color</span>
+                  <div class="flex flex-col gap-1">
+                    <div class="flex items-center gap-2">
+                      <Layers class="w-3.5 h-3.5 text-primary" />
+                      <p class="text-sm font-bold uppercase">{{ order.material_name || "unknown" }}</p>
+                      <button @click="editingParams = { id: order.id, material_id: order.material_id, color_name: order.color_name || '', quantity: order.quantity || 1 }" class="p-1 hover:bg-primary/10 rounded-md text-primary transition-colors">
+                        <Edit2 class="w-3 h-3" />
+                      </button>
+                    </div>
+                    <div class="flex items-center gap-2">
+                      <div class="w-3.5 h-3.5 rounded-full border border-border/50 bg-muted/20" :style="order.color_name ? { backgroundColor: order.color_name.toLowerCase() } : {}"></div>
+                      <p class="text-xs font-medium text-muted-foreground">{{ order.color_name || t("admin.fields.defaultColor") }}</p>
+                    </div>
+                  </div>
+                  
+                  <!-- Original Snapshot Indicator -->
+                  <div v-if="order.original_params" class="mt-2">
+                    <button @click="showOriginalParams(order.original_params)" class="flex items-center gap-1.5 text-[9px] font-bold text-primary/60 hover:text-primary transition-colors mb-2">
+                      <History class="w-3 h-3" /> {{ t("admin.actions.viewOriginal") }}
+                    </button>
                   </div>
                 </div>
                 <div class="text-right">
-                  <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">Quantity</span>
+                  <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">{{ t("admin.fields.quantity") }}</span>
                   <div class="flex items-center justify-end gap-1.5 px-2 py-1 bg-primary/10 rounded-lg text-primary font-bold">
                     <Hash class="w-3 h-3" /><span class="text-sm">{{ order.quantity || 1 }}</span>
                   </div>
                 </div>
               </div>
               <div>
-                <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">Shipping Address</span>
+                <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">{{ t("admin.fields.shippingAddress") }}</span>
                 <p class="text-xs text-muted-foreground line-clamp-2">{{ order.shipping_address }}</p>
               </div>
               <div v-if="order.notes" class="p-3 bg-background/50 border border-border/50 rounded-xl">
-                <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">Project Notes</span>
+                <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">{{ t("admin.fields.projectNotes") }}</span>
                 <p class="text-[11px] text-muted-foreground italic">"{{ order.notes }}"</p>
               </div>
               <div class="flex items-center gap-2">
                 <ShieldCheck :class="`w-4 h-4 ${order.allow_portfolio ? 'text-emerald-500' : 'text-muted-foreground/30'}`" />
-                <span class="text-xs">{{ order.allow_portfolio ? "Portfolio Allowed" : "No Portfolio" }}</span>
+                <span class="text-xs">{{ order.allow_portfolio ? t("admin.fields.portfolioAllowed") : t("admin.fields.noPortfolio") }}</span>
               </div>
             </div>
             <!-- Resources -->
             <div class="p-6 lg:w-1/4 space-y-6">
                 <div class="flex items-center justify-between mb-3">
-                  <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Source Files ({{ order.files?.length || 0 }})</span>
+                  <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.sourceFiles") }} ({{ order.files?.length || 0 }})</span>
                   <label class="p-1.5 bg-blue-500/10 text-blue-500 rounded-lg cursor-pointer hover:bg-blue-500 hover:text-white transition-all">
                     <Plus class="w-3 h-3" />
                     <input type="file" class="hidden" accept=".stl,.obj" @change="e => handleAttachFile(order.id, (e.target as HTMLInputElement).files?.[0])" />
@@ -110,7 +146,7 @@
                 </div>
                 <!-- Model Link (if provided) -->
                 <div v-if="order.model_link" class="mb-4 p-3 bg-blue-500/5 border border-blue-500/20 rounded-xl">
-                  <span class="text-[10px] font-bold uppercase tracking-widest text-blue-500/60 mb-1 block">External Model Link</span>
+                  <span class="text-[10px] font-bold uppercase tracking-widest text-blue-500/60 mb-1 block">{{ t("admin.fields.externalLink") }}</span>
                   <div class="flex items-center justify-between gap-2 overflow-hidden">
                     <p class="text-[10px] text-muted-foreground truncate">{{ order.model_link }}</p>
                     <a :href="order.model_link" target="_blank" class="text-blue-500 hover:underline"><ExternalLink class="w-3.5 h-3.5" /></a>
@@ -140,7 +176,7 @@
                     <!-- Delete Actions & Quantity -->
                     <div class="absolute top-2 right-2 flex flex-col items-end gap-1">
                       <div class="px-1.5 py-0.5 bg-background/80 backdrop-blur-md rounded-md border border-border/50 text-[10px] font-bold">x{{ f.quantity || 1 }}</div>
-                      <button @click.prevent="handleDeleteFile(order.id, f.file_id, f.filename)" class="p-1 bg-background/80 backdrop-blur-md border border-border/50 text-rose-500 hover:bg-rose-500 hover:text-white rounded-md transition-colors shadow-sm" title="Delete attached file">
+                      <button @click.prevent="handleDeleteFile(order.id, f.file_id, f.filename)" class="p-1 bg-background/80 backdrop-blur-md border border-border/50 text-rose-500 hover:bg-rose-500 hover:text-white rounded-md transition-colors shadow-sm" :title="t('admin.actions.deleteFile')">
                         <Trash2 class="w-2.5 h-2.5" />
                       </button>
                     </div>
@@ -148,7 +184,7 @@
                 </div>
               <div class="pt-4 border-t border-border/50">
                 <div class="flex items-center justify-between mb-3">
-                  <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Photo Report ({{ order.photos?.length || 0 }})</span>
+                  <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.photoReport") }} ({{ order.photos?.length || 0 }})</span>
                   <label class="p-1.5 bg-primary/10 text-primary rounded-lg cursor-pointer hover:bg-primary hover:text-primary-foreground transition-all">
                     <Plus class="w-3 h-3" />
                     <input type="file" class="hidden" accept="image/*" @change="e => handleUploadPhoto(order.id, (e.target as HTMLInputElement).files?.[0])" />
@@ -161,12 +197,17 @@
                       :class="`absolute top-0 right-0 p-0.5 rounded-bl-md bg-black/60 z-10 transition-colors ${p.is_public ? 'text-blue-400 hover:text-blue-300' : 'text-gray-400 hover:text-white'}`">
                       <Eye v-if="p.is_public" class="w-2.5 h-2.5" /><EyeOff v-else class="w-2.5 h-2.5" />
                     </button>
-                    <a :href="`http://localhost:8000/${p.file_path}`" target="_blank" class="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover/img:opacity-100 transition-opacity">
-                      <ExternalLink class="w-4 h-4 text-white" />
-                    </a>
+                    <div class="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover/img:opacity-100 transition-opacity gap-2">
+                       <a :href="`http://localhost:8000/${p.file_path}`" target="_blank" class="w-7 h-7 bg-white/10 hover:bg-white/20 rounded-full flex items-center justify-center transition-colors">
+                          <ExternalLink class="w-3.5 h-3.5 text-white" />
+                       </a>
+                       <button @click="handleDeletePhoto(p.id)" class="w-7 h-7 bg-rose-500/20 hover:bg-rose-500/40 rounded-full flex items-center justify-center transition-colors">
+                          <Trash2 class="w-3.5 h-3.5 text-white" />
+                       </button>
+                    </div>
                   </div>
                   <div v-if="!order.photos?.length" class="w-full py-4 border border-dashed border-border/50 rounded-xl flex flex-col items-center justify-center opacity-40">
-                    <ImageIcon class="w-4 h-4 mb-1" /><span class="text-[10px]">No photos yet</span>
+                    <ImageIcon class="w-4 h-4 mb-1" /><span class="text-[10px]">{{ t("admin.fields.noPhotos") }}</span>
                   </div>
                 </div>
               </div>
@@ -175,7 +216,7 @@
             <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>
+                <label :for="`notify-${order.id}`" class="text-[9px] font-bold uppercase text-muted-foreground cursor-pointer">{{ t("admin.fields.notifyUser") }}</label>
               </div>
               <div class="grid grid-cols-2 gap-2 mb-6">
                 <button v-for="s in Object.keys(STATUS_CONFIG)" :key="s"
@@ -186,18 +227,18 @@
               </div>
               <div class="pt-4 border-t border-border/50">
                 <div class="flex justify-between items-center mb-1">
-                  <span class="text-[10px] font-bold text-muted-foreground uppercase">Estimated</span>
+                  <span class="text-[10px] font-bold text-muted-foreground uppercase">{{ t("admin.fields.estimated") }}</span>
                   <span class="font-bold text-sm text-primary/80">{{ order.estimated_price }} EUR</span>
                 </div>
                 <div class="flex justify-between items-center">
-                  <span class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">Final Price</span>
-                  <button @click="editingPrice = { id: order.id, price: order.total_price?.toString() ?? '' }" class="text-[10px] text-primary hover:underline font-bold">Edit</button>
+                  <span class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">{{ t("admin.fields.finalPrice") }}</span>
+                  <button @click="editingPrice = { id: order.id, price: order.total_price?.toString() ?? '' }" class="text-[10px] text-primary hover:underline font-bold">{{ t("admin.actions.edit") }}</button>
                 </div>
                 <div class="text-2xl font-display font-bold">{{ order.total_price || "---" }} <span class="text-xs">EUR</span></div>
                 
                 <a v-if="order.invoice_path" :href="`http://localhost:8000/${order.invoice_path}`" target="_blank"
                    class="mt-4 w-full flex items-center justify-center gap-2 py-2 rounded-xl bg-primary/10 hover:bg-primary/20 text-primary border border-primary/20 font-bold transition-all text-sm">
-                  <FileText class="w-4 h-4" /> Print Uplatnica
+                  <FileText class="w-4 h-4" /> {{ t("admin.actions.printInvoice") }}
                 </a>
 
                 <button @click="toggleAdminChat(order.id)" :class="['w-full flex items-center justify-center gap-2 py-2 rounded-xl border border-border/50 hover:border-primary/30 text-sm font-bold transition-all relative', order.invoice_path ? 'mt-2' : 'mt-4', adminChatId === order.id ? 'bg-primary text-primary-foreground' : 'bg-background/50 text-muted-foreground hover:text-foreground']">
@@ -229,7 +270,7 @@
           </div>
           <div class="flex items-center gap-8">
             <div class="text-right">
-              <p class="text-[10px] font-bold text-muted-foreground uppercase">Price / cm³</p>
+              <p class="text-[10px] font-bold text-muted-foreground uppercase">{{ t("admin.fields.pricePerCm3") }}</p>
               <p class="font-display font-bold text-lg">{{ m.price_per_cm3 }} EUR</p>
             </div>
             <div class="flex items-center gap-2">
@@ -266,6 +307,83 @@
           </div>
         </div>
       </div>
+      <!-- USERS -->
+      <div v-else-if="activeTab === 'users'" class="space-y-6">
+        <div class="flex items-center gap-4 bg-card/40 p-4 rounded-2xl border border-border/50">
+          <div class="relative flex-1">
+            <Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
+            <input v-model="userSearch" :placeholder="t('admin.searchUsersPlaceholder')" class="w-full bg-background/50 border border-border/50 rounded-xl pl-10 pr-4 py-2 text-sm focus:ring-2 ring-primary/20 outline-none" />
+          </div>
+          <div class="text-xs font-bold text-muted-foreground uppercase">{{ t("admin.total") }}: {{ usersResult.total }} ({{ usersResult.users?.length }})</div>
+        </div>
+
+        <div v-if="!isLoading && usersResult.users?.length === 0" class="flex flex-col items-center justify-center py-20 bg-card/40 border border-border/50 rounded-3xl">
+          <Users class="w-12 h-12 text-muted-foreground/20 mb-4" />
+          <p class="text-sm text-muted-foreground">{{ t("admin.fields.noUsers") }}</p>
+        </div>
+
+        <div v-else class="bg-card/40 backdrop-blur-md border border-border/50 rounded-3xl overflow-hidden shadow-xl">
+          <div class="overflow-x-auto">
+            <table class="w-full text-left border-collapse">
+              <thead>
+                <tr class="bg-muted/30 border-b border-border/50">
+                  <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.user") }}</th>
+                  <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.contact") }}</th>
+                  <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.role") }}</th>
+                  <th class="p-4 text-[10px] font-bold uppercase tracking-wider text-center">{{ t("admin.labels.chat") }}</th>
+                  <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.registered") }}</th>
+                  <th class="p-4 text-[10px] font-bold uppercase tracking-wider text-right">{{ t("admin.labels.actions") }}</th>
+                </tr>
+              </thead>
+              <tbody class="divide-y divide-border/30">
+                <tr v-for="u in usersResult.users" :key="u.id" class="hover:bg-primary/5 transition-colors group/row">
+                  <td class="p-4">
+                    <div class="flex flex-col">
+                      <span class="text-sm font-bold">{{ u.first_name }} {{ u.last_name }}</span>
+                      <span class="text-[10px] text-muted-foreground font-mono">ID: {{ u.id }}</span>
+                    </div>
+                  </td>
+                  <td class="p-4">
+                    <div class="flex flex-col text-xs">
+                      <span class="font-medium">{{ u.email }}</span>
+                      <span class="text-muted-foreground">{{ u.phone }}</span>
+                    </div>
+                  </td>
+                  <td class="p-4">
+                    <span :class="`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase ${u.role === 'admin' ? 'bg-rose-500/10 text-rose-500 border border-rose-500/20' : 'bg-muted text-muted-foreground border border-border/50'}`">{{ u.role }}</span>
+                  </td>
+                  <td class="p-4 text-center">
+                    <button @click="handleToggleUserChat(u.id, u.can_chat)" class="inline-flex">
+                      <component :is="u.can_chat ? ToggleRight : ToggleLeft" class="w-6 h-6" :class="u.can_chat ? 'text-emerald-500' : 'text-muted-foreground'" />
+                    </button>
+                  </td>
+                  <td class="p-4 text-xs text-muted-foreground">
+                    {{ new Date(u.created_at).toLocaleDateString() }}
+                  </td>
+                  <td class="p-4 text-right">
+                    <div class="flex items-center justify-end gap-2">
+                      <button @click="handleUpdateUserRole(u.id, u.role === 'admin' ? 'user' : 'admin')" 
+                        class="p-2 rounded-lg hover:bg-muted text-muted-foreground hover:text-primary transition-all" 
+                        :title="t('admin.actions.toggleAdminRole')">
+                        <ShieldCheck class="w-4 h-4" />
+                      </button>
+                    </div>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        </div>
+        
+        <!-- Pagination -->
+        <div v-if="usersResult.total > 50" class="flex items-center justify-center gap-2 py-4">
+           <button v-for="p in Math.ceil(usersResult.total / 50)" :key="p" 
+             @click="userPage = p" 
+             :class="['w-8 h-8 rounded-lg font-bold text-xs transition-all', userPage === p ? 'bg-primary text-white' : 'bg-card border border-border/50 text-muted-foreground hover:border-primary/50']">
+             {{ p }}
+           </button>
+        </div>
+      </div>
 
       <!-- POSTS -->
       <div v-else-if="activeTab === 'posts'" class="grid gap-4">
@@ -294,6 +412,45 @@
           </div>
         </div>
       </div>
+
+      <!-- PORTFOLIO -->
+      <div v-else-if="activeTab === 'portfolio'" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
+        <div v-for="pi in portfolioItems" :key="pi.id" class="group relative aspect-square bg-card/40 border border-border/50 rounded-2xl overflow-hidden hover:border-primary/30 transition-all">
+           <img :src="`http://localhost:8000/${pi.file_path}`" class="w-full h-full object-cover" />
+           
+           <!-- Status Overlay -->
+           <div class="absolute top-2 left-2 flex gap-1">
+             <span :class="`px-2 py-0.5 rounded-full text-[8px] font-bold uppercase ${pi.is_public ? 'bg-emerald-500 text-white shadow-glow' : 'bg-black/40 text-white/40'}`">
+               {{ pi.is_public ? 'Public' : 'Private' }}
+             </span>
+             <span v-if="!pi.allow_portfolio" class="px-2 py-0.5 rounded-full text-[8px] font-bold uppercase bg-rose-500 text-white">
+               No Consent
+             </span>
+           </div>
+
+           <!-- Actions Overlay -->
+           <div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center p-4 gap-3">
+              <div class="text-center mb-1">
+                 <p class="text-[9px] font-bold text-white uppercase tracking-widest opacity-60">Order #{{ pi.order_id }}</p>
+                 <p class="text-xs font-bold text-white truncate max-w-full">{{ pi.first_name }} {{ pi.last_name }}</p>
+              </div>
+              <div class="flex items-center gap-2">
+                 <button @click="handleTogglePhotoPublic(pi.id, pi.is_public, pi.allow_portfolio)" 
+                    :class="`p-2 rounded-xl transition-all ${pi.is_public ? 'bg-emerald-500 text-white' : 'bg-white/10 text-white hover:bg-white/20'}`"
+                    :title="pi.is_public ? 'Make Private' : 'Make Public'">
+                    <Eye v-if="pi.is_public" class="w-4 h-4" /><EyeOff v-else class="w-4 h-4" />
+                 </button>
+                 <a :href="`http://localhost:8000/${pi.file_path}`" target="_blank" class="p-2 bg-white/10 text-white hover:bg-white/20 rounded-xl transition-all">
+                    <ExternalLink class="w-4 h-4" />
+                 </a>
+                 <button @click="handleDeletePhoto(pi.id)" class="p-2 bg-rose-500/20 text-rose-500 hover:bg-rose-500 hover:text-white rounded-xl transition-all">
+                    <Trash2 class="w-4 h-4" />
+                 </button>
+              </div>
+              <p class="text-[9px] font-bold text-primary uppercase mt-1">{{ pi.material_name }}</p>
+           </div>
+        </div>
+      </div>
     </main>
 
     <!-- ——— MODALS ——— -->
@@ -303,19 +460,77 @@
         <div v-if="editingPrice" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
           <div class="absolute inset-0 bg-background/80 backdrop-blur-sm" @click="editingPrice = null" />
           <div class="relative w-full max-w-sm bg-card border border-border/50 rounded-3xl p-8 shadow-2xl">
-            <h3 class="text-xl font-bold font-display mb-6">Update Final Price</h3>
+            <h3 class="text-xl font-bold font-display mb-6">{{ t("admin.fields.updateFinalPrice") }}</h3>
             <div class="space-y-4">
               <input v-model="editingPrice.price" type="number" step="0.01"
                 class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 font-bold" />
               <div class="flex gap-3">
                 <Button variant="ghost" class="flex-1" @click="editingPrice = null">Cancel</Button>
-                <Button variant="hero" class="flex-1" @click="handleUpdatePrice">Save Price</Button>
+                <Button variant="hero" class="flex-1" @click="handleUpdatePrice">{{ t("admin.actions.savePrice") }}</Button>
+              </div>
+            </div>
+          </div>
+        </div>
+      </Transition>
+
+      <!-- Params Modal (Material & Color) -->
+      <Transition enter-active-class="transition duration-150" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
+        <div v-if="editingParams" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
+          <div class="absolute inset-0 bg-background/80 backdrop-blur-sm" @click="editingParams = null" />
+          <div class="relative w-full max-w-sm bg-card border border-border/50 rounded-3xl p-8 shadow-2xl">
+            <h3 class="text-xl font-bold font-display mb-6">{{ t("admin.modals.changeParams") }}</h3>
+            <div class="space-y-4">
+              <div class="space-y-1">
+                <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.selectMaterialStrict") }}</label>
+                <select v-model="editingParams.material_id"
+                  class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 font-bold">
+                  <option v-for="m in materials" :key="m.id" :value="m.id">{{ m['name_' + locale] || m.name_en }} (@ {{ m.price_per_cm3 }})</option>
+                </select>
+              </div>
+              <div class="space-y-1" v-if="selectedMatColors.length">
+                <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.selectColorStrict") }}</label>
+                <select v-model="editingParams.color_name"
+                  class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 font-bold">
+                  <option v-for="c in selectedMatColors" :key="c" :value="c">{{ c }}</option>
+                </select>
+              </div>
+              <div class="space-y-1" v-else>
+                <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.customColorDirInfo") }}</label>
+                <input v-model="editingParams.color_name" type="text" :placeholder="t('admin.fields.customColorPlaceholder')"
+                  class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 font-bold" />
+              </div>
+              <div class="space-y-1">
+                <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.quantity") }}</label>
+                <input v-model.number="editingParams.quantity" type="number" min="1"
+                  class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 font-bold" />
+              </div>
+              <p class="text-[10px] text-muted-foreground italic">{{ t("admin.fields.strictSelectionInfo") }}</p>
+              <div class="flex gap-3 pt-2">
+                <Button variant="ghost" class="flex-1" @click="editingParams = null">{{ t("admin.actions.cancel") }}</Button>
+                <Button variant="hero" class="flex-1" @click="handleUpdateParams">{{ t("admin.actions.saveChanges") }}</Button>
               </div>
             </div>
           </div>
         </div>
       </Transition>
 
+      <!-- Original Params Snapshot Viewer -->
+      <Transition enter-active-class="transition duration-150" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
+        <div v-if="viewingOriginal" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
+          <div class="absolute inset-0 bg-background/80 backdrop-blur-sm" @click="viewingOriginal = null" />
+          <div class="relative w-full max-w-md bg-card border border-border/50 rounded-3xl p-8 shadow-2xl overflow-hidden">
+            <div class="flex items-center justify-between mb-6">
+              <h3 class="text-xl font-bold font-display">{{ t("admin.fields.originalSnapshot") }}</h3>
+              <button @click="viewingOriginal = null" class="p-2 hover:bg-muted rounded-full transition-colors"><X class="w-5 h-5" /></button>
+            </div>
+            <div class="bg-muted/30 rounded-2xl p-4 font-mono text-xs max-h-[60vh] overflow-y-auto">
+              <pre>{{ JSON.stringify(viewingOriginal, null, 2) }}</pre>
+            </div>
+            <p class="mt-4 text-[11px] text-muted-foreground">These are the parameters recorded at the moment of order submission.</p>
+          </div>
+        </div>
+      </Transition>
+
       <!-- Material Modal -->
       <Transition enter-active-class="transition duration-150" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
         <div v-if="editingMaterial || (showAddModal && activeTab === 'materials')" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
@@ -325,24 +540,49 @@
             <form @submit.prevent="handleSaveMaterial" class="space-y-6">
               <!-- Names -->
               <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Name (EN)</label><input v-model="matForm.name_en" required placeholder="PLA" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Name (RU)</label><input v-model="matForm.name_ru" required placeholder="ПЛА" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Name (UA)</label><input v-model="matForm.name_ua" required placeholder="ПЛА" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Name (ME)</label><input v-model="matForm.name_me" required placeholder="PLA" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.name") }} (EN)</label><input v-model="matForm.name_en" required placeholder="PLA Standard" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.name") }} (RU)</label><input v-model="matForm.name_ru" required placeholder="PLA Стандарт" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.name") }} (UA)</label><input v-model="matForm.name_ua" required placeholder="PLA Стандарт" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.name") }} (ME)</label><input v-model="matForm.name_me" required placeholder="PLA Standard" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
               </div>
               
               <!-- Price & Status -->
               <div class="grid grid-cols-2 gap-4">
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Price per cm³ (EUR)</label><input v-model.number="matForm.price_per_cm3" type="number" step="0.001" required placeholder="0.05" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
-                <div class="flex items-center gap-2 pt-6"><input v-model="matForm.is_active" type="checkbox" id="mat_active" class="w-5 h-5 rounded border-border" /><label for="mat_active" class="text-sm font-bold">Active and Visible</label></div>
+                <div class="space-y-1">
+                  <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.price") }}</label>
+                  <input v-model.number="matForm.price_per_cm3" type="number" step="0.01" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" />
+                </div>
+                <div class="flex items-center gap-2 pt-6"><input v-model="matForm.is_active" type="checkbox" id="mat_active" class="w-5 h-5 rounded border-border" /><label for="mat_active" class="text-sm font-bold">{{ t("admin.fields.active") }}</label></div>
+              </div>
+
+              <!-- Available Colors -->
+              <div class="space-y-1">
+                <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.colors") }}</label>
+                <div class="flex flex-wrap gap-2 p-3 bg-background border border-border/50 rounded-xl min-h-[50px]">
+                  <div v-for="(c, idx) in matForm.available_colors" :key="idx" class="group/color flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-primary/5 text-primary border border-primary/20 hover:border-primary/40 transition-all">
+                    <div class="w-2 h-2 rounded-full border border-primary/30" :style="{ backgroundColor: c.toLowerCase() }"></div>
+                    <span class="text-xs font-bold capitalize">{{ c }}</span>
+                    <button type="button" @click="removeColor(idx)" class="ml-1 opacity-40 hover:opacity-100 hover:text-rose-500 transition-all">
+                      <X class="w-3 h-3" />
+                    </button>
+                  </div>
+                  <input 
+                    v-model="newColor" 
+                    @keydown.enter.prevent="addColor" 
+                    @keydown.comma.prevent="addColor"
+                    placeholder="Add color..." 
+                    class="flex-1 min-w-[100px] bg-transparent border-none focus:ring-0 text-sm font-medium p-1" 
+                  />
+                </div>
+                <p class="text-[10px] text-muted-foreground italic px-1">Press Enter or use comma to add a color. Type "Blue" or hex codes like "#0000ff".</p>
               </div>
 
               <!-- Descriptions -->
               <div class="space-y-4">
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Description (EN)</label><textarea v-model="matForm.desc_en" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Description (RU)</label><textarea v-model="matForm.desc_ru" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Description (UA)</label><textarea v-model="matForm.desc_ua" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Description (ME)</label><textarea v-model="matForm.desc_me" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.description") }} (EN)</label><textarea v-model="matForm.desc_en" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.description") }} (RU)</label><textarea v-model="matForm.desc_ru" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.description") }} (UA)</label><textarea v-model="matForm.desc_ua" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.description") }} (ME)</label><textarea v-model="matForm.desc_me" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
               </div>
 
               <div class="flex gap-3 pt-4"><Button type="button" variant="ghost" class="flex-1" @click="closeModals">Cancel</Button><Button type="submit" variant="hero" class="flex-1">Save Changes</Button></div>
@@ -356,25 +596,25 @@
         <div v-if="editingService || (showAddModal && activeTab === 'services')" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
           <div class="absolute inset-0 bg-background/80 backdrop-blur-sm" @click="closeModals" />
           <div class="relative w-full max-w-lg bg-card border border-border/50 rounded-3xl p-8 shadow-2xl">
-            <h3 class="text-xl font-bold font-display mb-6">{{ editingService?.id ? "Edit Service" : "Add New Service" }}</h3>
+            <h3 class="text-xl font-bold font-display mb-6">{{ editingService?.id ? t("admin.modals.editService") : t("admin.modals.createService") }}</h3>
             <form @submit.prevent="handleSaveService" class="space-y-6">
               <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Name (EN)</label><input v-model="svcForm.name_en" required placeholder="FDM Printing" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Name (RU)</label><input v-model="svcForm.name_ru" required placeholder="FDM Печать" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Name (UA)</label><input v-model="svcForm.name_ua" required placeholder="FDM Друк" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Name (ME)</label><input v-model="svcForm.name_me" required placeholder="FDM Štampa" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.name") }} (EN)</label><input v-model="svcForm.name_en" required placeholder="FDM Printing" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.name") }} (RU)</label><input v-model="svcForm.name_ru" required placeholder="FDM Печать" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.name") }} (UA)</label><input v-model="svcForm.name_ua" required placeholder="FDM Друк" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.name") }} (ME)</label><input v-model="svcForm.name_me" required placeholder="FDM Štampa" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
               </div>
               
               <div class="grid grid-cols-2 gap-4">
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Tech Type</label><input v-model="svcForm.tech_type" placeholder="FDM" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
-                <div class="flex items-center gap-2 pt-6"><input v-model="svcForm.is_active" type="checkbox" id="srv_active" class="w-5 h-5 rounded border-border" /><label for="srv_active" class="text-sm font-bold">Active and Visible</label></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.techType") }}</label><input v-model="svcForm.tech_type" placeholder="FDM" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
+                <div class="flex items-center gap-2 pt-6"><input v-model="svcForm.is_active" type="checkbox" id="srv_active" class="w-5 h-5 rounded border-border" /><label for="srv_active" class="text-sm font-bold">{{ t("admin.fields.active") }}</label></div>
               </div>
 
               <div class="space-y-4">
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Description (EN)</label><textarea v-model="svcForm.desc_en" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Description (RU)</label><textarea v-model="svcForm.desc_ru" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Description (UA)</label><textarea v-model="svcForm.desc_ua" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Description (ME)</label><textarea v-model="svcForm.desc_me" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.description") }} (EN)</label><textarea v-model="svcForm.desc_en" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.description") }} (RU)</label><textarea v-model="svcForm.desc_ru" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.description") }} (UA)</label><textarea v-model="svcForm.desc_ua" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.description") }} (ME)</label><textarea v-model="svcForm.desc_me" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
               </div>
 
               <div class="flex gap-3 pt-4"><Button type="button" variant="ghost" class="flex-1" @click="closeModals">Cancel</Button><Button type="submit" variant="hero" class="flex-1">Save Changes</Button></div>
@@ -388,44 +628,44 @@
         <div v-if="editingPost || (showAddModal && activeTab === 'posts')" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
           <div class="absolute inset-0 bg-background/80 backdrop-blur-sm" @click="closeModals" />
           <div class="relative w-full max-w-4xl bg-card border border-border/50 rounded-3xl p-8 shadow-2xl max-h-[90vh] overflow-y-auto">
-            <h3 class="text-xl font-bold font-display mb-6">{{ editingPost?.id ? "Edit Blog Post" : "Create New Post" }}</h3>
+            <h3 class="text-xl font-bold font-display mb-6">{{ editingPost?.id ? t("admin.modals.editPost") : t("admin.modals.createPost") }}</h3>
             <form @submit.prevent="handleSavePost" class="space-y-6">
               
               <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
                 <div class="space-y-1">
-                  <label class="text-[10px] font-bold uppercase ml-1">Slug (URL)</label>
+                  <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.slug") }}</label>
                   <input v-model="postForm.slug" required placeholder="my-new-post" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" />
                 </div>
                 <div class="space-y-1">
-                  <label class="text-[10px] font-bold uppercase ml-1">Category</label>
+                  <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.category") }}</label>
                   <input v-model="postForm.category" required placeholder="Technology" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" />
                 </div>
               </div>
 
               <div class="space-y-1">
-                <label class="text-[10px] font-bold uppercase ml-1">Image URL</label>
+                <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.imageUrl") }}</label>
                 <input v-model="postForm.image_url" placeholder="https://ex.com/img.jpg" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" />
               </div>
 
               <!-- Titles -->
               <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Title (EN)</label><input v-model="postForm.title_en" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Title (ME)</label><input v-model="postForm.title_me" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Title (RU)</label><input v-model="postForm.title_ru" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Title (UA)</label><input v-model="postForm.title_ua" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.title") }} (EN)</label><input v-model="postForm.title_en" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.title") }} (ME)</label><input v-model="postForm.title_me" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.title") }} (RU)</label><input v-model="postForm.title_ru" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.title") }} (UA)</label><input v-model="postForm.title_ua" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
               </div>
 
               <!-- Excerpts -->
               <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Excerpt (EN)</label><textarea v-model="postForm.excerpt_en" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Excerpt (ME)</label><textarea v-model="postForm.excerpt_me" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Excerpt (RU)</label><textarea v-model="postForm.excerpt_ru" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Excerpt (UA)</label><textarea v-model="postForm.excerpt_ua" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.excerpt") }} (EN)</label><textarea v-model="postForm.excerpt_en" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.excerpt") }} (ME)</label><textarea v-model="postForm.excerpt_me" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.excerpt") }} (RU)</label><textarea v-model="postForm.excerpt_ru" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.excerpt") }} (UA)</label><textarea v-model="postForm.excerpt_ua" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
               </div>
 
               <!-- Content -->
               <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Content (EN)</label><textarea v-model="postForm.content_en" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[200px]" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.content") }} (EN)</label><textarea v-model="postForm.content_en" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[200px]" /></div>
                 <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Content (ME)</label><textarea v-model="postForm.content_me" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[200px]" /></div>
                 <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Content (RU)</label><textarea v-model="postForm.content_ru" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[200px]" /></div>
                 <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Content (UA)</label><textarea v-model="postForm.content_ua" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[200px]" /></div>
@@ -433,12 +673,44 @@
 
               <div class="flex items-center gap-2">
                 <input v-model="postForm.is_published" type="checkbox" id="post_published" class="w-5 h-5 rounded border-border" />
-                <label for="post_published" class="text-sm font-bold">Publish immediately</label>
+                <label for="post_published" class="text-sm font-bold">{{ t("admin.fields.publishImmediately") }}</label>
               </div>
 
               <div class="flex gap-3 pt-4">
-                <Button type="button" variant="ghost" class="flex-1" @click="closeModals">Cancel</Button>
-                <Button type="submit" variant="hero" class="flex-1">Save Post</Button>
+                <Button type="button" variant="ghost" class="flex-1" @click="closeModals">{{ t("admin.actions.cancel") }}</Button>
+                <Button type="submit" variant="hero" class="flex-1">{{ t("admin.actions.save") }}</Button>
+              </div>
+            </form>
+          </div>
+        </div>
+      </Transition>
+      <!-- User Modal -->
+      <Transition enter-active-class="transition duration-150" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
+        <div v-if="showAddModal && activeTab === 'users'" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
+          <div class="absolute inset-0 bg-background/80 backdrop-blur-sm" @click="closeModals" />
+          <div class="relative w-full max-w-md bg-card border border-border/50 rounded-3xl p-8 shadow-2xl">
+            <h3 class="text-xl font-bold font-display mb-6">{{ t("admin.modals.createUser") }}</h3>
+            <form @submit.prevent="handleSaveUser" class="space-y-4">
+              <div class="space-y-1">
+                <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.email") }}</label>
+                <input v-model="userForm.email" type="email" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" />
+              </div>
+              <div class="space-y-1">
+                <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.password") }}</label>
+                <input v-model="userForm.password" type="password" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" />
+              </div>
+              <div class="grid grid-cols-2 gap-4">
+                 <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.firstName") }}</label><input v-model="userForm.first_name" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
+                 <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.lastName") }}</label><input v-model="userForm.last_name" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
+              </div>
+              <div class="space-y-1">
+                <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.phone") }}</label>
+                <input v-model="userForm.phone" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" />
+              </div>
+
+              <div class="flex gap-3 pt-4">
+                <Button type="button" variant="ghost" class="flex-1" @click="closeModals">{{ t("admin.actions.cancel") }}</Button>
+                <Button type="submit" variant="hero" class="flex-1">{{ t("admin.actions.create") }}</Button>
               </div>
             </form>
           </div>
@@ -451,11 +723,11 @@
 </template>
 
 <script setup lang="ts">
-import { ref, computed, watch, reactive, onMounted } from "vue";
+import { ref, computed, watch, reactive, onMounted, onUnmounted } from "vue";
 import { RouterLink, useRouter } from "vue-router";
 import { useI18n } from "vue-i18n";
 import { toast } from "vue-sonner";
-import { Package, Clock, CheckCircle2, Truck, XCircle, AlertCircle, FileText, ExternalLink, ShieldCheck, Eye, RefreshCw, Search, Filter, Layers, Settings, Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Database, Image as ImageIcon, EyeOff, Hash, MessageCircle, FileBox, Download, Newspaper } from "lucide-vue-next";
+import { Package, Clock, CheckCircle2, Truck, XCircle, AlertCircle, FileText, ExternalLink, ShieldCheck, Eye, RefreshCw, Search, Filter, Layers, Settings, Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Database, Image as ImageIcon, EyeOff, Hash, MessageCircle, FileBox, Download, Newspaper, History, X, Users } from "lucide-vue-next";
 import Button from "@/components/ui/button.vue";
 import Header from "@/components/Header.vue";
 import Footer from "@/components/Footer.vue";
@@ -464,10 +736,10 @@ import { useAuthStore } from "@/stores/auth";
 import { 
   adminGetOrders, adminUpdateOrder, adminGetMaterials, adminCreateMaterial, adminUpdateMaterial, adminDeleteMaterial, 
   adminGetServices, adminCreateService, adminUpdateService, adminDeleteService, adminUploadOrderPhoto, 
-  adminUpdatePhotoStatus, adminAttachFile, adminDeleteFile, getBlogPosts, adminCreatePost, adminUpdatePost, adminDeletePost 
+  adminUpdatePhotoStatus, adminDeletePhoto, adminGetAllPhotos, adminAttachFile, adminDeleteFile, getBlogPosts, adminCreatePost, adminUpdatePost, adminDeletePost, adminUpdateUser, adminGetUsers, adminCreateUser 
 } from "@/lib/api";
 
-const { t } = useI18n();
+const { t, locale } = useI18n();
 const router = useRouter();
 const authStore = useAuthStore();
 
@@ -488,29 +760,40 @@ function toggleAdminChat(orderId: number) {
 }
 
 const tabs: { id: Tab; label: string; icon: any }[] = [
-  { id: "orders",    label: "Orders",    icon: Package },
-  { id: "materials", label: "Materials", icon: Layers },
-  { id: "services",  label: "Services",  icon: Database },
-  { id: "posts",     label: "Blog",      icon: Newspaper },
+  { id: "orders",    label: t("admin.tabs.orders"),    icon: Package },
+  { id: "materials", label: t("admin.tabs.materials"), icon: Layers },
+  { id: "services",  label: t("admin.tabs.services"),  icon: Database },
+  { id: "portfolio", label: t("admin.tabs.portfolio"), icon: ImageIcon },
+  { id: "users",     label: t("admin.tabs.users"),     icon: Users },
+  { id: "posts",     label: t("admin.tabs.posts"),     icon: Newspaper },
 ];
 
-type Tab = "orders" | "materials" | "services" | "posts";
+type Tab = "orders" | "materials" | "services" | "posts" | "users" | "portfolio";
 const activeTab    = ref<Tab>("orders");
 const orders       = ref<any[]>([]);
 const materials    = ref<any[]>([]);
 const services     = ref<any[]>([]);
 const posts        = ref<any[]>([]);
+const portfolioItems = ref<any[]>([]);
+
+const usersResult  = ref({ users: [] as any[], total: 0, page: 1, size: 50 });
+const userSearch   = ref("");
+const userPage     = ref(1);
 const isLoading    = ref(true);
 const searchQuery  = ref("");
 const statusFilter = ref("all");
 const editingPrice    = ref<{ id: number; price: string } | null>(null);
+const editingParams   = ref<{ id: number; material_id: number; color_name: string; quantity: number } | null>(null);
+const focusedOrderId = ref<number | null>(null);
+const viewingOriginal = ref<any | null>(null);
 const editingMaterial = ref<any | null>(null);
 const editingService  = ref<any | null>(null);
 const editingPost     = ref<any | null>(null);
 const showAddModal    = ref(false);
 const notifyStatusMap = ref<Record<number, boolean>>({});
 
-const matForm = reactive({ name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", price_per_cm3: 0, is_active: true });
+const matForm = reactive({ name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", price_per_cm3: 0, available_colors: [] as string[], is_active: true });
+const newColor = ref("");
 const svcForm = reactive({ name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", tech_type: "", is_active: true });
 const postForm = reactive({
   slug: "",
@@ -520,6 +803,14 @@ const postForm = reactive({
   category: "Technology", image_url: "", is_published: true
 });
 
+const userForm = reactive({
+  email: "",
+  password: "",
+  first_name: "",
+  last_name: "",
+  phone: ""
+});
+
 const filteredOrders = computed(() => orders.value.filter(o => {
   const qs = searchQuery.value.toLowerCase();
   const matchSearch = o.email?.toLowerCase().includes(qs) || o.first_name?.toLowerCase().includes(qs) || o.last_name?.toLowerCase().includes(qs) || String(o.id).includes(qs);
@@ -527,75 +818,162 @@ const filteredOrders = computed(() => orders.value.filter(o => {
   return matchSearch && matchStatus;
 }));
 
+const selectedMatColors = computed(() => {
+  if (!editingParams.value?.material_id) return [];
+  const mat = materials.value.find(m => m.id === editingParams.value?.material_id);
+  return mat?.available_colors || [];
+});
+
+function addColor() {
+  const val = newColor.value.trim();
+  if (!val) return;
+  if (!matForm.available_colors) matForm.available_colors = [];
+  if (!matForm.available_colors.includes(val)) {
+    matForm.available_colors.push(val);
+  }
+  newColor.value = "";
+}
+
+function removeColor(index: number) {
+  matForm.available_colors.splice(index, 1);
+}
+
 async function fetchData() {
   isLoading.value = true;
   try {
-    if (activeTab.value === "orders") {
+    const currentTab = activeTab.value;
+    if (currentTab === "orders") {
       orders.value = await adminGetOrders();
+      materials.value = await adminGetMaterials(); // Needed for changing order params
       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();
-    else if (activeTab.value === "posts")     posts.value     = await getBlogPosts(false);
-  } catch { toast.error(`Failed to load ${activeTab.value}`); }
+    else if (currentTab === "materials") materials.value = await adminGetMaterials();
+    else if (currentTab === "services")  services.value  = await adminGetServices();
+    else if (currentTab === "posts")     posts.value     = await getBlogPosts(false);
+    else if (currentTab === "portfolio") portfolioItems.value = await adminGetAllPhotos();
+    else if (currentTab === "users")     await fetchUsers();
+  } catch (err) { 
+    console.error(err);
+    toast.error(t("admin.toasts.loadError", { tab: activeTab.value })); 
+  }
   finally { isLoading.value = false; }
 }
 
+async function fetchUsers() {
+  try {
+    const res = await adminGetUsers(userPage.value, 50, userSearch.value);
+    usersResult.value = res;
+  } catch {
+    toast.error(t("admin.toasts.loadError", { tab: "users" }));
+  }
+}
+
+watch([userPage, userSearch], () => {
+  if (activeTab.value === 'users') fetchUsers();
+});
+
 watch(activeTab, fetchData, { immediate: false });
 onMounted(async () => {
   if (!authStore.user || authStore.user.role !== "admin") { router.push("/auth"); return; }
   fetchData();
 });
 
+function handlePaste(event: ClipboardEvent) {
+  const active = document.activeElement;
+  if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return;
+  if (!focusedOrderId.value) return;
+  const items = event.clipboardData?.items;
+  if (!items) return;
+  for (const item of items) {
+    if (item.type.indexOf('image') !== -1) {
+      const blob = item.getAsFile();
+      if (blob) handleUploadPhoto(focusedOrderId.value, blob);
+    }
+  }
+}
+
+onMounted(() => {
+  window.addEventListener('paste', handlePaste);
+});
+onUnmounted(() => {
+  window.removeEventListener('paste', handlePaste);
+});
+
 async function handleUpdateStatus(id: number, status: string) {
   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"); }
+  try { await adminUpdateOrder(id, { status, send_notification: notify }); toast.success(t("admin.toasts.statusUpdated", { status })); fetchData(); }
+  catch { toast.error(t("admin.toasts.genericError")); }
 }
 async function handleUpdatePrice() {
   if (!editingPrice.value) return;
   const p = parseFloat(editingPrice.value.price);
-  if (isNaN(p)) { toast.error("Invalid price"); return; }
-  try { await adminUpdateOrder(editingPrice.value.id, { total_price: p }); toast.success("Price updated"); editingPrice.value = null; fetchData(); }
-  catch { toast.error("Failed to update price"); }
+  if (isNaN(p)) { toast.error(t("admin.toasts.genericError")); return; }
+  try { await adminUpdateOrder(editingPrice.value.id, { total_price: p }); toast.success(t("admin.toasts.priceUpdated")); editingPrice.value = null; fetchData(); }
+  catch { toast.error(t("admin.toasts.genericError")); }
+}
+async function handleUpdateParams() {
+  if (!editingParams.value) return;
+  try {
+    await adminUpdateOrder(editingParams.value.id, {
+      material_id: editingParams.value.material_id,
+      color_name: editingParams.value.color_name,
+      quantity: editingParams.value.quantity
+    });
+    toast.success(t("admin.toasts.paramsUpdated"));
+    editingParams.value = null;
+    fetchData();
+  } catch { toast.error(t("admin.toasts.genericError")); }
+}
+function showOriginalParams(snapshotStr: string) {
+  try {
+    viewingOriginal.value = JSON.parse(snapshotStr);
+  } catch {
+    toast.error(t("admin.toasts.genericError"));
+  }
 }
 async function handleTogglePhotoPublic(photoId: number, current: boolean, allowed: boolean) {
-  if (!allowed && !current) { toast.error("User did not consent to portfolio"); return; }
-  try { await adminUpdatePhotoStatus(photoId, { is_public: !current }); toast.success(`Photo is now ${!current ? "Public" : "Private"}`); fetchData(); }
-  catch (e: any) { toast.error(e.message); }
+  if (!allowed && !current) { toast.error(t("admin.toasts.noConsent")); return; }
+  try { await adminUpdatePhotoStatus(photoId, { is_public: !current }); toast.success(t("admin.toasts.statusUpdated")); fetchData(); }
+  catch (e: any) { toast.error(e.message || t("admin.toasts.genericError")); }
+}
+
+async function handleDeletePhoto(photoId: number) {
+  if (!window.confirm(t("admin.questions.deletePhoto"))) return;
+  try { await adminDeletePhoto(photoId); toast.success(t("admin.toasts.photoDeleted")); fetchData(); }
+  catch { toast.error(t("admin.toasts.genericError")); }
 }
 async function handleUploadPhoto(orderId: number, file?: File) {
   if (!file) return;
   const order = orders.value.find(o => o.id === orderId);
   const fd = new FormData(); fd.append("file", file); fd.append("is_public", order?.allow_portfolio ? "true" : "false");
-  try { await adminUploadOrderPhoto(orderId, fd); toast.success("Photo added"); fetchData(); }
+  try { await adminUploadOrderPhoto(orderId, fd); toast.success(t("admin.toasts.photoAdded")); fetchData(); }
   catch (e: any) { toast.error(e.message); }
 }
 async function handleAttachFile(orderId: number, file?: File) {
   if (!file) return;
   const fd = new FormData(); fd.append("file", file);
-  try { await adminAttachFile(orderId, fd); toast.success("File attached and preview generated"); fetchData(); }
+  try { await adminAttachFile(orderId, fd); toast.success(t("admin.toasts.fileAttached")); fetchData(); }
   catch (e: any) { toast.error(e.message); }
 }
 async function handleDeleteFile(orderId: number, fileId: number, filename: string) {
   if (!window.confirm(`Delete attached file "${filename}"?`)) return;
-  try { await adminDeleteFile(orderId, fileId); toast.success("File deleted successfully"); fetchData(); }
-  catch { toast.error("Failed to delete file"); }
+  try { await adminDeleteFile(orderId, fileId); toast.success(t("admin.toasts.fileDeleted")); fetchData(); }
+  catch { toast.error(t("admin.toasts.genericError")); }
 }
 async function handleDeleteMaterial(id: number, name: string) {
   if (!window.confirm(`Delete material "${name}"?`)) return;
-  try { await adminDeleteMaterial(id); toast.success("Deleted"); fetchData(); }
-  catch { toast.error("Failed to delete"); }
+  try { await adminDeleteMaterial(id); toast.success(t("admin.toasts.materialDeleted")); fetchData(); }
+  catch { toast.error(t("admin.toasts.genericError")); }
 }
 async function handleDeleteService(id: number, name: string) {
   if (!window.confirm(`Delete service "${name}"?`)) return;
-  try { await adminDeleteService(id); toast.success("Deleted"); fetchData(); }
-  catch { toast.error("Failed to delete"); }
+  try { await adminDeleteService(id); toast.success(t("admin.toasts.serviceDeleted")); fetchData(); }
+  catch { toast.error(t("admin.toasts.genericError")); }
 }
 async function handleDeletePost(id: number, title: string) {
   if (!window.confirm(`Delete post "${title}"?`)) return;
-  try { await adminDeletePost(id); toast.success("Post deleted"); fetchData(); }
-  catch { toast.error("Failed to delete post"); }
+  try { await adminDeletePost(id); toast.success(t("admin.toasts.postDeleted")); fetchData(); }
+  catch { toast.error(t("admin.toasts.genericError")); }
 }
 async function toggleMaterialActive(m: any) { await adminUpdateMaterial(m.id, { is_active: !m.is_active }); fetchData(); }
 async function toggleServiceActive(s: any)  { await adminUpdateService(s.id,  { is_active: !s.is_active  }); fetchData(); }
@@ -603,7 +981,8 @@ async function togglePostActive(p: any)     { await adminUpdatePost(p.id,    { .
 
 function handleAddNew() {
   if (activeTab.value === 'materials') {
-    Object.assign(matForm, { name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", price_per_cm3: 0, is_active: true });
+    Object.assign(matForm, { name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", price_per_cm3: 0, available_colors: [], is_active: true });
+    newColor.value = "";
     editingMaterial.value = null;
   } else if (activeTab.value === 'services') {
     Object.assign(svcForm, { name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", tech_type: "", is_active: true });
@@ -611,35 +990,73 @@ function handleAddNew() {
   } else if (activeTab.value === 'posts') {
     Object.assign(postForm, { slug: "", title_en: "", title_me: "", title_ru: "", title_ua: "", excerpt_en: "", excerpt_me: "", excerpt_ru: "", excerpt_ua: "", content_en: "", content_me: "", content_ru: "", content_ua: "", category: "Technology", image_url: "", is_published: true });
     editingPost.value = null;
+  } else if (activeTab.value === 'users') {
+    Object.assign(userForm, { email: "", password: "", first_name: "", last_name: "", phone: "" });
   }
   showAddModal.value = true;
 }
 
 async function handleSaveMaterial() {
   try {
-    if (editingMaterial.value?.id) { await adminUpdateMaterial(editingMaterial.value.id, { ...matForm }); toast.success("Material updated"); }
-    else { await adminCreateMaterial({ ...matForm }); toast.success("Material created"); }
+    if (editingMaterial.value?.id) { await adminUpdateMaterial(editingMaterial.value.id, { ...matForm }); toast.success(t("admin.toasts.materialSaved")); }
+    else { await adminCreateMaterial({ ...matForm }); toast.success(t("admin.toasts.materialSaved")); }
     closeModals(); fetchData();
-  } catch { toast.error("Failed to save material"); }
+  } catch { toast.error(t("admin.toasts.genericError")); }
 }
 async function handleSaveService() {
   try {
-    if (editingService.value?.id) { await adminUpdateService(editingService.value.id, { ...svcForm }); toast.success("Service updated"); }
-    else { await adminCreateService({ ...svcForm }); toast.success("Service created"); }
+    if (editingService.value?.id) { await adminUpdateService(editingService.value.id, { ...svcForm }); toast.success(t("admin.toasts.serviceSaved")); }
+    else { await adminCreateService({ ...svcForm }); toast.success(t("admin.toasts.serviceSaved")); }
     closeModals(); fetchData();
-  } catch { toast.error("Failed to save service"); }
+  } catch { toast.error(t("admin.toasts.genericError")); }
 }
 async function handleSavePost() {
   try {
-    if (editingPost.value?.id) { await adminUpdatePost(editingPost.value.id, { ...postForm }); toast.success("Post updated"); }
-    else { await adminCreatePost({ ...postForm }); toast.success("Post created"); }
+    if (editingPost.value?.id) { await adminUpdatePost(editingPost.value.id, { ...postForm }); toast.success(t("admin.toasts.postSaved")); }
+    else { await adminCreatePost({ ...postForm }); toast.success(t("admin.toasts.postSaved")); }
     closeModals(); fetchData();
-  } catch { toast.error("Failed to save post"); }
+  } catch { toast.error(t("admin.toasts.genericError")); }
+}
+
+async function handleSaveUser() {
+  try {
+    await adminCreateUser({ ...userForm });
+    toast.success(t("admin.toasts.userCreated"));
+    closeModals(); fetchUsers();
+  } catch (err: any) {
+    toast.error(err.message || t("admin.toasts.genericError"));
+  }
 }
 
-function closeModals() { editingMaterial.value = null; editingService.value = null; editingPost.value = null; showAddModal.value = false; editingPrice.value = null; }
+async function handleToggleUserChat(userId: number, current: boolean) {
+  try {
+    await adminUpdateUser(userId, { can_chat: !current });
+    toast.success(t(`admin.toasts.chat${!current ? "Enabled" : "Disabled"}`, { id: userId }));
+    if (activeTab.value === 'users') fetchUsers();
+    else fetchData();
+  } catch {
+    toast.error(t("admin.toasts.genericError"));
+  }
+}
 
-watch(editingMaterial, m => { if (m) Object.assign(matForm, m); });
+async function handleUpdateUserRole(userId: number, role: string) {
+  try {
+    await adminUpdateUser(userId, { role });
+    toast.success(t("admin.toasts.roleUpdated", { id: userId, role }));
+    if (activeTab.value === 'users') fetchUsers();
+  } catch {
+    toast.error(t("admin.toasts.genericError"));
+  }
+}
+
+function closeModals() { editingMaterial.value = null; editingService.value = null; editingPost.value = null; showAddModal.value = false; editingPrice.value = null; editingParams.value = null; viewingOriginal.value = null; }
+
+watch(editingMaterial, m => { 
+  if (m) {
+    Object.assign(matForm, m);
+    newColor.value = "";
+  }
+});
 watch(editingService,  s => { if (s) Object.assign(svcForm, s); });
 watch(editingPost,     p => { if (p) Object.assign(postForm, p); });
 </script>

+ 10 - 10
src/pages/Auth.vue

@@ -19,7 +19,7 @@
 
       <div class="flex justify-center mb-10 text-center flex-col items-center">
         <Logo />
-        <p class="text-[10px] uppercase tracking-[0.2em] text-muted-foreground mt-2 opacity-50">3D Printing Studio</p>
+        <p class="text-[10px] uppercase tracking-[0.2em] text-muted-foreground mt-2 opacity-50">{{ t("auth.studio") }}</p>
       </div>
 
       <div class="bg-card/40 backdrop-blur-xl border border-border/50 rounded-3xl p-8 shadow-card overflow-hidden">
@@ -86,7 +86,7 @@
             <div class="space-y-2 pt-2">
               <div class="flex justify-between items-center px-1">
                 <label class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
-                  {{ mode === "reset" ? "New Password" : t("auth.fields.password") }}
+                  {{ mode === "reset" ? t("auth.fields.newPassword") : t("auth.fields.password") }}
                 </label>
                 <button v-if="mode === 'login'" type="button" @click="mode = 'forgot'"
                   class="text-[11px] text-primary hover:underline font-medium">{{ t("auth.forgot.link") }}</button>
@@ -130,11 +130,11 @@
             <div class="relative">
               <div class="absolute inset-0 flex items-center"><span class="w-full border-t border-border/50" /></div>
               <div class="relative flex justify-center text-[10px] uppercase tracking-widest">
-                <span class="bg-card/40 px-2 text-muted-foreground backdrop-blur-md">{{ t("auth.orContinueWith") || "Or continue with" }}</span>
+                <span class="bg-card/40 px-2 text-muted-foreground backdrop-blur-md">{{ t("auth.orContinueWith") }}</span>
               </div>
             </div>
             <div class="grid grid-cols-2 gap-3">
-              <button type="button" @click="toast.info('Google login coming soon')"
+              <button type="button" @click="toast.info(t('auth.toasts.socialSoon', { provider: 'Google' }))"
                 class="flex items-center justify-center gap-2 bg-background/50 hover:bg-background/80 border border-border/50 rounded-xl py-2.5 px-4 transition-all hover:scale-[1.02] active:scale-[0.98]">
                 <svg class="w-4 h-4" viewBox="0 0 24 24">
                   <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
@@ -144,7 +144,7 @@
                 </svg>
                 <span class="text-xs font-medium">Google</span>
               </button>
-              <button type="button" @click="toast.info('Facebook login coming soon')"
+              <button type="button" @click="toast.info(t('auth.toasts.socialSoon', { provider: 'Facebook' }))"
                 class="flex items-center justify-center gap-2 bg-background/50 hover:bg-background/80 border border-border/50 rounded-xl py-2.5 px-4 transition-all hover:scale-[1.02] active:scale-[0.98]">
                 <svg class="w-4 h-4 fill-[#1877F2]" viewBox="0 0 24 24">
                   <path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
@@ -208,7 +208,7 @@ function toggleMode() {
 
 async function handleSubmit() {
   if ((mode.value === "register" || mode.value === "reset") && formData.password !== formData.confirmPassword) {
-    toast.error("Passwords do not match"); return;
+    toast.error(t("auth.toasts.passwordsNoMatch")); return;
   }
   isLoading.value = true;
   try {
@@ -216,19 +216,19 @@ async function handleSubmit() {
       const res = await loginUser({ email: formData.email, password: formData.password });
       localStorage.setItem("token", res.access_token);
       await authStore.refreshUser();
-      toast.success("Welcome back!");
+      toast.success(t("auth.toasts.welcomeBack"));
       router.push("/");
     } else if (mode.value === "register") {
       await registerUser({ email: formData.email, password: formData.password, first_name: formData.firstName, last_name: formData.lastName, phone: formData.phone, shipping_address: formData.address, preferred_language: currentLanguage() });
-      toast.success("Account created! Please log in.");
+      toast.success(t("auth.toasts.accountCreated"));
       mode.value = "login";
     } else if (mode.value === "forgot") {
       const res = await forgotPassword(formData.email);
-      toast.success(res.message);
+      toast.success(t("auth.toasts.resetLinkSent"));
       if (res.demo_token) { formData.token = res.demo_token; mode.value = "reset"; }
     } else if (mode.value === "reset") {
       await resetPassword({ token: formData.token, new_password: formData.password });
-      toast.success("Password changed! Please log in.");
+      toast.success(t("auth.toasts.passwordChanged"));
       mode.value = "login";
     }
   } catch (err: any) {

+ 3 - 3
src/pages/Blog.vue

@@ -14,9 +14,9 @@
         </div>
 
         <!-- Loading State -->
-        <div v-if="isLoading" class="flex flex-col items-center justify-center py-20 opacity-50">
-          <RefreshCw class="w-10 h-10 animate-spin text-primary mb-4" />
-          <p class="font-bold tracking-widest uppercase text-[10px]">Loading articles...</p>
+        <div v-if="isLoading" class="flex flex-col items-center justify-center py-24 gap-4 opacity-50">
+          <RefreshCw class="w-8 h-8 text-primary animate-spin mb-4" />
+          <p class="font-bold tracking-widest uppercase text-[10px]">{{ t("blog.loading") }}</p>
         </div>
 
         <template v-else>

+ 3 - 3
src/pages/BlogPost.vue

@@ -10,7 +10,7 @@
 
         <div v-if="isLoading" class="flex flex-col items-center justify-center py-24 opacity-50">
           <RefreshCw class="w-10 h-10 animate-spin text-primary mb-4" />
-          <p class="font-bold tracking-widest uppercase text-[10px]">Loading article...</p>
+          <p class="font-bold tracking-widest uppercase text-[10px]">{{ t("blog.loadingSingle") }}</p>
         </div>
 
         <article v-else-if="post">
@@ -38,8 +38,8 @@
         </article>
 
         <div v-else class="py-24 text-center bg-gray-50 rounded-[2.5rem] border border-dashed border-black/[0.05]">
-          <h2 class="text-2xl font-display font-bold text-foreground mb-4">Post not found</h2>
-          <router-link to="/blog" class="text-primary font-bold hover:underline italic">Explore other articles</router-link>
+          <h2 class="text-2xl font-display font-bold text-foreground mb-4">{{ t("blog.notFound") }}</h2>
+          <router-link to="/blog" class="text-primary font-bold hover:underline italic">{{ t("blog.exploreOther") }}</router-link>
         </div>
       </div>
     </div>

+ 5 - 3
src/pages/NotFound.vue

@@ -1,15 +1,17 @@
 <template>
   <div class="flex min-h-screen items-center justify-center bg-muted">
     <div class="text-center">
-      <h1 class="mb-4 text-4xl font-bold">404</h1>
-      <p class="mb-4 text-xl text-muted-foreground">Oops! Page not found</p>
-      <RouterLink to="/" class="text-primary underline hover:text-primary/90">Return to Home</RouterLink>
+      <h1 class="mb-4 text-4xl font-bold">{{ t("errors.404.title") }}</h1>
+      <p class="mb-4 text-xl text-muted-foreground">{{ t("errors.404.subtitle") }}</p>
+      <RouterLink to="/" class="text-primary underline hover:text-primary/90">{{ t("errors.404.button") }}</RouterLink>
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
 import { useRoute } from "vue-router";
+import { useI18n } from "vue-i18n";
 const route = useRoute();
+const { t } = useI18n();
 console.error("404 Error: User attempted to access non-existent route:", route.path);
 </script>

+ 26 - 14
src/pages/Orders.vue

@@ -8,14 +8,14 @@
             <ArrowLeft class="w-4 h-4 mr-2 group-hover:-translate-x-1 transition-transform" />{{ t("auth.back") }}
           </RouterLink>
           <h1 class="font-display text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/60">
-            {{ t("nav.myOrders") || "My Orders" }}
+            {{ t("nav.myOrders") }}
           </h1>
-          <p class="text-muted-foreground mt-2">Track your 3D printing projects</p>
+          <p class="text-muted-foreground mt-2">{{ t("orders.titleSubtitle") }}</p>
         </div>
 
         <div v-if="isLoading" class="flex flex-col items-center justify-center py-20 gap-4">
           <Loader2 class="w-10 h-10 animate-spin text-primary" />
-          <p class="text-muted-foreground animate-pulse">Loading order history...</p>
+          <p class="text-muted-foreground animate-pulse">{{ t("orders.loading") }}</p>
         </div>
 
         <div v-else-if="orders.length === 0"
@@ -24,9 +24,11 @@
           <div class="w-20 h-20 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-6">
             <Package class="w-10 h-10 text-primary opacity-60" />
           </div>
-          <h2 class="text-2xl font-bold mb-2">No orders yet</h2>
-          <p class="text-muted-foreground mb-8 max-w-sm mx-auto">Once you start a 3D printing project, you'll be able to track its progress right here.</p>
-          <Button variant="hero" :as="RouterLink" to="/#upload">Start New Project</Button>
+          <h2 class="text-2xl font-bold mb-2">{{ t("orders.noOrders") }}</h2>
+          <p class="text-muted-foreground mb-8 max-w-sm mx-auto">{{ t("orders.startProjectDesc") }}</p>
+          <button @click="router.push('/#upload')" class="bg-primary hover:bg-primary/90 text-primary-foreground px-8 py-3 rounded-xl font-bold transition-all transform hover:scale-105">
+            {{ t("orders.startProject") }}
+          </button>
         </div>
 
         <div v-else class="space-y-4">
@@ -46,25 +48,35 @@
                   <Package class="w-6 h-6 text-primary" />
                 </div>
                 <div>
-                  <h3 class="font-bold text-lg leading-none mb-1">Order #{{ order.id }}</h3>
+                  <h3 class="font-bold text-lg leading-none mb-1">{{ t("common.orderId", { id: order.id }) }}</h3>
                   <p class="text-xs text-muted-foreground">{{ formatDate(order.created_at) }}</p>
                 </div>
               </div>
 
               <div class="flex flex-wrap items-center gap-4 md:gap-8">
                 <div class="flex flex-col">
-                  <span class="text-[10px] uppercase tracking-wider text-muted-foreground font-bold mb-1">Quantity</span>
+                  <span class="text-[10px] uppercase tracking-wider text-muted-foreground font-bold mb-1">{{ t("orders.labels.quantity") }}</span>
                   <div class="flex items-center gap-1.5 px-2.5 py-1 bg-primary/10 rounded-lg text-primary font-bold">
                     <Hash class="w-3 h-3" /><span class="text-sm">{{ order.quantity || 1 }}</span>
                   </div>
                 </div>
                 <div class="flex flex-col">
-                  <span class="text-[10px] uppercase tracking-wider text-muted-foreground font-bold mb-1">Status</span>
+                  <span class="text-[10px] uppercase tracking-wider text-muted-foreground font-bold mb-1">{{ t("orders.labels.status") }}</span>
                   <StatusBadge :status="order.status" />
                 </div>
                 <div class="flex flex-col">
-                  <span class="text-[10px] uppercase tracking-wider text-muted-foreground font-bold mb-1">Estimate</span>
-                  <span class="font-display font-bold text-lg">{{ order.total_price ? `${order.total_price} €` : "Pending..." }}</span>
+                  <span class="text-[10px] uppercase tracking-wider text-muted-foreground font-bold mb-1">{{ t("orders.labels.estimate") }}</span>
+                  <span class="font-display font-bold text-lg">{{ order.total_price ? `${order.total_price} €` : t("common.pending") }}</span>
+                </div>
+                <div class="flex flex-col border-l border-border/50 pl-4">
+                  <span class="text-[10px] uppercase tracking-wider text-muted-foreground font-bold mb-1">{{ t("orders.labels.materialColor") }}</span>
+                  <div class="flex flex-col">
+                    <span class="text-xs font-bold uppercase text-primary">{{ order.material_name || "PLA" }}</span>
+                    <div class="flex items-center gap-1.5 mt-0.5">
+                      <div class="w-2.5 h-2.5 rounded-full border border-border/50" :style="order.color_name ? { backgroundColor: order.color_name.toLowerCase() } : { backgroundColor: '#ccc' }"></div>
+                      <span class="text-[9px] text-muted-foreground font-medium">{{ order.color_name || t("common.default") }}</span>
+                    </div>
+                  </div>
                 </div>
                 <div class="flex items-center gap-2">
                   <a v-if="order.model_link && !order.model_link.startsWith('javascript:')"
@@ -85,7 +97,7 @@
             <div v-if="order.notes" class="mt-8 p-4 bg-primary/5 border border-primary/10 rounded-2xl relative z-10">
               <div class="flex items-center gap-2 mb-2">
                 <FileText class="w-3.5 h-3.5 text-primary" />
-                <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">My Notes</span>
+                <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("orders.labels.myNotes") }}</span>
               </div>
               <p class="text-xs text-muted-foreground italic leading-relaxed">"{{ order.notes }}"</p>
             </div>
@@ -99,7 +111,7 @@
             <div v-if="order.files && order.files.length > 0" class="mt-8 pt-6 border-t border-border/50 relative z-10">
               <div class="flex items-center gap-2 mb-4">
                 <FileBox class="w-3.5 h-3.5 text-primary" />
-                <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">My Project Files</span>
+                <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("orders.labels.projectFiles") }}</span>
               </div>
               <div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
                 <div v-for="file in order.files" :key="file.id" 
@@ -129,7 +141,7 @@
             <div v-if="order.photos && order.photos.length > 0" class="mt-8 pt-6 border-t border-border/50 relative z-10">
               <div class="flex items-center gap-2 mb-4">
                 <ImageIcon class="w-3.5 h-3.5 text-primary" />
-                <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Progress Report</span>
+                <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("orders.labels.progressReport") }}</span>
               </div>
               <div class="flex flex-wrap gap-3">
                 <div v-for="photo in order.photos" :key="photo.id"

+ 11 - 9
src/pages/Portfolio.vue

@@ -6,19 +6,19 @@
         <div v-motion :initial="{ opacity: 0, scale: 0.9 }" :enter="{ opacity: 1, scale: 1 }"
           class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 border border-primary/20 text-primary mb-6">
           <Sparkles class="w-4 h-4" />
-          <span class="text-xs font-display font-medium tracking-wider uppercase">{{ t("nav.portfolio") || "Showcase" }}</span>
+          <span class="text-xs font-display font-medium tracking-wider uppercase">{{ t("nav.portfolio") }}</span>
         </div>
         <h1 class="font-display text-4xl sm:text-5xl lg:text-6xl font-bold mb-6">
-          {{ t("portfolio.title") || "Public" }} <span class="text-gradient">{{ t("portfolio.titleGradient") || "Portfolio" }}</span>
+          {{ t("portfolio.title") }} <span class="text-gradient">{{ t("portfolio.titleGradient") }}</span>
         </h1>
         <p class="text-muted-foreground max-w-2xl mx-auto text-lg leading-relaxed">
-          {{ t("portfolio.description") || "Explore our successful 3D printing projects." }}
+          {{ t("portfolio.description") }}
         </p>
       </div>
 
       <div v-if="isLoading" class="flex flex-col items-center justify-center py-24 gap-4 opacity-50">
-        <Loader2 class="w-10 h-10 animate-spin text-primary" />
-        <p class="text-sm font-medium animate-pulse">Loading gallery...</p>
+        <RefreshCw class="w-8 h-8 text-primary animate-spin mb-4" />
+        <p class="text-sm font-medium animate-pulse">{{ t("portfolio.loading") }}</p>
       </div>
 
       <div v-else-if="items.length > 0" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
@@ -36,7 +36,7 @@
           <div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-all duration-500 flex flex-col justify-end p-6">
             <span class="text-[10px] font-bold uppercase tracking-widest text-primary mb-1">{{ item.material_name }}</span>
             <div class="flex items-center justify-between">
-              <p class="text-white font-bold">{{ item.order_id ? `Order #${item.order_id}` : 'Radionica3D' }}</p>
+              <p class="text-white font-bold">{{ item.order_id ? t("common.orderId", { id: item.order_id }) : 'Radionica3D' }}</p>
               <ExternalLink class="w-4 h-4 text-white/70" />
             </div>
           </div>
@@ -44,9 +44,11 @@
       </div>
 
       <div v-else class="text-center py-32 bg-card/20 border border-dashed border-border/50 rounded-[40px]">
-        <Camera class="w-12 h-12 text-muted-foreground mb-4 mx-auto opacity-20" />
-        <h3 class="text-xl font-bold mb-2">Our gallery is growing</h3>
-        <p class="text-muted-foreground">Check back soon for more amazing prints!</p>
+        <div class="w-20 h-20 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-6">
+          <ImageIcon class="w-10 h-10 text-primary opacity-60" />
+        </div>
+        <h3 class="text-xl font-bold mb-2">{{ t("portfolio.emptyTitle") }}</h3>
+        <p class="text-muted-foreground">{{ t("portfolio.emptyDesc") }}</p>
       </div>
     </main>
 

+ 2 - 2
src/pages/Privacy.vue

@@ -17,8 +17,8 @@
             </p>
           </div>
           <div class="bg-white p-8 rounded-3xl border border-black/[0.04] shadow-sm hover:shadow-md transition-shadow">
-            <h3 class="text-xs font-bold uppercase tracking-widest text-primary mb-2">Need assistance?</h3>
-            <p class="text-sm text-foreground/60 mb-6 font-medium">If you have any questions or wish to exercise your data rights, please reach out to our dedicated team.</p>
+            <h3 class="text-xs font-bold uppercase tracking-widest text-primary mb-2">{{ t("privacy.contactTitle") }}</h3>
+            <p class="text-sm text-foreground/60 mb-6 font-medium">{{ t("privacy.contactDesc") }}</p>
             <a href="mailto:Hello@radionica3d.me" class="inline-flex items-center gap-2 px-6 py-3 bg-foreground text-background rounded-full text-sm font-bold hover:bg-primary transition-colors group">
               <Mail class="w-4 h-4" />
               Hello@radionica3d.me

+ 21 - 15
src/router/index.ts

@@ -3,21 +3,21 @@ import { createRouter, createWebHistory } from "vue-router";
 const router = createRouter({
   history: createWebHistory(),
   routes: [
-    { path: "/",          component: () => import("@/pages/Index.vue") },
-    { path: "/auth",      component: () => import("@/pages/Auth.vue") },
-    { path: "/orders",    component: () => import("@/pages/Orders.vue") },
-    { path: "/portfolio", component: () => import("@/pages/Portfolio.vue") },
-    { path: "/admin",     component: () => import("@/pages/Admin.vue") },
-    { path: "/privacy",   component: () => import("@/pages/Privacy.vue") },
-    { path: "/about",     component: () => import("@/pages/About.vue") },
-    { path: "/careers",   component: () => import("@/pages/Careers.vue") },
-    { path: "/blog",      component: () => import("@/pages/Blog.vue") },
-    { path: "/blog/:id",  component: () => import("@/pages/BlogPost.vue") },
-    { path: "/contact",   component: () => import("@/pages/Contact.vue") },
-    { path: "/help",      component: () => import("@/pages/HelpCenter.vue") },
-    { path: "/guidelines", component: () => import("@/pages/Guidelines.vue") },
-    { path: "/terms",     component: () => import("@/pages/Terms.vue") },
-    { path: "/:pathMatch(.*)*", component: () => import("@/pages/NotFound.vue") },
+    { path: "/",          component: () => import("@/pages/Index.vue"),     meta: { title: "Home" } },
+    { path: "/auth",      component: () => import("@/pages/Auth.vue"),      meta: { title: "Authentication" } },
+    { path: "/orders",    component: () => import("@/pages/Orders.vue"),    meta: { title: "My Orders" } },
+    { path: "/portfolio", component: () => import("@/pages/Portfolio.vue"), meta: { title: "Portfolio" } },
+    { path: "/admin",     component: () => import("@/pages/Admin.vue"),     meta: { title: "Admin Panel" } },
+    { path: "/privacy",   component: () => import("@/pages/Privacy.vue"),   meta: { title: "Privacy Policy" } },
+    { path: "/about",     component: () => import("@/pages/About.vue"),     meta: { title: "About Us" } },
+    { path: "/careers",   component: () => import("@/pages/Careers.vue"),   meta: { title: "Careers" } },
+    { path: "/blog",      component: () => import("@/pages/Blog.vue"),      meta: { title: "Blog" } },
+    { path: "/blog/:id",  component: () => import("@/pages/BlogPost.vue"),  meta: { title: "Blog Post" } },
+    { path: "/contact",   component: () => import("@/pages/Contact.vue"),   meta: { title: "Contact" } },
+    { path: "/help",      component: () => import("@/pages/HelpCenter.vue"), meta: { title: "Help Center" } },
+    { path: "/guidelines", component: () => import("@/pages/Guidelines.vue"), meta: { title: "Guidelines" } },
+    { path: "/terms",     component: () => import("@/pages/Terms.vue"),      meta: { title: "Terms of Service" } },
+    { path: "/:pathMatch(.*)*", component: () => import("@/pages/NotFound.vue"), meta: { title: "Not Found" } },
   ],
   scrollBehavior(to) {
     if (to.hash) {
@@ -27,4 +27,10 @@ const router = createRouter({
   },
 });
 
+router.afterEach((to) => {
+  const baseTitle = "Radionica 3D";
+  const pageTitle = to.meta.title ? `${to.meta.title} | ${baseTitle}` : baseTitle;
+  document.title = pageTitle;
+});
+
 export default router;

+ 24 - 4
src/stores/auth.ts

@@ -7,21 +7,24 @@ export const useAuthStore = defineStore("auth", () => {
   const isLoading = ref(true);
   const showCompleteProfile = ref(false);
   let initialized = false;
+  const wsAuthFailed = ref(false);
 
   async function refreshUser() {
     try {
       const userData = await getCurrentUser();
       user.value = userData;
+      wsAuthFailed.value = false; // Reset on successful auth
       showCompleteProfile.value = !!(
         userData && (!userData.phone || !userData.shipping_address)
       );
     } catch {
       user.value = null;
+      wsAuthFailed.value = true;
       showCompleteProfile.value = false;
       stopPing();
     } finally {
       isLoading.value = false;
-      if (user.value && !pingInterval) startPing();
+      if (user.value && !pingInterval && !wsAuthFailed.value) startPing();
     }
   }
 
@@ -60,7 +63,7 @@ export const useAuthStore = defineStore("auth", () => {
   function startPing() {
     stopPing(); // Ensure fresh start
     const token = localStorage.getItem("token");
-    if (!token) return;
+    if (!token || wsAuthFailed.value) return;
 
     globalWs = new WebSocket(`${WS_BASE_URL}/api/auth/ws/global?token=${encodeURIComponent(token)}`);
 
@@ -87,12 +90,26 @@ export const useAuthStore = defineStore("auth", () => {
       }
     };
 
-    globalWs.onclose = () => {
+    globalWs.onclose = (event) => {
       if (pingInterval) clearInterval(pingInterval);
-      if (user.value) { // If still logged in, try reconnecting
+      
+      // 4001 is our custom code for Auth failure
+      // 1008 is Policy Violation (often used for Auth fail in generic WS)
+      if (event.code === 4001 || event.code === 1008) {
+        console.warn("WS Authentication failed (403/401). Stopping reconnection until next login.");
+        wsAuthFailed.value = true;
+        return;
+      }
+
+      if (user.value && !wsAuthFailed.value) { // If still logged in and no auth error, try reconnecting
         reconnectTimer = window.setTimeout(startPing, 5000);
       }
     };
+
+    globalWs.onerror = () => {
+      // On error, the close event will follow
+      console.error("WS Connection error occurred");
+    };
   }
 
   function stopPing() {
@@ -106,6 +123,7 @@ export const useAuthStore = defineStore("auth", () => {
     }
     if (globalWs) {
       globalWs.onclose = null; // Disable auto reconnect
+      globalWs.onerror = null;
       globalWs.close();
       globalWs = null;
     }
@@ -120,6 +138,8 @@ export const useAuthStore = defineStore("auth", () => {
 
   function setUser(u: any) {
     user.value = u;
+    wsAuthFailed.value = false;
+    if (u && !pingInterval) startPing();
   }
 
   function onProfileComplete() {

+ 1 - 1
src/views/PrivacyPolicy.vue

@@ -37,7 +37,7 @@
         <div class="flex flex-col sm:flex-row items-center justify-between gap-6">
           <div class="space-y-1">
             <h3 class="font-display text-lg font-bold">{{ t("footer.contact") }}</h3>
-            <p class="text-foreground/40 text-sm font-medium">We respond to all privacy requests within 48 hours.</p>
+            <p class="text-foreground/40 text-sm font-medium">{{ t("privacy.responseNotice") }}</p>
           </div>
           <a href="mailto:hello@radionica3d.me" class="inline-flex items-center gap-2 px-6 py-3 bg-white border border-black/[0.05] rounded-2xl font-bold text-sm hover:shadow-md transition-all active:scale-95">
             <Mail class="w-4 h-4 text-primary" />

Vissa filer visades inte eftersom för många filer har ändrats