| import os |
| import io |
| import base64 |
| import json |
| import logging |
| import threading |
| import time |
| import math |
| from datetime import datetime, timedelta, timezone |
| from uuid import uuid4 |
| import random |
| import string |
| from flask import Flask, render_template_string, request, redirect, url_for, flash, jsonify, session |
| from PIL import Image |
| import google.generativeai as genai |
| import numpy as np |
| from huggingface_hub import HfApi, hf_hub_download |
| from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError |
| from werkzeug.utils import secure_filename |
| from dotenv import load_dotenv |
| import requests |
|
|
| load_dotenv() |
|
|
| app = Flask(__name__) |
| app.secret_key = 'your_unique_secret_key_gippo_312_shop_54321_no_login' |
| DATA_FILE = 'data.json' |
| SYNC_FILES =[DATA_FILE] |
| REPO_ID = "Kgshop/metas" |
| HF_TOKEN_WRITE = os.getenv("HF_TOKEN") |
| HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") |
| GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY") |
|
|
| DOWNLOAD_RETRIES = 3 |
| DOWNLOAD_DELAY = 5 |
| ALMATY_TZ = timezone(timedelta(hours=6)) |
|
|
| db_lock = threading.RLock() |
|
|
| CURRENCIES = { |
| 'KGS': 'Кыргызский сом', |
| 'KZT': 'Казахстанский тенге', |
| 'UAH': 'Украинская гривна', |
| 'RUB': 'Российский рубль', |
| 'USD': 'Доллар США', |
| 'EUR': 'Евро' |
| } |
|
|
| COLOR_SCHEMES = { |
| 'default': 'Бирюзовый (по умолч.)', |
| 'forest': 'Лесной зеленый', |
| 'ocean': 'Глубокий синий', |
| 'sunset': 'Закатный оранжевый', |
| 'lavender': 'Лавандовый', |
| 'vintage': 'Винтажный', |
| 'dark': 'Полночь (тёмная)', |
| 'cosmic': 'Космическая ночь', |
| 'minty': 'Свежая мята', |
| 'mocha': 'Кофейный мокко', |
| 'crimson': 'Багровый рассвет', |
| 'solar': 'Солнечная вспышка', |
| 'cyberpunk': 'Киберпанк неон', |
| 'neon': 'Неоновая вспышка', |
| 'pastel': 'Пастельный (светлый)', |
| 'emerald': 'Изумрудный город', |
| 'gold': 'Роскошное золото', |
| 'sakura': 'Цветение сакуры (светлый)', |
| 'arctic': 'Арктический лед (светлый)', |
| 'volcano': 'Магма', |
| 'monochrome_light': 'Классика (Светлая)', |
| 'monochrome_dark': 'Классика (Темная)', |
| 'nord': 'Скандинавский Норд', |
| 'dracula': 'Дракула (Темный)', |
| 'ruby': 'Глубокий Рубин', |
| 'sapphire': 'Королевский Сапфир', |
| 'amethyst': 'Аметистовый блеск' |
| } |
|
|
| logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') |
|
|
| def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY): |
| if not HF_TOKEN_READ and not HF_TOKEN_WRITE: |
| pass |
| token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE |
| files_to_download = [specific_file] if specific_file else SYNC_FILES |
| all_successful = True |
| for file_name in files_to_download: |
| success = False |
| for attempt in range(retries + 1): |
| try: |
| local_path = hf_hub_download( |
| repo_id=REPO_ID, |
| filename=file_name, |
| repo_type="dataset", |
| token=token_to_use, |
| local_dir=".", |
| local_dir_use_symlinks=False, |
| force_download=True, |
| resume_download=False |
| ) |
| success = True |
| break |
| except RepositoryNotFoundError: |
| return False |
| except HfHubHTTPError as e: |
| if e.response.status_code == 404: |
| if attempt == 0 and not os.path.exists(file_name): |
| try: |
| if file_name == DATA_FILE: |
| with open(file_name, 'w', encoding='utf-8') as f: |
| json.dump({}, f) |
| except Exception: |
| pass |
| success = False |
| break |
| else: |
| pass |
| except requests.exceptions.RequestException: |
| pass |
| except Exception: |
| pass |
| if attempt < retries: |
| time.sleep(delay) |
| if not success: |
| all_successful = False |
| return all_successful |
|
|
| def upload_db_to_hf(specific_file=None): |
| if not HF_TOKEN_WRITE: |
| return |
| try: |
| api = HfApi() |
| files_to_upload = [specific_file] if specific_file else SYNC_FILES |
| for file_name in files_to_upload: |
| if os.path.exists(file_name): |
| try: |
| api.upload_file( |
| path_or_fileobj=file_name, |
| path_in_repo=file_name, |
| repo_id=REPO_ID, |
| repo_type="dataset", |
| token=HF_TOKEN_WRITE, |
| commit_message=f"Sync {file_name} {datetime.now(ALMATY_TZ).strftime('%Y-%m-%d %H:%M:%S')}" |
| ) |
| except Exception: |
| pass |
| except Exception: |
| pass |
|
|
| def periodic_backup(): |
| backup_interval = 1800 |
| while True: |
| time.sleep(backup_interval) |
| upload_db_to_hf() |
|
|
| def load_data(): |
| with db_lock: |
| try: |
| with open(DATA_FILE, 'r', encoding='utf-8') as f: |
| data = json.load(f) |
| if not isinstance(data, dict): |
| data = {} |
| except (FileNotFoundError, json.JSONDecodeError): |
| if download_db_from_hf(specific_file=DATA_FILE): |
| try: |
| with open(DATA_FILE, 'r', encoding='utf-8') as f: |
| data = json.load(f) |
| if not isinstance(data, dict): |
| data = {} |
| except (FileNotFoundError, json.JSONDecodeError): |
| data = {} |
| else: |
| data = {} |
| return data |
|
|
| def save_data(data): |
| with db_lock: |
| try: |
| with open(DATA_FILE, 'w', encoding='utf-8') as file: |
| json.dump(data, file, ensure_ascii=False, indent=4) |
| except Exception: |
| pass |
| upload_db_to_hf(specific_file=DATA_FILE) |
|
|
| def get_env_data(env_id): |
| with db_lock: |
| all_data = load_data() |
| default_organization_info = { |
| "about_us": "Мы — надежный партнер в мире уникальных товаров.", |
| "shipping": "Доставка осуществляется по всему Кыргызстану.", |
| "returns": "Возврат и обмен товара возможен в течение 14 дней.", |
| "contact": "Наш магазин находится по адресу: Рынок Кербен. Мы работаем ежедневно с 9:00 до 18:00." |
| } |
| default_settings = { |
| "organization_name": "Gippo312", |
| "whatsapp_number": "+996701202013", |
| "currency_code": "KGS", |
| "chat_name": "EVA", |
| "chat_avatar": None, |
| "color_scheme": "default", |
| "business_type": "retail", |
| "env_mode": "external", |
| "welcome_message_enabled": False, |
| "welcome_message_text": "Добро пожаловать в наш магазин!", |
| "inventory_tracking": False, |
| "admin_password_enabled": False, |
| "admin_password": "", |
| "checkout_fields_enabled": False, |
| "checkout_fields": {"name": False, "phone": False, "city": False, "address": False, "zip": False}, |
| "categories_as_lines": False |
| } |
|
|
| env_data = all_data.get(env_id, {}) |
| if not env_data: |
| env_data = { |
| 'products': [], 'categories':[], 'orders': {}, 'employees':[], 'blocks':[], |
| 'organization_info': default_organization_info, |
| 'settings': default_settings, |
| 'inventory_history':[] |
| } |
|
|
| if 'products' not in env_data: env_data['products'] =[] |
| if 'categories' not in env_data: env_data['categories'] =[] |
| if 'orders' not in env_data: env_data['orders'] = {} |
| if 'organization_info' not in env_data: env_data['organization_info'] = default_organization_info |
| if 'settings' not in env_data: env_data['settings'] = default_settings |
| if 'employees' not in env_data: env_data['employees'] =[] |
| if 'blocks' not in env_data: env_data['blocks'] =[] |
| if 'inventory_history' not in env_data: env_data['inventory_history'] =[] |
| |
| settings_changed = False |
| for key, value in default_settings.items(): |
| if key not in env_data['settings']: |
| env_data['settings'][key] = value |
| settings_changed = True |
|
|
| products_changed = False |
| for product in env_data['products']: |
| if 'product_id' not in product: |
| product['product_id'] = uuid4().hex |
| products_changed = True |
| if 'views' not in product: |
| product['views'] = 0 |
| products_changed = True |
| if 'tags' not in product: |
| product['tags'] =[] |
| products_changed = True |
| else: |
| for tag in product['tags']: |
| if 'stock' not in tag: |
| tag['stock'] = 0 |
| products_changed = True |
| if 'stock_batches' not in tag: |
| tag['stock_batches'] = [{"qty": tag['stock'], "price": tag.get('price', 0), "box_price": tag.get('box_price', 0)}] |
| products_changed = True |
|
|
| if products_changed or settings_changed: |
| save_env_data(env_id, env_data) |
|
|
| return env_data |
|
|
| def save_env_data(env_id, env_data): |
| with db_lock: |
| all_data = load_data() |
| all_data[env_id] = env_data |
| save_data(all_data) |
|
|
| def configure_gemini(): |
| if not GOOGLE_API_KEY: |
| return False |
| try: |
| genai.configure(api_key=GOOGLE_API_KEY) |
| return True |
| except Exception: |
| return False |
|
|
| def generate_ai_description_from_image(image_data, language): |
| if not configure_gemini(): |
| raise ValueError("Google AI API не настроен.") |
| try: |
| if not image_data: |
| raise ValueError("Файл изображения не найден.") |
| image_stream = io.BytesIO(image_data) |
| image = Image.open(image_stream).convert('RGB') |
| except Exception: |
| raise ValueError("Не удалось обработать изображение.") |
| base_prompt = "Напиши большой и красивый, содержательный рекламный пост минимум на 1000 символов со смайликами и 25 тематических хэштегов с ключевыми словами разных вариантов, чтобы мои клиенты могли найти меня в поиске Instagram, Google и т.д. Пост пиши исключительно под товар, который на фото, без адресов и номеров телефона." |
| lang_suffix = "" |
| if language == "Русский": |
| lang_suffix = " Пиши на русском языке." |
| elif language == "Кыргызский": |
| lang_suffix = " Пиши на кыргызском языке." |
| elif language == "Казахский": |
| lang_suffix = " Пиши на казахском языке." |
| elif language == "Узбекский": |
| lang_suffix = " Пиши на узбекском языке." |
| final_prompt = f"{base_prompt}{lang_suffix}" |
| try: |
| model = genai.GenerativeModel('gemma-4-31b-it') |
| response = model.generate_content([final_prompt, image]) |
| if hasattr(response, 'text'): |
| return response.text |
| else: |
| if response.parts: |
| return "".join(part.text for part in response.parts if hasattr(part, 'text')) |
| else: |
| response.resolve() |
| return response.text |
| except Exception as e: |
| raise ValueError(f"Ошибка при генерации контента: {e}") |
|
|
| LANDING_PAGE_TEMPLATE = ''' |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title> MetaStore - AI система для Вашего Бизнеса</title> |
| <style> |
| body, html { margin: 0; padding: 0; height: 100%; overflow: hidden; } |
| iframe { border: none; width: 100%; height: 100%; } |
| </style> |
| </head> |
| <body> |
| <iframe src="https://v0-ai-agent-landing-page-smoky-six.vercel.app/"></iframe> |
| </body> |
| </html> |
| ''' |
|
|
| LOGIN_TEMPLATE = ''' |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Вход в Админ-панель</title> |
| <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600&display=swap" rel="stylesheet"> |
| <style> |
| * { box-sizing: border-box; } |
| body { font-family: 'Montserrat', sans-serif; background-color: #f4f6f9; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; padding: 20px; } |
| .login-container { background: #fff; padding: 40px; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); text-align: center; width: 100%; max-width: 350px; } |
| h2 { color: #135D66; margin-bottom: 20px; } |
| input[type="password"] { width: 100%; padding: 12px; margin-bottom: 20px; border: 1px solid #ccc; border-radius: 8px; font-size: 1rem; min-height: 44px; } |
| button { width: 100%; padding: 12px; background-color: #48D1CC; color: #003C43; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; font-size: 1rem; transition: background 0.3s; min-height: 44px; } |
| button:hover { background-color: #77E4D8; } |
| .error { color: #E57373; margin-bottom: 15px; font-size: 0.9rem; } |
| </style> |
| </head> |
| <body> |
| <div class="login-container"> |
| <h2>Вход</h2> |
| {% with messages = get_flashed_messages(with_categories=true) %} |
| {% if messages %} |
| {% for category, message in messages %} |
| <div class="error">{{ message }}</div> |
| {% endfor %} |
| {% endif %} |
| {% endwith %} |
| <form method="POST"> |
| <input type="password" name="password" placeholder="Введите пароль" required autofocus> |
| <button type="submit">Войти</button> |
| </form> |
| </div> |
| </body> |
| </html> |
| ''' |
|
|
| ADMHOSTO_TEMPLATE = ''' |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Главная Админ-панель</title> |
| <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600&display=swap" rel="stylesheet"> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> |
| <style> |
| :root { --bg-light: #f4f6f9; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-dark: #333; --text-on-accent: #003C43; --danger: #E57373; } |
| * { box-sizing: border-box; } |
| body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-light); color: var(--text-dark); padding: 20px; margin: 0; } |
| .container { max-width: 900px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); } |
| h1 { font-weight: 600; color: var(--bg-medium); margin-bottom: 25px; text-align: center; } |
| .section { margin-bottom: 30px; } |
| .add-env-form { margin-bottom: 20px; text-align: center; display: flex; gap: 10px; justify-content: center; align-items: center;} |
| #search-env { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; font-family: 'Montserrat', sans-serif; min-height: 44px;} |
| .button { padding: 10px 18px; border: none; border-radius: 8px; background-color: var(--accent); color: var(--text-on-accent); font-weight: 600; cursor: pointer; transition: background-color 0.3s ease; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-height: 44px; } |
| .button:hover { background-color: var(--accent-hover); } |
| .env-list { list-style: none; padding: 0; } |
| .env-item { background: #fdfdff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 15px; margin-bottom: 10px; display: flex; flex-direction: column; gap: 15px; } |
| .env-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; } |
| .env-id { font-weight: 600; color: var(--bg-medium); font-size: 1.2rem; } |
| .env-actions { display: flex; gap: 10px; flex-wrap: wrap; } |
| .env-pwd { background: #f1f3f5; padding: 10px; border-radius: 8px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } |
| .env-pwd input[type="text"] { padding: 10px; border: 1px solid #ccc; border-radius: 6px; flex-grow: 1; min-height: 44px;} |
| .delete-button { background-color: var(--danger); color: white; } |
| .message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; text-align: center; } |
| .message.success { background-color: #d4edda; color: #155724; } |
| .message.error { background-color: #f8d7da; color: #721c24; } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <h1><i class="fas fa-server"></i> Управление Средами</h1> |
| {% with messages = get_flashed_messages(with_categories=true) %} |
| {% if messages %} |
| {% for category, message in messages %} |
| <div class="message {{ category }}">{{ message }}</div> |
| {% endfor %} |
| {% endif %} |
| {% endwith %} |
| <div class="section"> |
| <form method="POST" action="{{ url_for('create_environment') }}" class="add-env-form"> |
| <select name="env_mode" style="padding: 10px; border-radius: 8px; border: 1px solid #ccc; font-family: inherit;"> |
| <option value="external">Внешняя цифровизация (по умолчанию)</option> |
| <option value="2in1">2 в 1 (Склад и Касса)</option> |
| </select> |
| <button type="submit" class="button"><i class="fas fa-plus-circle"></i> Создать новую среду</button> |
| </form> |
| </div> |
| <div class="section"> |
| <input type="text" id="search-env" placeholder="Поиск по ID или Названию среды..."> |
| </div> |
| <div class="section"> |
| <h2><i class="fas fa-list-ul"></i> Существующие среды</h2> |
| {% if environments %} |
| <ul class="env-list"> |
| {% for env in environments %} |
| <li class="env-item"> |
| <div class="env-header"> |
| <div style="display:flex; align-items:center; gap: 10px; flex-wrap: wrap;"> |
| <span class="env-id">{{ env.org_name }} (ID: {{ env.id }})</span> |
| <form method="POST" action="{{ url_for('update_env_mode', env_id=env.id) }}" style="margin:0;"> |
| <select name="env_mode" onchange="this.form.submit()" style="padding: 4px; border-radius: 4px; border: 1px solid #ccc; font-size: 0.85rem; font-weight: bold;"> |
| <option value="external" {% if env.mode == 'external' %}selected{% endif %}>Внешняя цифр.</option> |
| <option value="2in1" {% if env.mode == '2in1' %}selected{% endif %}>2 в 1</option> |
| </select> |
| </form> |
| </div> |
| <div class="env-actions"> |
| <a href="{{ url_for('admin', env_id=env.id) }}" class="button" target="_blank"><i class="fas fa-tools"></i> Админ</a> |
| <a href="{{ url_for('catalog', env_id=env.id) }}" class="button" target="_blank"><i class="fas fa-store"></i> Каталог</a> |
| <form method="POST" action="{{ url_for('delete_environment', env_id=env.id) }}" style="display:inline;" onsubmit="if(!confirm('Вы уверены, что хотите удалить среду {{ env.id }}? Это действие необратимо.')) return false;"> |
| <button type="submit" class="button delete-button"><i class="fas fa-trash-alt"></i></button> |
| </form> |
| </div> |
| </div> |
| <div class="env-pwd"> |
| <form method="POST" action="{{ url_for('update_env_pwd', env_id=env.id) }}" style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap; width: 100%;"> |
| <label style="display: flex; align-items: center; gap: 5px;"><input type="checkbox" name="pwd_enabled" {% if env.pwd_enabled %}checked{% endif %} style="width: 20px; height: 20px;"> Вкл. пароль</label> |
| <input type="text" name="password" value="{{ env.password }}" placeholder="Пароль"> |
| <button type="submit" class="button" style="font-size: 0.9rem;">Сохранить</button> |
| </form> |
| </div> |
| </li> |
| {% endfor %} |
| </ul> |
| {% else %} |
| <p>Пока не создано ни одной среды.</p> |
| {% endif %} |
| </div> |
| </div> |
| <script> |
| document.getElementById('search-env').addEventListener('input', function() { |
| const searchTerm = this.value.toLowerCase().trim(); |
| const envItems = document.querySelectorAll('.env-item'); |
| envItems.forEach(item => { |
| const envId = item.querySelector('.env-id').textContent.toLowerCase(); |
| if (envId.includes(searchTerm)) { item.style.display = 'flex'; } else { item.style.display = 'none'; } |
| }); |
| }); |
| </script> |
| </body> |
| </html> |
| ''' |
|
|
| CATALOG_TEMPLATE = ''' |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
| <title>{{ settings.organization_name }} - Каталог</title> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> |
| <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800&display=swap" rel="stylesheet"> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css"> |
| <style> |
| {% if settings.color_scheme == 'forest' %} |
| :root { --bg-dark: #2F4F4F; --bg-medium: #556B2F; --accent: #8FBC8F; --accent-hover: #98FB98; --text-light: #F5F5DC; --text-dark: #1A2F1A; --danger: #CD5C5C; --danger-hover: #F08080; } |
| {% elif settings.color_scheme == 'ocean' %} |
| :root { --bg-dark: #000080; --bg-medium: #1E90FF; --accent: #87CEEB; --accent-hover: #ADD8E6; --text-light: #F0F8FF; --text-dark: #000033; --danger: #FF6347; --danger-hover: #FF4500; } |
| {% elif settings.color_scheme == 'sunset' %} |
| :root { --bg-dark: #4A2511; --bg-medium: #D2691E; --accent: #FFA500; --accent-hover: #FFB733; --text-light: #FFF8DC; --text-dark: #4A2511; --danger: #DC143C; --danger-hover: #FF0000; } |
| {% elif settings.color_scheme == 'lavender' %} |
| :root { --bg-dark: #483D8B; --bg-medium: #8A2BE2; --accent: #D8BFD8; --accent-hover: #E6E6FA; --text-light: #F8F4FF; --text-dark: #2D1B36; --danger: #DB7093; --danger-hover: #FFC0CB; } |
| {% elif settings.color_scheme == 'vintage' %} |
| :root { --bg-dark: #5D4037; --bg-medium: #8D6E63; --accent: #D7CCC8; --accent-hover: #EFEBE9; --text-light: #EFEBE9; --text-dark: #3E2723; --danger: #BF360C; --danger-hover: #F4511E; } |
| {% elif settings.color_scheme == 'dark' %} |
| :root { --bg-dark: #121212; --bg-medium: #1E1E1E; --accent: #BB86FC; --accent-hover: #A764FC; --text-light: #E1E1E1; --text-dark: #121212; --danger: #CF6679; --danger-hover: #D98899; } |
| {% elif settings.color_scheme == 'cosmic' %} |
| :root { --bg-dark: #0D1136; --bg-medium: #303F9F; --accent: #536DFE; --accent-hover: #7986CB; --text-light: #FFFFFF; --text-dark: #FFFFFF; --danger: #F50057; --danger-hover: #FF4081; } |
| {% elif settings.color_scheme == 'minty' %} |
| :root { --bg-dark: #004D40; --bg-medium: #00796B; --accent: #4DB6AC; --accent-hover: #80CBC4; --text-light: #E0F2F1; --text-dark: #00332A; --danger: #ef5350; --danger-hover: #e57373; } |
| {% elif settings.color_scheme == 'mocha' %} |
| :root { --bg-dark: #3E2723; --bg-medium: #5D4037; --accent: #A1887F; --accent-hover: #BCAAA4; --text-light: #EFEBE9; --text-dark: #261412; --danger: #D32F2F; --danger-hover: #E57373; } |
| {% elif settings.color_scheme == 'crimson' %} |
| :root { --bg-dark: #4A148C; --bg-medium: #9C27B0; --accent: #CE93D8; --accent-hover: #E1BEE7; --text-light: #FFFFFF; --text-dark: #2D0854; --danger: #E91E63; --danger-hover: #F06292; } |
| {% elif settings.color_scheme == 'solar' %} |
| :root { --bg-dark: #BF360C; --bg-medium: #FB8C00; --accent: #FFCA28; --accent-hover: #FFD54F; --text-light: #FFF3E0; --text-dark: #3E2723; --danger: #D32F2F; --danger-hover: #E57373; } |
| {% elif settings.color_scheme == 'cyberpunk' %} |
| :root { --bg-dark: #000000; --bg-medium: #0D0221; --accent: #00F0FF; --accent-hover: #81F5FF; --text-light: #FFFFFF; --text-dark: #000000; --danger: #F50057; --danger-hover: #FF4081; } |
| {% elif settings.color_scheme == 'neon' %} |
| :root { --bg-dark: #0F0C29; --bg-medium: #302B63; --accent: #FF00CC; --accent-hover: #FF66CC; --text-light: #FFFFFF; --text-dark: #FFFFFF; --danger: #FF0000; --danger-hover: #CC0000; } |
| {% elif settings.color_scheme == 'pastel' %} |
| :root { --bg-dark: #FCE4EC; --bg-medium: #F8BBD0; --accent: #F06292; --accent-hover: #F48FB1; --text-light: #4A148C; --text-dark: #FFFFFF; --danger: #D32F2F; --danger-hover: #E57373; } |
| {% elif settings.color_scheme == 'emerald' %} |
| :root { --bg-dark: #004D40; --bg-medium: #00695C; --accent: #1DE9B6; --accent-hover: #64FFDA; --text-light: #E0F2F1; --text-dark: #00332A; --danger: #FF5252; --danger-hover: #FF8A80; } |
| {% elif settings.color_scheme == 'gold' %} |
| :root { --bg-dark: #1C1C1C; --bg-medium: #3A3A3A; --accent: #D4AF37; --accent-hover: #F3E5AB; --text-light: #F5F5F5; --text-dark: #1C1C1C; --danger: #B71C1C; --danger-hover: #D32F2F; } |
| {% elif settings.color_scheme == 'sakura' %} |
| :root { --bg-dark: #FFEBEE; --bg-medium: #FFCDD2; --accent: #FF8A80; --accent-hover: #FFBCAF; --text-light: #4A148C; --text-dark: #FFFFFF; --danger: #D32F2F; --danger-hover: #E57373; } |
| {% elif settings.color_scheme == 'arctic' %} |
| :root { --bg-dark: #E3F2FD; --bg-medium: #BBDEFB; --accent: #4FC3F7; --accent-hover: #81D4FA; --text-light: #0D47A1; --text-dark: #000000; --danger: #EF5350; --danger-hover: #E57373; } |
| {% elif settings.color_scheme == 'volcano' %} |
| :root { --bg-dark: #212121; --bg-medium: #B71C1C; --accent: #FF3D00; --accent-hover: #FF6E40; --text-light: #FFFFFF; --text-dark: #FFFFFF; --danger: #D50000; --danger-hover: #FF1744; } |
| {% elif settings.color_scheme == 'monochrome_light' %} |
| :root { --bg-dark: #F5F5F5; --bg-medium: #E0E0E0; --accent: #000000; --accent-hover: #333333; --text-light: #000000; --text-dark: #FFFFFF; --danger: #D32F2F; --danger-hover: #B71C1C; } |
| {% elif settings.color_scheme == 'monochrome_dark' %} |
| :root { --bg-dark: #121212; --bg-medium: #2A2A2A; --accent: #FFFFFF; --accent-hover: #E0E0E0; --text-light: #FFFFFF; --text-dark: #000000; --danger: #EF5350; --danger-hover: #E57373; } |
| {% elif settings.color_scheme == 'nord' %} |
| :root { --bg-dark: #2E3440; --bg-medium: #3B4252; --accent: #88C0D0; --accent-hover: #81A1C1; --text-light: #D8DEE9; --text-dark: #2E3440; --danger: #BF616A; --danger-hover: #D08770; } |
| {% elif settings.color_scheme == 'dracula' %} |
| :root { --bg-dark: #282A36; --bg-medium: #44475A; --accent: #FF79C6; --accent-hover: #BD93F9; --text-light: #F8F8F2; --text-dark: #282A36; --danger: #FF5555; --danger-hover: #FFB86C; } |
| {% elif settings.color_scheme == 'ruby' %} |
| :root { --bg-dark: #4A0E17; --bg-medium: #7B1826; --accent: #FFB3B3; --accent-hover: #FFD9D9; --text-light: #FFF0F0; --text-dark: #4A0E17; --danger: #FF4D4D; --danger-hover: #FF1A1A; } |
| {% elif settings.color_scheme == 'sapphire' %} |
| :root { --bg-dark: #0B192C; --bg-medium: #1A365D; --accent: #FFD700; --accent-hover: #FFF176; --text-light: #F7FAFC; --text-dark: #0B192C; --danger: #E53E3E; --danger-hover: #FC8181; } |
| {% elif settings.color_scheme == 'amethyst' %} |
| :root { --bg-dark: #2D1B36; --bg-medium: #4A2C59; --accent: #B28DFF; --accent-hover: #D4C4FB; --text-light: #F4EBFF; --text-dark: #2D1B36; --danger: #FF6B6B; --danger-hover: #FF8787; } |
| {% else %} |
| :root { --bg-dark: #003C43; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-light: #E3FEF7; --text-dark: #003C43; --danger: #E57373; --danger-hover: #EF5350; } |
| {% endif %} |
| |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| html { -webkit-tap-highlight-color: transparent; scroll-behavior: smooth; } |
| body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-dark); color: var(--text-light); line-height: 1.6; } |
| .container { max-width: 1300px; margin: 0 auto; padding: 0 0 100px 0; } |
| .top-bar { display: flex; align-items: center; padding: 10px 15px; gap: 10px; position: sticky; top: 0; background-color: var(--bg-dark); z-index: 999; border-bottom: 1px solid var(--bg-medium); box-shadow: 0 4px 10px rgba(0,0,0,0.2); } |
| .logo { flex-shrink: 0; } |
| .logo img { width: 50px; height: 50px; border-radius: 50%; border: 2px solid var(--accent); box-shadow: 0 0 10px rgba(0,0,0,0.3); object-fit: cover; } |
| .search-wrapper { flex-grow: 1; position: relative; } |
| #search-input { width: 100%; padding: 12px 20px 12px 40px; font-size: 1.05rem; border: none; border-radius: 30px; outline: none; background-color: var(--bg-medium); color: var(--text-light); transition: all 0.3s ease; box-shadow: inset 0 2px 5px rgba(0,0,0,0.2); } |
| #search-input::placeholder { color: var(--text-light); opacity: 0.7; font-weight: 500; } |
| #search-input:focus { background-color: var(--bg-medium); box-shadow: 0 0 0 2px var(--accent); opacity: 0.9;} |
| .search-wrapper .fa-search { position: absolute; top: 50%; left: 15px; transform: translateY(-50%); color: var(--text-light); opacity: 0.8; font-size: 1.1rem; } |
| |
| .blocks-container { padding: 15px 15px 0 15px; display: flex; flex-direction: column; gap: 12px; } |
| .block-link { display: flex; justify-content: center; align-items: center; background: var(--bg-medium); color: var(--text-light); text-align: center; padding: 15px; border-radius: 12px; text-decoration: none; font-weight: 600; box-shadow: 0 4px 6px rgba(0,0,0,0.1); transition: transform 0.2s, background 0.2s; min-height: 44px;} |
| .block-link:hover { transform: translateY(-2px); background: var(--accent); color: var(--text-dark); } |
| .block-text { background: var(--bg-medium); padding: 15px; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); text-align: center; color: var(--text-light); } |
| .block-text h3 { margin-bottom: 5px; font-size: 1.2rem; color: var(--accent); } |
| |
| .category-chips-container { padding: 15px; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; scrollbar-width: none; } |
| .category-chips-container::-webkit-scrollbar { display: none; } |
| .category-chips { display: inline-flex; gap: 10px; } |
| .chip { padding: 10px 20px; border-radius: 20px; background-color: var(--bg-medium); color: var(--text-light); border: 1px solid transparent; font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 2px 5px rgba(0,0,0,0.1); text-decoration: none;} |
| .chip:hover, .chip.active { background-color: var(--accent); color: var(--text-dark); border-color: var(--accent); transform: translateY(-2px); box-shadow: 0 4px 10px rgba(0,0,0,0.2); } |
| |
| .product-grid { display: flex; flex-direction: column; gap: 15px; padding: 15px; margin: 0; } |
| |
| .product-card { width: 100%; aspect-ratio: auto; display: flex; flex-direction: row; align-items: center; background: #ffffff; border-radius: 12px; overflow: hidden; cursor: pointer; transition: opacity 0.2s ease-in-out, transform 0.2s; box-shadow: 0 4px 10px rgba(0,0,0,0.1); position: relative; padding: 10px; gap: 15px; } |
| .dark-theme .product-card { background: #2a2a2a; } |
| .product-card:active { transform: scale(0.96); } |
| .product-image-container { width: 100px; height: 100px; flex-shrink: 0; position: relative; background-color: #f5f5f5; border-radius: 8px; overflow: hidden; } |
| .dark-theme .product-image-container { background-color: #1a1a1a; } |
| .product-image-container img { width: 100%; height: 100%; object-fit: cover; } |
| .top-product-indicator { position: absolute; top: 10px; right: 10px; background-color: var(--accent); color: var(--text-dark); padding: 4px 10px; font-size: 0.75rem; border-radius: 12px; font-weight: 700; z-index: 10; box-shadow: 0 2px 5px rgba(0,0,0,0.2); } |
| .price-badge { position: absolute; bottom: 10px; left: 10px; background-color: rgba(0,0,0,0.7); color: #ffffff; padding: 4px 10px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; z-index: 10; backdrop-filter: blur(4px); } |
| .product-info { display: flex; flex-direction: column; flex-grow: 1; } |
| .product-title { font-size: 1.05rem; font-weight: 600; color: #333; } |
| .dark-theme .product-title { color: #f0f0f0; } |
| .product-price-list { font-size: 1rem; font-weight: 700; color: var(--bg-medium); margin-top: 5px; } |
| .dark-theme .product-price-list { color: var(--accent); } |
| .no-results-message { padding: 40px 20px; text-align: center; font-size: 1.2rem; color: var(--text-light); opacity: 0.7; font-weight: 500; } |
| |
| .category-line-section { margin-bottom: 40px; } |
| .category-line-title { font-size: 1.4rem; color: var(--bg-medium); margin: 0 15px 15px 15px; font-weight: 700; border-bottom: 2px solid var(--accent); display: inline-block; padding-bottom: 5px; } |
| .dark-theme .category-line-title { color: var(--accent); border-bottom-color: var(--bg-medium); } |
| .category-line-grid { display: grid; grid-template-rows: repeat(2, 1fr); grid-auto-flow: column; grid-auto-columns: 160px; gap: 15px; padding: 0 15px 10px 15px; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; } |
| @media (min-width: 768px) { .category-line-grid { grid-auto-columns: 200px; gap: 20px; padding: 0 20px 10px 20px; } .category-line-title { margin: 0 20px 15px 20px; } } |
| .category-line-grid::-webkit-scrollbar { display: none; } |
| .category-line-grid .product-card { scroll-snap-align: start; height: auto; flex-direction: column; padding: 0; gap: 0;} |
| .category-line-grid .product-image-container { width: 100%; height: auto; aspect-ratio: 1/1; border-radius: 0; } |
| .category-line-grid .product-info { padding: 8px 10px; } |
| .category-line-grid .product-title { font-size: 0.9rem; } |
| .category-line-grid .product-price-list { font-size: 0.95rem; } |
| |
| .hide-chips .category-chips-container { display: none; } |
| .show-line-nav .category-chips-container { display: block; } |
| |
| .floating-buttons-container { position: fixed; bottom: 25px; right: 25px; display: flex; flex-direction: column; gap: 15px; z-index: 1000; } |
| .floating-button { background-color: var(--accent); color: var(--text-dark); border: none; border-radius: 50%; width: 60px; height: 60px; font-size: 1.6rem; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 20px rgba(0,0,0,0.3); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); text-decoration: none; } |
| .floating-button:hover { background-color: var(--accent-hover); transform: translateY(-5px) scale(1.05); } |
| #cart-button { position: relative; } |
| #cart-count { position: absolute; top: -5px; right: -5px; background-color: var(--danger); color: #ffffff; border-radius: 50%; padding: 4px 8px; font-size: 0.8rem; font-weight: 800; border: 2px solid #fff; box-shadow: 0 2px 5px rgba(0,0,0,0.2); } |
| .dark-theme #cart-count { border-color: #2a2a2a; } |
| |
| .modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.8); backdrop-filter: blur(8px); overflow-y: auto; -webkit-overflow-scrolling: touch; } |
| .modal-content { background: #ffffff; color: #333333; margin: 5% auto; padding: 25px; border-radius: 15px; width: 95%; max-width: 700px; box-shadow: 0 15px 40px rgba(0,0,0,0.3); animation: slideIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); position: relative; } |
| .dark-theme .modal-content { background: #222222; color: #f0f0f0; } |
| @keyframes slideIn { from { transform: translateY(-50px) scale(0.9); opacity: 0; } to { transform: translateY(0) scale(1); opacity: 1; } } |
| .close { position: absolute; top: 15px; right: 20px; font-size: 2.5rem; color: #aaaaaa; cursor: pointer; transition: color 0.3s; line-height: 1; font-weight: bold; z-index: 100; } |
| .close:hover { color: var(--danger); } |
| .modal-content h2 { margin-top: 0; margin-bottom: 25px; color: var(--bg-medium); display: flex; align-items: center; gap: 12px; font-weight: 700;} |
| .dark-theme .modal-content h2 { color: var(--accent); } |
| |
| .cart-item { display: grid; grid-template-columns: 70px 1fr auto auto; gap: 10px; align-items: center; padding: 20px 0; border-bottom: 1px solid #eeeeee; } |
| .dark-theme .cart-item { border-bottom-color: #444444; } |
| .cart-item:last-child { border-bottom: none; } |
| .cart-item img { width: 70px; height: 70px; object-fit: cover; border-radius: 12px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); } |
| .cart-item-details { grid-column: 2 / span 2; } |
| .cart-item-details strong { display: block; margin-bottom: 5px; font-size: 1.05rem; font-weight: 600;} |
| .cart-item-details .variant-info { font-size: 0.9rem; color: #777777; margin-bottom: 3px;} |
| .dark-theme .cart-item-details .variant-info { color: #aaaaaa; } |
| .cart-item-price { font-size: 0.95rem; color: #555555; font-weight: 500;} |
| .dark-theme .cart-item-price { color: #bbbbbb; } |
| .cart-item-quantity { display: flex; align-items: center; gap: 10px; grid-column: 1; grid-row: 2;} |
| .quantity-btn { background-color: #f5f5f5; border: none; border-radius: 8px; width: 36px; height: 36px; cursor: pointer; font-size: 1.2rem; font-weight: bold; color: #333333; display: flex; align-items: center; justify-content: center; transition: background 0.2s; } |
| .quantity-btn:hover { background-color: #e0e0e0; } |
| .quantity-btn:disabled { opacity: 0.5; cursor: not-allowed; } |
| .dark-theme .quantity-btn { background-color: #444444; color: #ffffff; } |
| .dark-theme .quantity-btn:hover { background-color: #555555; } |
| .cart-item-total { font-weight: 800; text-align: right; grid-column: 3 / span 2; grid-row: 2; font-size: 1.1rem; color: var(--bg-medium);} |
| .dark-theme .cart-item-total { color: var(--accent); } |
| .cart-item-remove { grid-column: 4; grid-row: 1; justify-self: end; background:none; border:none; color: var(--danger); cursor:pointer; font-size: 1.4rem; padding: 5px; transition: transform 0.2s; min-height: 44px; min-width: 44px; display:flex; align-items:center; justify-content:center;} |
| .cart-item-remove:hover { color: var(--danger-hover); transform: scale(1.1); } |
| .cart-summary { margin-top: 25px; text-align: right; border-top: 2px dashed #eeeeee; padding-top: 20px; } |
| .dark-theme .cart-summary { border-top-color: #444444; } |
| .cart-summary strong { font-size: 1.4rem; color: var(--bg-medium); font-weight: 800;} |
| .dark-theme .cart-summary strong { color: var(--accent); } |
| |
| .checkout-fields { margin-top: 20px; padding: 20px; background: #f9f9f9; border-radius: 8px; border: 1px solid #eeeeee; } |
| .dark-theme .checkout-fields { background: #333333; border-color: #444444; } |
| .checkout-fields h3 { margin-top: 0; font-size: 1.1rem; margin-bottom: 15px; } |
| .checkout-fields input { width: 100%; padding: 12px; margin-bottom: 15px; border: 1px solid #cccccc; border-radius: 8px; box-sizing: border-box; font-family: inherit; font-size: 1rem; } |
| .dark-theme .checkout-fields input { background: #222222; color: #ffffff; border-color: #555555; } |
| |
| .cart-actions { margin-top: 30px; display: flex; justify-content: space-between; gap: 15px; flex-wrap: wrap; } |
| .product-button { display: flex; align-items: center; justify-content: center; gap: 8px; width: auto; flex-grow: 1; padding: 14px; border: none; border-radius: 12px; font-size: 1rem; font-weight: 700; cursor: pointer; transition: all 0.3s ease; text-transform: uppercase; letter-spacing: 0.5px; min-height: 50px;} |
| .clear-cart { background-color: #f1f3f5; color: #495057;} |
| .clear-cart:hover { background-color: #e9ecef; } |
| .dark-theme .clear-cart { background-color: #444444; color: #f8f9fa; } |
| .dark-theme .clear-cart:hover { background-color: #555555; } |
| .formulate-order-button { background-color: var(--accent); color: var(--text-dark); box-shadow: 0 4px 15px rgba(0,0,0,0.1); } |
| .formulate-order-button:hover { background-color: var(--accent-hover); transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.15); } |
| |
| .notification { position: fixed; bottom: 100px; left: 50%; transform: translateX(-50%) translateY(20px); background-color: var(--bg-medium); color: #ffffff; padding: 12px 25px; border-radius: 30px; box-shadow: 0 10px 25px rgba(0,0,0,0.2); z-index: 1002; opacity: 0; transition: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55); font-size: 0.95rem; font-weight: 600; text-align: center; width: max-content; max-width: 90vw;} |
| .notification.show { opacity: 1; transform: translateX(-50%) translateY(0); } |
| |
| .hide-markers .tag-marker-overlay { opacity: 0 !important; pointer-events: none !important; } |
| .tag-marker-overlay { position: absolute; width: 20px; height: 20px; background-color: var(--accent); border-radius: 50%; border: 2px solid #ffffff; box-shadow: 0 0 8px rgba(0,0,0,0.6); transform: translate(-50%, -50%); cursor: pointer; z-index: 100; transition: opacity 0.3s ease, transform 0.2s; opacity: 1; } |
| .tag-marker-overlay::after { content: ''; position: absolute; top: -15px; bottom: -15px; left: -15px; right: -15px; border-radius: 50%; background: transparent; cursor: pointer; } |
| .tag-marker-overlay:hover { transform: translate(-50%, -50%) scale(1.3); } |
| .hidden-marker { display: none; } |
| |
| .unit-toggle input[type="radio"]:checked + .toggle-span { background: var(--accent); color: var(--text-dark); font-weight: 700; box-shadow: 0 2px 5px rgba(0,0,0,0.1);} |
| .unit-toggle .toggle-span { display: block; padding: 10px 5px; font-size: 0.85rem; font-weight: 600; color: #555; transition: all 0.2s; border-radius: 6px; } |
| .dark-theme .unit-toggle { background: #333; border-color: #444; } |
| .dark-theme .unit-toggle .toggle-span { color: #bbb; } |
| |
| .pagination { display: flex; justify-content: center; gap: 8px; margin-top: 30px; padding-bottom: 20px; align-items: center; flex-wrap: wrap; } |
| .pagination button { width: 44px; height: 44px; border: none; border-radius: 8px; background: var(--bg-medium); color: var(--text-light); font-weight: 600; cursor: pointer; transition: background 0.3s, transform 0.2s; font-size: 1rem; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 5px rgba(0,0,0,0.1);} |
| .pagination button.active { background: var(--accent); color: var(--text-dark); box-shadow: 0 4px 10px rgba(0,0,0,0.2);} |
| .pagination button:hover:not(.active) { background: var(--accent-hover); color: var(--text-dark); transform: translateY(-2px); } |
| .pagination .dots { color: var(--text-light); font-weight: bold; padding: 0 5px;} |
| |
| @media (max-width: 480px) { .cart-item { grid-template-columns: 70px 1fr auto; } .cart-item-details { grid-column: 2; } .cart-item-remove { grid-column: 3; } .cart-item-quantity { grid-column: 2; grid-row: 2; justify-self: start; } .cart-item-total { grid-column: 3; grid-row: 2; } } |
| </style> |
| </head> |
| <body class="{{ 'dark-theme' if settings.color_scheme in['dark', 'cyberpunk', 'neon', 'volcano', 'gold', 'monochrome_dark', 'nord', 'dracula', 'ruby', 'sapphire', 'amethyst', 'cosmic', 'crimson', 'mocha', 'minty', 'solar'] else '' }}"> |
| <div class="container"> |
| <div class="top-bar"> |
| <a href="#" class="logo" onclick="window.scrollTo({top:0, behavior:'smooth'}); return false;"> |
| <img src="{{ chat_avatar_url }}" alt="Logo"> |
| </a> |
| <div class="search-wrapper"> |
| <i class="fas fa-search"></i> |
| <input type="text" id="search-input" placeholder="Поиск товаров..."> |
| </div> |
| </div> |
| |
| {% if blocks %} |
| <div class="blocks-container"> |
| {% for block in blocks %} |
| {% if block.type == 'link' %} |
| <a href="{{ block.url }}" class="block-link" target="_blank" rel="noopener noreferrer">{{ block.title }}</a> |
| {% elif block.type == 'text' %} |
| <div class="block-text"> |
| {% if block.title %}<h3>{{ block.title }}</h3>{% endif %} |
| <p>{{ block.content|replace('\\n', '<br>')|safe }}</p> |
| </div> |
| {% endif %} |
| {% endfor %} |
| </div> |
| {% endif %} |
| |
| <div class="category-chips-container"> |
| <div class="category-chips" id="category-chips"></div> |
| </div> |
| |
| <div id="catalog-content"></div> |
| </div> |
| |
| <div id="productModal" class="modal"> |
| <div class="modal-content" style="padding-top: 40px;"> |
| <span class="close" onclick="closeModal('productModal')" aria-label="Закрыть">×</span> |
| <div id="modalContent">Загрузка...</div> |
| </div> |
| </div> |
| <div id="cartModal" class="modal"> |
| <div class="modal-content"> |
| <span class="close" onclick="closeModal('cartModal')" aria-label="Закрыть">×</span> |
| <h2><i class="fas fa-shopping-cart"></i> Ваша корзина</h2> |
| <div id="cartContent"><p style="text-align: center; padding: 30px; font-size: 1.1rem;">Ваша корзина пуста.</p></div> |
| <div class="cart-summary"><strong>Итого: <span id="cartTotal">0.00</span> {{ currency_code }}</strong></div> |
| {% if settings.checkout_fields_enabled %} |
| <div class="checkout-fields" id="checkoutFieldsContainer" style="display:none;"> |
| <h3>Данные для доставки</h3> |
| {% if settings.checkout_fields.name %}<input type="text" id="c_name" placeholder="Ваше Имя" required>{% endif %} |
| {% if settings.checkout_fields.phone %}<input type="tel" id="c_phone" placeholder="Ваш Телефон" required>{% endif %} |
| {% if settings.checkout_fields.city %}<input type="text" id="c_city" placeholder="Город" required>{% endif %} |
| {% if settings.checkout_fields.address %}<input type="text" id="c_address" placeholder="Адрес доставки" required>{% endif %} |
| {% if settings.checkout_fields.zip %}<input type="text" id="c_zip" placeholder="Почтовый индекс" required>{% endif %} |
| </div> |
| {% endif %} |
| <div class="cart-actions" id="cartActions" style="display:none;"> |
| <button class="product-button clear-cart" onclick="clearCart()"><i class="fas fa-trash"></i> Очистить</button> |
| <button class="product-button formulate-order-button" onclick="formulateOrder()"><i class="fas fa-check-circle"></i> Оформить</button> |
| </div> |
| </div> |
| </div> |
| <div class="floating-buttons-container"> |
| <button id="cart-button" class="floating-button" onclick="openCartModal()" aria-label="Открыть корзину"><i class="fas fa-shopping-cart"></i><span id="cart-count">0</span></button> |
| </div> |
| <div id="notification-placeholder"></div> |
| |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script> |
| <script> |
| const allProducts = {{ products_json|safe }}; |
| const orderedCategories = {{ ordered_categories|tojson|safe }}; |
| const repoId = '{{ repo_id }}'; |
| const currencyCode = '{{ currency_code }}'; |
| const envId = '{{ env_id }}'; |
| const bType = '{{ settings.business_type }}'; |
| const orgName = `{{ settings.organization_name }}`.replace(/`/g, ''); |
| const categoriesAsLines = {{ 'true' if settings.categories_as_lines else 'false' }}; |
| let cart = JSON.parse(localStorage.getItem(`mekaCart_${envId}`) || '[]'); |
| |
| const itemsPerPage = 20; |
| let currentPage = 1; |
| let currentCategory = 'all'; |
| |
| document.addEventListener('DOMContentLoaded', () => { |
| const urlParams = new URLSearchParams(window.location.search); |
| const empId = urlParams.get('emp'); |
| if (empId) { localStorage.setItem(`gippoEmp_${envId}`, empId); } |
| updateCartButton(); |
| |
| const chipsContainer = document.getElementById('category-chips'); |
| if(chipsContainer) { |
| if(categoriesAsLines) { |
| let chipsHtml = ''; |
| orderedCategories.forEach(cat => { |
| const safeId = cat.replace(/[^a-zA-Z0-9]/g, '-'); |
| chipsHtml += `<a href="#cat-section-${safeId}" class="chip">${cat}</a>`; |
| }); |
| chipsContainer.innerHTML = chipsHtml; |
| } else { |
| let chipsHtml = `<button class="chip active" onclick="setCategory('all', this)">Все категории</button>`; |
| orderedCategories.forEach(cat => { |
| chipsHtml += `<button class="chip" onclick="setCategory('${cat.replace(/'/g, "\\'")}', this)">${cat}</button>`; |
| }); |
| chipsContainer.innerHTML = chipsHtml; |
| } |
| } |
| |
| document.getElementById('search-input').addEventListener('input', () => { |
| currentPage = 1; |
| renderCatalog(); |
| }); |
| |
| window.addEventListener('click', function(event) { if (event.target.classList.contains('modal')) { closeModal(event.target.id); } }); |
| window.addEventListener('keydown', function(event) { |
| if (event.key === 'Escape') { document.querySelectorAll('.modal[style*="display: block"]').forEach(modal => { closeModal(modal.id); }); } |
| }); |
| |
| renderCatalog(); |
| }); |
| |
| function setCategory(cat, btn) { |
| document.querySelectorAll('.chip').forEach(c => c.classList.remove('active')); |
| if(btn) btn.classList.add('active'); |
| currentCategory = cat; |
| currentPage = 1; |
| document.getElementById('search-input').value = ''; |
| renderCatalog(); |
| } |
| |
| function buildProductCard(product, isLineView) { |
| let minPrice = 0; |
| if (product.tags && product.tags.length > 0) { |
| let prices = product.tags.map(t => bType === 'wholesale' ? (t.box_price || t.price) : t.price); |
| minPrice = Math.min(...prices); |
| } |
| let photoUrl = (product.photos && product.photos.length > 0) |
| ? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${product.photos[0]}` |
| : `https://via.placeholder.com/300x300.png?text=${encodeURIComponent(orgName)}`; |
| let topBadge = product.is_top ? '<span class="top-product-indicator"><i class="fas fa-star"></i></span>' : ''; |
| let priceBadge = minPrice > 0 && isLineView ? `<div class="price-badge">От ${minPrice.toFixed(0)} ${currencyCode}</div>` : ''; |
| let priceText = minPrice > 0 && !isLineView ? `<div class="product-price-list">От ${minPrice.toFixed(0)} ${currencyCode}</div>` : ''; |
| |
| if (isLineView) { |
| return ` |
| <div class="product-card" onclick="openModalById('${product.product_id}')"> |
| <div class="product-image-container"> |
| ${topBadge} |
| <img src="${photoUrl}" alt="${product.name}" loading="lazy" onerror="this.src='https://via.placeholder.com/300x300.png?text=Error'"> |
| ${priceBadge} |
| </div> |
| <div class="product-info"> |
| <h3 class="product-title">${product.name}</h3> |
| </div> |
| </div> |
| `; |
| } else { |
| return ` |
| <div class="product-card" onclick="openModalById('${product.product_id}')"> |
| <div class="product-image-container"> |
| ${topBadge} |
| <img src="${photoUrl}" alt="${product.name}" loading="lazy" onerror="this.src='https://via.placeholder.com/300x300.png?text=Error'"> |
| </div> |
| <div class="product-info"> |
| <h3 class="product-title">${product.name}</h3> |
| ${priceText} |
| </div> |
| </div> |
| `; |
| } |
| } |
| |
| function buildCategorySection(categoryName, products) { |
| const safeId = categoryName.replace(/[^a-zA-Z0-9]/g, '-'); |
| let html = `<div class="category-line-section" id="cat-section-${safeId}">`; |
| html += `<h2 class="category-line-title">${categoryName}</h2>`; |
| html += `<div class="category-line-grid">`; |
| products.forEach(product => { |
| html += buildProductCard(product, true); |
| }); |
| html += `</div></div>`; |
| return html; |
| } |
| |
| function renderCatalog() { |
| const searchTerm = document.getElementById('search-input').value.toLowerCase().trim(); |
| const container = document.getElementById('catalog-content'); |
| |
| if (categoriesAsLines) { |
| document.body.classList.add('show-line-nav'); |
| let filtered = allProducts.filter(p => { |
| return searchTerm === '' || (p.name || '').toLowerCase().includes(searchTerm) || (p.description || '').toLowerCase().includes(searchTerm); |
| }); |
| |
| if (filtered.length === 0) { |
| container.innerHTML = '<p class="no-results-message">По вашему запросу ничего не найдено.</p>'; |
| return; |
| } |
| |
| let grouped = {}; |
| orderedCategories.forEach(c => grouped[c] = []); |
| if (!grouped['Без категории']) grouped['Без категории'] =[]; |
| |
| filtered.forEach(p => { |
| let cat = p.category || 'Без категории'; |
| if (!grouped[cat]) grouped[cat] = []; |
| grouped[cat].push(p); |
| }); |
| |
| let html = ''; |
| orderedCategories.forEach(cat => { |
| if (grouped[cat] && grouped[cat].length > 0) { |
| html += buildCategorySection(cat, grouped[cat]); |
| } |
| }); |
| if (grouped['Без категории'] && grouped['Без категории'].length > 0 && !orderedCategories.includes('Без категории')) { |
| html += buildCategorySection('Без категории', grouped['Без категории']); |
| } |
| container.innerHTML = html; |
| } else { |
| document.body.classList.remove('show-line-nav'); |
| document.body.classList.remove('hide-chips'); |
| let filtered = allProducts.filter(p => { |
| let matchCat = currentCategory === 'all' || p.category === currentCategory; |
| let matchSearch = searchTerm === '' || (p.name || '').toLowerCase().includes(searchTerm) || (p.description || '').toLowerCase().includes(searchTerm); |
| return matchCat && matchSearch; |
| }); |
| |
| const totalPages = Math.ceil(filtered.length / itemsPerPage) || 1; |
| if (currentPage > totalPages) currentPage = totalPages; |
| |
| const start = (currentPage - 1) * itemsPerPage; |
| const paginated = filtered.slice(start, start + itemsPerPage); |
| |
| if (filtered.length === 0) { |
| container.innerHTML = '<p class="no-results-message">По вашему запросу ничего не найдено.</p>'; |
| return; |
| } |
| |
| let html = '<div class="product-grid">'; |
| paginated.forEach(product => { |
| html += buildProductCard(product, false); |
| }); |
| html += '</div>'; |
| |
| if (totalPages > 1) { |
| html += '<div class="pagination">'; |
| if (currentPage > 1) { |
| html += `<button onclick="changePage(${currentPage - 1})"><i class="fas fa-chevron-left"></i></button>`; |
| } |
| for (let i = 1; i <= totalPages; i++) { |
| if (i === 1 || i === totalPages || (i >= currentPage - 1 && i <= currentPage + 1)) { |
| html += `<button class="${i === currentPage ? 'active' : ''}" onclick="changePage(${i})">${i}</button>`; |
| } else if (i === currentPage - 2 || i === currentPage + 2) { |
| html += `<span class="dots">...</span>`; |
| } |
| } |
| if (currentPage < totalPages) { |
| html += `<button onclick="changePage(${currentPage + 1})"><i class="fas fa-chevron-right"></i></button>`; |
| } |
| html += '</div>'; |
| } |
| container.innerHTML = html; |
| } |
| } |
| |
| function changePage(page) { |
| currentPage = page; |
| renderCatalog(); |
| window.scrollTo({top: 0, behavior: 'smooth'}); |
| } |
| |
| function getProductById(productId) { return allProducts.find(p => p.product_id === productId); } |
| |
| function openModalById(productId) { |
| const product = getProductById(productId); |
| if (!product) { alert("Ошибка: товар не найден."); return; } |
| fetch(`/${envId}/track_view/${productId}`, {method: 'POST'}).catch(e=>{}); |
| loadProductDetails(productId); |
| const modal = document.getElementById('productModal'); |
| if (modal) { modal.style.display = "block"; document.body.style.overflow = 'hidden'; } |
| } |
| |
| function closeModal(modalId) { |
| const modal = document.getElementById(modalId); |
| if (modal) { modal.style.display = "none"; } |
| if (!document.querySelector('.modal[style*="display: block"]')) { document.body.style.overflow = 'auto'; } |
| } |
| |
| function loadProductDetails(productId) { |
| const modalContent = document.getElementById('modalContent'); |
| if (!modalContent) return; |
| modalContent.innerHTML = '<p style="text-align:center; padding: 40px; font-weight: 600;">Загрузка данных...</p>'; |
| fetch(`/${envId}/product/${productId}`) |
| .then(response => { |
| if (!response.ok) throw new Error(`Ошибка ${response.status}`); |
| return response.text(); |
| }) |
| .then(data => { |
| modalContent.innerHTML = data; |
| initializeSwiper(); |
| renderVariantsList(); |
| }) |
| .catch(error => { modalContent.innerHTML = `<p style="color: var(--danger); text-align:center;">Не удалось загрузить инфо: ${error.message}</p>`; }); |
| } |
| |
| function initializeSwiper() { |
| const swiperContainer = document.querySelector('#productModal .swiper-container'); |
| if (swiperContainer) { |
| const swiper = new Swiper(swiperContainer, { |
| slidesPerView: 1, spaceBetween: 20, grabCursor: true, |
| zoom: { maxRatio: 3, minRatio: 1 }, |
| pagination: { el: '.swiper-pagination', clickable: true }, |
| navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' } |
| }); |
| swiper.on('slideChange', function () { updateVisibleTags(swiper.realIndex); }); |
| updateVisibleTags(0); |
| } |
| } |
| |
| function toggleTags(imgEl) { |
| const container = imgEl.closest('.swiper-zoom-container'); |
| if(container) { |
| container.classList.toggle('hide-markers'); |
| } |
| } |
| |
| function updateVisibleTags(activeIndex) { |
| document.querySelectorAll('.tag-marker-overlay').forEach(marker => { |
| if(parseInt(marker.dataset.photoIndex) === activeIndex) { marker.classList.remove('hidden-marker'); } |
| else { marker.classList.add('hidden-marker'); } |
| }); |
| } |
| |
| function highlightVariantRow(tagId) { |
| const row = document.getElementById(`tag-row-${tagId}`); |
| if(row) { |
| row.style.backgroundColor = 'var(--accent-hover)'; |
| row.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| setTimeout(() => row.style.backgroundColor = '', 1000); |
| } |
| } |
| |
| function renderVariantsList() { |
| const container = document.getElementById('productVariantsContainer'); |
| if (!container) return; |
| const productId = container.dataset.productId; |
| const product = getProductById(productId); |
| let html = ''; |
| |
| if (product.tags && product.tags.length > 0) { |
| product.tags.forEach(tag => { |
| tag.box_qty = tag.box_qty || 1; |
| tag.box_price = tag.box_price || tag.price; |
| |
| let selectHtml = ""; |
| let hasVariants = tag.variants && tag.variants.trim() !== ""; |
| if (hasVariants) { |
| const varList = tag.variants.split(',').map(s => s.trim()).filter(s => s); |
| selectHtml = `<select id="sel-${tag.id}" onchange="window.updateVariantRow('${productId}', '${tag.id}')" style="margin-left: 10px; padding: 8px; border-radius: 8px; border: 1px solid #ccc; font-family: inherit; font-size: 0.95rem; min-height: 44px;"> |
| ${varList.map(v => `<option value="${v}">${v}</option>`).join('')} |
| </select>`; |
| } |
| |
| let unitSelector = ''; |
| if (bType === 'combined') { |
| unitSelector = `<div class="unit-toggle" style="margin-top: 10px; display: flex; background: #f1f3f5; border-radius: 8px; overflow: hidden; border: 1px solid #ddd; width: 100%;"> |
| <label style="flex: 1; margin: 0; cursor: pointer; display: block;"> |
| <input type="radio" name="unit_${tag.id}" value="piece" checked onchange="window.updateVariantRow('${productId}', '${tag.id}')" style="display: none;"> |
| <span class="toggle-span">Шт. (${tag.price})</span> |
| </label> |
| <label style="flex: 1; margin: 0; cursor: pointer; display: block;"> |
| <input type="radio" name="unit_${tag.id}" value="box" onchange="window.updateVariantRow('${productId}', '${tag.id}')" style="display: none;"> |
| <span class="toggle-span">Упак. (${tag.box_qty}шт - ${tag.box_price})</span> |
| </label> |
| </div>`; |
| } else if (bType === 'wholesale') { |
| unitSelector = `<div style="margin-top: 8px; font-size: 0.9rem; font-weight: 600; color: var(--accent);"><input type="hidden" name="unit_${tag.id}" value="box"> Упак. (${tag.box_qty} шт) - ${tag.box_price}</div>`; |
| } else { |
| unitSelector = `<div style="margin-top: 8px; font-size: 0.9rem; font-weight: 600; color: var(--accent);"><input type="hidden" name="unit_${tag.id}" value="piece"> ${tag.price}</div>`; |
| } |
| |
| let stockBadge = ""; |
| if (tag.stock !== undefined) { |
| stockBadge = `<span style="font-size: 0.8rem; background: #eee; padding: 2px 6px; border-radius: 4px; margin-left: 10px; color: #555;">Остаток: ${tag.stock}</span>`; |
| } |
| |
| html += ` |
| <div id="tag-row-${tag.id}" style="display: flex; align-items: center; justify-content: space-between; padding: 15px 12px; border-bottom: 1px solid #eee; transition: background-color 0.3s; border-radius: 8px; flex-wrap: wrap; gap: 10px;"> |
| <div style="flex-grow: 1; min-width: 60%;"> |
| <div style="display:flex; align-items:center; flex-wrap:wrap; gap: 10px;"> |
| <strong style="font-size:1.05rem;">${tag.name}</strong>${stockBadge} |
| ${selectHtml} |
| </div> |
| ${unitSelector} |
| </div> |
| <div style="display: flex; align-items: center; gap: 8px;" id="controls-${tag.id}"></div> |
| </div>`; |
| }); |
| } else { |
| html = '<p style="text-align:center; padding: 20px;">Нет отмеченных вариантов для покупки.</p>'; |
| } |
| container.innerHTML = html; |
| |
| if (product.tags) { |
| product.tags.forEach(tag => { |
| window.updateVariantRow(productId, tag.id); |
| }); |
| } |
| } |
| |
| window.updateVariantRow = function(productId, tagId) { |
| const product = getProductById(productId); |
| const tag = product.tags.find(t => t.id === tagId); |
| const sel = document.getElementById(`sel-${tagId}`); |
| let selectedVariant = sel ? sel.value : ""; |
| |
| let unitElement = document.querySelector(`input[name="unit_${tagId}"]:checked`) || document.querySelector(`input[name="unit_${tagId}"]`); |
| let unitType = unitElement ? unitElement.value : 'piece'; |
| |
| const colorCode = selectedVariant ? `TAG_${tagId}_VAR_${selectedVariant}` : `TAG_${tagId}`; |
| const cartItemId = `${productId}-${colorCode}-${unitType}`; |
| |
| const cartItem = cart.find(i => i.id === cartItemId); |
| const currentQty = cartItem ? cartItem.quantity : 0; |
| |
| const controlsDiv = document.getElementById(`controls-${tagId}`); |
| if (controlsDiv) { |
| controlsDiv.innerHTML = ` |
| <button class="quantity-btn" onclick="updateInlineCart('${productId}', '${colorCode}', '${unitType}', -1, '${tagId}')">-</button> |
| <input type="number" value="${currentQty}" style="width: 50px; text-align: center; border: 1px solid #ccc; border-radius: 8px; padding: 8px; font-weight: bold; min-height: 36px;" onchange="setInlineCartQty('${productId}', '${colorCode}', '${unitType}', this.value, '${tagId}')" min="0"> |
| <button class="quantity-btn" onclick="updateInlineCart('${productId}', '${colorCode}', '${unitType}', 1, '${tagId}')">+</button> |
| `; |
| } |
| }; |
| |
| function setInlineCartQty(productId, colorCode, unitType, value, tagId) { |
| let newQty = parseInt(value); |
| if (isNaN(newQty) || newQty < 0) newQty = 0; |
| const cartItemId = `${productId}-${colorCode}-${unitType}`; |
| let itemIndex = cart.findIndex(i => i.id === cartItemId); |
| |
| if (newQty === 0) { |
| if (itemIndex > -1) cart.splice(itemIndex, 1); |
| } else { |
| const product = getProductById(productId); |
| const tag = product.tags.find(t => t.id === tagId); |
| let variantName = colorCode.includes('_VAR_') ? colorCode.split('_VAR_')[1] : ""; |
| let unitLabel = unitType === 'box' ? `[Упак: ${tag.box_qty}шт]` : `[Шт]`; |
| let itemPrice = unitType === 'box' ? tag.box_price : tag.price; |
| let itemName = product.name + " (" + tag.name + (variantName ? ` - ${variantName}` : "") + ")" + unitLabel; |
| let photoUrl = product.photos && product.photos[tag.photo_index] ? product.photos[tag.photo_index] : null; |
| |
| if (itemIndex > -1) { |
| cart[itemIndex].quantity = newQty; |
| } else { |
| cart.push({ id: cartItemId, product_id: product.product_id, name: itemName, price: itemPrice, photo: photoUrl, quantity: newQty, color: colorCode, tag_x: tag.x, tag_y: tag.y, unit_type: unitType, box_qty: tag.box_qty, box_price: tag.box_price, orig_price: tag.price }); |
| } |
| } |
| localStorage.setItem(`mekaCart_${envId}`, JSON.stringify(cart)); |
| updateCartButton(); |
| window.updateVariantRow(productId, tagId); |
| } |
| |
| function updateInlineCart(productId, colorCode, unitType, change, tagId) { |
| const cartItemId = `${productId}-${colorCode}-${unitType}`; |
| let item = cart.find(i => i.id === cartItemId); |
| let currentQty = item ? item.quantity : 0; |
| setInlineCartQty(productId, colorCode, unitType, currentQty + change, tagId); |
| } |
| |
| function updateCartButton() { |
| const cartCountElement = document.getElementById('cart-count'); |
| const cartButton = document.getElementById('cart-button'); |
| if (!cartCountElement || !cartButton) return; |
| let totalItems = 0; cart.forEach(item => { totalItems += item.quantity; }); |
| if (totalItems > 0) { cartCountElement.textContent = totalItems; cartCountElement.style.display = 'flex'; cartButton.style.display = 'flex'; } |
| else { cartCountElement.style.display = 'none'; } |
| } |
| |
| function openCartModal() { |
| const cartContent = document.getElementById('cartContent'); |
| const cartTotalElement = document.getElementById('cartTotal'); |
| const cartActions = document.getElementById('cartActions'); |
| const checkoutFields = document.getElementById('checkoutFieldsContainer'); |
| if (!cartContent || !cartTotalElement) return; |
| let total = 0; |
| if (cart.length === 0) { |
| cartContent.innerHTML = '<p style="text-align: center; padding: 30px; font-size: 1.1rem;">Ваша корзина пуста.</p>'; |
| cartTotalElement.textContent = '0.00'; |
| if(cartActions) cartActions.style.display = 'none'; |
| if(checkoutFields) checkoutFields.style.display = 'none'; |
| } else { |
| cartContent.innerHTML = cart.map(item => { |
| let effPrice = item.price; |
| if (item.unit_type === 'piece' && item.box_qty > 1 && item.quantity >= item.box_qty) { |
| effPrice = item.box_price / item.box_qty; |
| } |
| const itemTotal = effPrice * item.quantity; |
| total += itemTotal; |
| |
| let discountBadge = (effPrice < item.orig_price && item.unit_type === 'piece') |
| ? `<span style="color:var(--danger); font-size: 0.8rem; display:block;">Оптовая цена прим.</span>` : ''; |
| |
| const photoUrl = item.photo ? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${item.photo}` : 'https://via.placeholder.com/70x70.png?text=N/A'; |
| return ` |
| <div class="cart-item"> |
| <img src="${photoUrl}" alt="${item.name}"> |
| <div class="cart-item-details"> |
| <strong>${item.name}</strong> |
| <p class="cart-item-price">${effPrice.toFixed(2)} ${currencyCode}</p> |
| ${discountBadge} |
| </div> |
| <div class="cart-item-quantity"> |
| <button class="quantity-btn" onclick="decrementCartItem('${item.id}')">-</button> |
| <input type="number" value="${item.quantity}" style="width: 50px; text-align: center; border: 1px solid #ccc; border-radius: 8px; padding: 8px; font-weight: bold; min-height: 36px;" onchange="setCartItemQty('${item.id}', this.value)" min="1"> |
| <button class="quantity-btn" onclick="incrementCartItem('${item.id}')">+</button> |
| </div> |
| <span class="cart-item-total">${itemTotal.toFixed(2)}</span> |
| <button class="cart-item-remove" onclick="removeFromCart('${item.id}')" title="Удалить товар"><i class="fas fa-trash-alt"></i></button> |
| </div> |
| `; |
| }).join(''); |
| cartTotalElement.textContent = total.toFixed(2); |
| if(cartActions) cartActions.style.display = 'flex'; |
| if(checkoutFields) checkoutFields.style.display = 'block'; |
| } |
| const modal = document.getElementById('cartModal'); |
| if (modal) { modal.style.display = "block"; document.body.style.overflow = 'hidden'; } |
| } |
| |
| function setCartItemQty(itemId, value) { |
| let newQty = parseInt(value); |
| const itemIndex = cart.findIndex(item => item.id === itemId); |
| if (itemIndex > -1) { |
| if (isNaN(newQty) || newQty <= 0) { |
| cart.splice(itemIndex, 1); |
| } else { |
| cart[itemIndex].quantity = newQty; |
| } |
| localStorage.setItem(`mekaCart_${envId}`, JSON.stringify(cart)); |
| openCartModal(); updateCartButton(); renderVariantsList(); |
| } |
| } |
| |
| function incrementCartItem(itemId) { |
| const itemIndex = cart.findIndex(item => item.id === itemId); |
| if (itemIndex > -1) { |
| setCartItemQty(itemId, cart[itemIndex].quantity + 1); |
| } |
| } |
| |
| function decrementCartItem(itemId) { |
| const itemIndex = cart.findIndex(item => item.id === itemId); |
| if (itemIndex > -1) { |
| setCartItemQty(itemId, cart[itemIndex].quantity - 1); |
| } |
| } |
| |
| function removeFromCart(itemId) { |
| cart = cart.filter(item => item.id !== itemId); |
| localStorage.setItem(`mekaCart_${envId}`, JSON.stringify(cart)); |
| openCartModal(); updateCartButton(); renderVariantsList(); |
| } |
| |
| function clearCart() { |
| if (confirm("Вы уверены, что хотите очистить корзину?")) { |
| cart =[]; localStorage.removeItem(`mekaCart_${envId}`); |
| openCartModal(); updateCartButton(); renderVariantsList(); |
| } |
| } |
| |
| function formulateOrder() { |
| if (cart.length === 0) { alert("Корзина пуста!"); return; } |
| |
| const customerData = {}; |
| let hasError = false;['name', 'phone', 'city', 'address', 'zip'].forEach(field => { |
| const el = document.getElementById('c_' + field); |
| if (el) { |
| if (!el.value.trim()) { |
| alert("Пожалуйста, заполните все обязательные поля для доставки."); |
| hasError = true; |
| } |
| customerData[field] = el.value.trim(); |
| } |
| }); |
| if (hasError) return; |
| |
| const empId = localStorage.getItem(`gippoEmp_${envId}`); |
| const orderData = { cart: cart, emp_id: empId, customer_data: customerData, source: 'catalog' }; |
| const formulateButton = document.querySelector('.formulate-order-button'); |
| if (formulateButton) formulateButton.disabled = true; |
| showNotification("Формируем заказ...", 5000); |
| fetch(`/${envId}/create_order`, { |
| method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(orderData) |
| }).then(response => { |
| if (!response.ok) return response.json().then(err => { throw new Error(err.error); }); |
| return response.json(); |
| }).then(data => { |
| if (data.order_id) { |
| localStorage.removeItem(`mekaCart_${envId}`); |
| cart =[]; updateCartButton(); renderVariantsList(); closeModal('cartModal'); |
| window.location.href = `/${envId}/order/${data.order_id}`; |
| } else throw new Error('Не получен ID заказа.'); |
| }).catch(error => { |
| alert(`Ошибка: ${error.message}`); |
| if (formulateButton) formulateButton.disabled = false; |
| }); |
| } |
| |
| function showNotification(message, duration = 3000) { |
| const placeholder = document.getElementById('notification-placeholder'); |
| if (!placeholder) return; |
| const notification = document.createElement('div'); |
| notification.className = 'notification'; notification.textContent = message; |
| placeholder.appendChild(notification); void notification.offsetWidth; notification.classList.add('show'); |
| setTimeout(() => { notification.classList.remove('show'); notification.addEventListener('transitionend', () => notification.remove()); }, duration); |
| } |
| </script> |
| </body> |
| </html> |
| ''' |
|
|
| PRODUCT_DETAIL_TEMPLATE = ''' |
| <div style="padding: 10px;"> |
| <h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; text-align: center; color: var(--bg-medium);">{{ product['name'] }}</h2> |
| <div class="swiper-container" style="max-width: 450px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden; background-color: #ffffff; border: 1px solid #e0e0e0; position: relative;"> |
| <div class="swiper-wrapper"> |
| {% if product.get('photos') and product['photos']|length > 0 %} |
| {% for photo in product['photos'] %} |
| {% set photo_idx = loop.index0 %} |
| <div class="swiper-slide" style="display: flex; justify-content: center; align-items: center; padding: 10px;"> |
| <div class="swiper-zoom-container" style="position: relative; display: inline-block; width: 100%; height: 100%;"> |
| <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}" |
| alt="{{ product['name'] }}" |
| onclick="toggleTags(this)" |
| style="max-width: 100%; max-height: 400px; object-fit: contain; display: block; margin: auto; cursor: pointer;"> |
| {% for tag in product.tags %} |
| {% if tag.photo_index == photo_idx %} |
| <div class="tag-marker-overlay hidden-marker" data-photo-index="{{ photo_idx }}" style="left: {{ tag.x }}%; top: {{ tag.y }}%;" onclick="highlightVariantRow('{{ tag.id }}')" title="{{ tag.name }} - {{ tag.price }}"></div> |
| {% endif %} |
| {% endfor %} |
| </div> |
| </div> |
| {% endfor %} |
| {% else %} |
| <div class="swiper-slide" style="display: flex; justify-content: center; align-items: center;"> |
| <img src="https://via.placeholder.com/400x400.png?text=No+Image" alt="Изображение отсутствует" style="max-width: 100%; max-height: 400px; object-fit: contain;"> |
| </div> |
| {% endif %} |
| </div> |
| {% if product.get('photos') and product['photos']|length > 1 %} |
| <div class="swiper-pagination" style="position: relative; bottom: 5px;"></div> |
| <div class="swiper-button-next" style="color: var(--bg-medium);"></div> |
| <div class="swiper-button-prev" style="color: var(--bg-medium);"></div> |
| {% endif %} |
| </div> |
| <div id="productVariantsContainer" data-product-id="{{ product.get('product_id') }}" style="margin: 20px 0; padding: 0 10px;"></div> |
| <div style="margin-top: 20px; font-size: 1rem; line-height: 1.7; padding: 0 10px; border-top: 1px solid #eeeeee; padding-top: 15px;"> |
| <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p> |
| <p><strong>Описание:</strong><br> {{ product.get('description', 'Описание отсутствует.')|replace('\\n', '<br>')|safe }}</p> |
| </div> |
| </div> |
| ''' |
|
|
| ORDER_TEMPLATE = ''' |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Заказ №{{ order.id }} - {{ settings.organization_name }}</title> |
| <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;600&display=swap" rel="stylesheet"> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> |
| <style> |
| :root { --bg-light: #f4f6f9; --bg-medium: #135D66; --accent: #48D1CC; --text-dark: #333; --text-light: #E3FEF7; --danger: #E57373; } |
| * { box-sizing: border-box; } |
| body { font-family: 'Montserrat', sans-serif; background: var(--bg-light); color: var(--text-dark); line-height: 1.6; padding: 20px; margin: 0; } |
| .container { max-width: 800px; margin: 20px auto; padding: 30px; background: #fff; border-radius: 15px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); border: 1px solid #e0e0e0; } |
| h1 { text-align: center; color: var(--bg-medium); margin-bottom: 25px; font-size: 1.8rem; font-weight: 600; } |
| h2 { color: var(--bg-medium); margin-top: 30px; margin-bottom: 15px; font-size: 1.4rem; border-bottom: 1px solid #e0e0e0; padding-bottom: 8px;} |
| .order-meta { font-size: 0.9rem; color: #999; margin-bottom: 20px; text-align: center; } |
| .order-item { display: grid; grid-template-columns: 60px 1fr auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #f0f0f0; } |
| .order-item:last-child { border-bottom: none; } |
| .order-item img { width: 60px; height: 60px; object-fit: cover; border-radius: 8px; cursor: pointer; border: 1px solid #ccc; transition: transform 0.2s;} |
| .order-item img:hover { transform: scale(1.05); } |
| .item-details { grid-column: 2; } |
| .item-details strong { display: block; margin-bottom: 5px; font-size: 1.05rem; color: var(--text-dark);} |
| .item-details span { font-size: 0.9rem; color: #666; display: block;} |
| .item-quantity-total { grid-column: 3; text-align: right; } |
| .item-quantity { display: flex; align-items: center; justify-content: flex-end; gap: 5px; margin-bottom: 5px;} |
| .item-quantity button { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; cursor: pointer; border: 1px solid #ccc; background: #eee; border-radius: 8px;} |
| .item-quantity input { width: 50px; text-align: center; border: 1px solid #ccc; border-radius: 8px; font-weight: bold; min-height: 36px;} |
| .item-total { font-weight: bold; font-size: 1rem; color: var(--bg-medium);} |
| .order-summary { margin-top: 30px; padding-top: 20px; border-top: 2px solid var(--accent); text-align: right; } |
| .order-summary p { margin-bottom: 10px; font-size: 1.1rem; } |
| .order-summary strong { font-size: 1.3rem; color: var(--bg-medium); } |
| .customer-info { margin-top: 30px; background-color: #f9f9f9; padding: 20px; border-radius: 8px; border: 1px solid #e0e0e0;} |
| .customer-info p { margin-bottom: 8px; font-size: 0.95rem; } |
| .customer-info strong { color: var(--bg-medium); } |
| .actions { margin-top: 30px; text-align: center; display: flex; gap: 15px; justify-content: center; flex-wrap: wrap;} |
| .button { padding: 12px 25px; border: none; border-radius: 8px; background-color: var(--accent); color: var(--text-dark); font-weight: 600; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; font-size: 1rem; display: inline-flex; align-items: center; justify-content: center; gap: 8px; text-decoration: none; min-height: 44px;} |
| .button:hover { background-color: #77E4D8; } |
| .button:active { transform: scale(0.98); } |
| .button-print { background-color: #6c757d; color: white; } |
| .button-print:hover { background-color: #5a6268; } |
| .catalog-link { display: block; text-align: center; margin-top: 25px; color: var(--bg-medium); text-decoration: none; font-size: 0.9rem; min-height: 44px; line-height: 44px;} |
| .catalog-link:hover { text-decoration: underline; } |
| .not-found { text-align: center; color: #dc3545; font-size: 1.2rem; padding: 40px 0;} |
| .modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.8); } |
| .modal-content { position: relative; margin: 10% auto; width: 90%; max-width: 600px; background: transparent; text-align: center; } |
| .modal-content img { max-width: 100%; max-height: 80vh; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.5); } |
| .close-modal { position: absolute; top: -40px; right: 0; color: white; font-size: 2.5rem; cursor: pointer; } |
| .tag-marker { position: absolute; width: 24px; height: 24px; background-color: var(--accent); border-radius: 50%; border: 3px solid #fff; box-shadow: 0 0 10px rgba(0,0,0,0.8); transform: translate(-50%, -50%); pointer-events: none; } |
| @media (max-width: 600px) { .order-item { grid-template-columns: 60px 1fr; } .item-quantity-total { grid-column: 1 / -1; grid-row: 2; text-align: left; margin-top: 10px; display: flex; justify-content: space-between; align-items: center; } } |
| |
| @media print { |
| body { background: white; padding: 0; } |
| .container { box-shadow: none; border: none; padding: 0; margin: 0; } |
| .no-print, .actions, .catalog-link, .item-quantity button, .item-quantity input { display: none !important; } |
| body.print-table .container > *:not(#printTableContainer) { display: none !important; } |
| body.print-table #printTableContainer { display: block !important; } |
| } |
| #printTableContainer { display: none; } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| {% if order %} |
| <h1><i class="fas fa-receipt"></i> Ваш Заказ №<span id="orderId">{{ order.id }}</span></h1> |
| <p class="order-meta">Дата создания: {{ order.created_at }}</p> |
| |
| {% if order.customer_data %} |
| <div class="customer-info" style="margin-top:0; margin-bottom: 30px;"> |
| <h2><i class="fas fa-user"></i> Данные клиента</h2> |
| {% if order.customer_data.name %}<p><strong>Имя:</strong> {{ order.customer_data.name }}</p>{% endif %} |
| {% if order.customer_data.phone %}<p><strong>Телефон:</strong> {{ order.customer_data.phone }}</p>{% endif %} |
| {% if order.customer_data.city %}<p><strong>Город:</strong> {{ order.customer_data.city }}</p>{% endif %} |
| {% if order.customer_data.address %}<p><strong>Адрес:</strong> {{ order.customer_data.address }}</p>{% endif %} |
| {% if order.customer_data.zip %}<p><strong>Индекс:</strong> {{ order.customer_data.zip }}</p>{% endif %} |
| </div> |
| {% endif %} |
| |
| <h2><i class="fas fa-shopping-bag"></i> Товары в заказе</h2> |
| <div id="orderItems"> |
| {% for item in order.cart %} |
| <div class="order-item"> |
| <img src="{{ item.photo_url }}" alt="{{ item.name }}" onclick="showTagModal('{{ item.photo_url }}', {{ item.tag_x if item.tag_x is not none else 'null' }}, {{ item.tag_y if item.tag_y is not none else 'null' }})" title="Нажмите, чтобы посмотреть отметку"> |
| <div class="item-details"> |
| <strong>{{ item.name }}</strong> |
| <span>{{ "%.2f"|format(item.price) }} {{ currency_code }}</span> |
| {% if item.discount_applied %} |
| <span style="color:var(--danger); font-size:0.8rem;">(Оптовая цена)</span> |
| {% endif %} |
| </div> |
| <div class="item-quantity-total"> |
| <div class="item-quantity"> |
| <button onclick="updateOrderItem({{ loop.index0 }}, 'dec')">-</button> |
| <input type="number" value="{{ item.quantity }}" onchange="updateOrderItem({{ loop.index0 }}, 'set', this.value)" min="1"> |
| <button onclick="updateOrderItem({{ loop.index0 }}, 'inc')">+</button> |
| <button onclick="if(confirm('Удалить товар из заказа?')) updateOrderItem({{ loop.index0 }}, 'remove')" style="color: white; background: var(--danger); border: none; margin-left: 10px;"><i class="fas fa-trash"></i></button> |
| </div> |
| <div class="item-total">{{ "%.2f"|format(item.price * item.quantity) }} {{ currency_code }}</div> |
| </div> |
| </div> |
| {% endfor %} |
| </div> |
| <div class="order-summary"><p><strong>ИТОГО К ОПЛАТЕ: <span id="orderTotal">{{ "%.2f"|format(order.total_price) }}</span> {{ currency_code }}</strong></p></div> |
| <div class="customer-info"> |
| <h2><i class="fas fa-info-circle"></i> Статус заказа</h2> |
| {% if order.employee_name %}<p>Ваш персональный менеджер: <strong>{{ order.employee_name }}</strong></p>{% endif %} |
| <p>Пожалуйста, свяжитесь с нами по WhatsApp для подтверждения и уточнения деталей.</p> |
| </div> |
| <div class="actions"> |
| <button class="button" onclick="sendOrderViaWhatsApp()"><i class="fab fa-whatsapp"></i> Отправить в WhatsApp</button> |
| <button class="button button-print" onclick="window.print()"><i class="fas fa-print"></i> Печать накладной</button> |
| <button class="button button-print-table" style="background-color: #28a745; color: white;" onclick="printTable()"><i class="fas fa-table"></i> Печать таблицей</button> |
| </div> |
| <a href="{{ url_for('catalog', env_id=env_id) }}" class="catalog-link">← Вернуться в каталог</a> |
| |
| <div id="printTableContainer"> |
| <h2 style="text-align: center; color: black; border-bottom: none; margin-bottom: 5px;">Заказ №{{ order.id }}</h2> |
| <p style="text-align: center; margin-bottom: 20px;">Дата: {{ order.created_at }}</p> |
| <table style="width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 14px;"> |
| <thead> |
| <tr> |
| <th style="border: 1px solid #000; padding: 6px; background-color: #f0f0f0;">№</th> |
| <th style="border: 1px solid #000; padding: 6px; background-color: #f0f0f0; text-align: left;">Наименование</th> |
| <th style="border: 1px solid #000; padding: 6px; background-color: #f0f0f0;">Кол-во</th> |
| <th style="border: 1px solid #000; padding: 6px; background-color: #f0f0f0;">Цена</th> |
| <th style="border: 1px solid #000; padding: 6px; background-color: #f0f0f0;">Сумма</th> |
| </tr> |
| </thead> |
| <tbody> |
| {% for item in order.cart %} |
| <tr> |
| <td style="border: 1px solid #000; padding: 6px; text-align: center;">{{ loop.index }}</td> |
| <td style="border: 1px solid #000; padding: 6px;">{{ item.name }}</td> |
| <td style="border: 1px solid #000; padding: 6px; text-align: center;">{{ item.quantity }}</td> |
| <td style="border: 1px solid #000; padding: 6px; text-align: right;">{{ "%.2f"|format(item.price) }}</td> |
| <td style="border: 1px solid #000; padding: 6px; text-align: right;">{{ "%.2f"|format(item.price * item.quantity) }}</td> |
| </tr> |
| {% endfor %} |
| </tbody> |
| <tfoot> |
| <tr> |
| <td colspan="4" style="border: 1px solid #000; padding: 8px; text-align: right; font-weight: bold;">ИТОГО:</td> |
| <td style="border: 1px solid #000; padding: 8px; text-align: right; font-weight: bold;">{{ "%.2f"|format(order.total_price) }}</td> |
| </tr> |
| </tfoot> |
| </table> |
| </div> |
| |
| <div id="tagModal" class="modal" onclick="closeTagModal(event)"> |
| <div class="modal-content" onclick="event.stopPropagation()"> |
| <span class="close-modal" onclick="closeTagModal(event)">×</span> |
| <div style="position: relative; display: inline-block;"> |
| <img id="modalImg" src=""> |
| <div id="modalMarker" class="tag-marker" style="display: none;"></div> |
| </div> |
| </div> |
| </div> |
| |
| <script> |
| const envId = '{{ env_id }}'; |
| const orderId = '{{ order.id }}'; |
| |
| function sendOrderViaWhatsApp() { |
| const defaultWhatsapp = "{{ settings.whatsapp_number }}".replace(/[^0-9]/g, ''); |
| const employeeWhatsapp = "{{ order.employee_whatsapp if order.employee_whatsapp else '' }}".replace(/[^0-9]/g, ''); |
| const whatsappNumber = employeeWhatsapp || defaultWhatsapp; |
| const orderUrl = window.location.href; |
| let message = `Здравствуйте! Хочу подтвердить свой заказ №${orderId} в магазине {{ settings.organization_name }}.%0A%0A`; |
| message += `Ссылка на заказ: ${encodeURIComponent(orderUrl)}`; |
| window.open(`https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${message}`, '_blank'); |
| } |
| function showTagModal(imgSrc, x, y) { |
| const modal = document.getElementById('tagModal'); |
| const img = document.getElementById('modalImg'); |
| const marker = document.getElementById('modalMarker'); |
| img.src = imgSrc; |
| if (x !== null && y !== null) { marker.style.left = x + '%'; marker.style.top = y + '%'; marker.style.display = 'block'; } |
| else { marker.style.display = 'none'; } |
| modal.style.display = 'block'; |
| } |
| function closeTagModal(e) { |
| document.getElementById('tagModal').style.display = 'none'; |
| } |
| function printTable() { |
| document.body.classList.add('print-table'); |
| window.print(); |
| setTimeout(() => { document.body.classList.remove('print-table'); }, 1000); |
| } |
| function updateOrderItem(index, action, value=null) { |
| fetch(`/${envId}/update_order/${orderId}`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ index: index, action: action, value: value }) |
| }) |
| .then(res => res.json()) |
| .then(data => { |
| if (data.success) { |
| location.reload(); |
| } else { |
| alert(data.error || 'Ошибка обновления заказа'); |
| } |
| }) |
| .catch(err => { |
| console.error('Ошибка:', err); |
| alert('Сетевая ошибка при обновлении заказа'); |
| }); |
| } |
| </script> |
| {% else %} |
| <h1 style="color: #dc3545;"><i class="fas fa-exclamation-triangle"></i> Ошибка</h1> |
| <p class="not-found">Заказ с таким ID не найден.</p> |
| <a href="{{ url_for('catalog', env_id=env_id) }}" class="catalog-link">← Вернуться в каталог</a> |
| {% endif %} |
| </div> |
| </body> |
| </html> |
| ''' |
|
|
| HISTORY_TEMPLATE = ''' |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>История Продаж - {{ settings.organization_name }}</title> |
| <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600&display=swap" rel="stylesheet"> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> |
| <style> |
| :root { --bg-light: #f4f6f9; --bg-medium: #135D66; --accent: #48D1CC; --text-dark: #333; --danger: #E57373; } |
| * { box-sizing: border-box; } |
| body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-light); color: var(--text-dark); padding: 20px; line-height: 1.6; margin: 0; } |
| .container { max-width: 1200px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); } |
| h1 { color: var(--bg-medium); margin-bottom: 20px; } |
| .filters { display: flex; gap: 15px; margin-bottom: 20px; flex-wrap: wrap; background: #fdfdff; padding: 15px; border-radius: 8px; border: 1px solid #e0e0e0;} |
| input, select { padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-family: inherit; font-size: 1rem; min-height: 44px; flex-grow: 1; } |
| table { width: 100%; border-collapse: collapse; margin-top: 10px; min-width: 600px;} |
| th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; } |
| th { background-color: #f8f9fa; color: var(--bg-medium); font-weight: 600; cursor: pointer;} |
| th:hover { background-color: #e9ecef; } |
| .order-link { color: var(--bg-medium); text-decoration: none; font-weight: 500; } |
| .order-link:hover { text-decoration: underline; } |
| .btn-back { display: inline-flex; align-items: center; gap: 8px; margin-bottom: 20px; color: var(--bg-medium); text-decoration: none; font-weight: 600; min-height: 44px; } |
| .delete-btn { background: none; border: none; color: var(--danger); cursor: pointer; font-size: 1.2rem; padding: 10px; transition: transform 0.2s; min-height: 44px; min-width: 44px;} |
| .delete-btn:hover { transform: scale(1.1); color: #d32f2f; } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <a href="{{ url_for('admin', env_id=env_id) }}" class="btn-back"><i class="fas fa-arrow-left"></i> Назад в админ-панель</a> |
| <h1><i class="fas fa-history"></i> История Продаж</h1> |
| <div class="filters"> |
| <input type="text" id="search" placeholder="Поиск (ID, Имя...)" oninput="filterTable()"> |
| <select id="empFilter" onchange="filterTable()"> |
| <option value="all">Все сотрудники</option> |
| <option value="none">Без сотрудника (Прямые)</option> |
| {% for emp in employees %} |
| <option value="{{ emp.name }}">{{ emp.name }}</option> |
| {% endfor %} |
| </select> |
| </div> |
| <div style="overflow-x: auto;"> |
| <table id="ordersTable"> |
| <thead> |
| <tr> |
| <th onclick="sortTable(0)">ID Заказа <i class="fas fa-sort"></i></th> |
| <th onclick="sortTable(1)">Дата <i class="fas fa-sort"></i></th> |
| <th onclick="sortTable(2)">Сотрудник <i class="fas fa-sort"></i></th> |
| <th onclick="sortTable(3)">Источник <i class="fas fa-sort"></i></th> |
| <th onclick="sortTable(4)">Сумма <i class="fas fa-sort"></i></th> |
| <th>Действия</th> |
| </tr> |
| </thead> |
| <tbody> |
| {% for order in orders %} |
| <tr> |
| <td><a href="{{ url_for('view_order', env_id=env_id, order_id=order.id) }}" class="order-link">{{ order.id }}</a></td> |
| <td>{{ order.created_at }}</td> |
| <td>{{ order.employee_name if order.employee_name else 'Прямой заказ' }}</td> |
| <td>{{ 'Касса (POS)' if order.source == 'pos' else 'Каталог' }}</td> |
| <td>{{ "%.2f"|format(order.total_price) }} {{ currency_code }}</td> |
| <td> |
| <a href="{{ url_for('view_order', env_id=env_id, order_id=order.id) }}" style="color: var(--bg-medium); margin-right: 15px; padding: 10px; display: inline-block;"><i class="fas fa-eye fa-lg"></i></a> |
| <form method="POST" action="{{ url_for('delete_order', env_id=env_id, order_id=order.id) }}" style="display:inline;" onsubmit="if(!confirm('Вы уверены, что хотите удалить заказ навсегда?')) return false;"> |
| <button type="submit" class="delete-btn"><i class="fas fa-trash-alt"></i></button> |
| </form> |
| </td> |
| </tr> |
| {% endfor %} |
| </tbody> |
| </table> |
| </div> |
| </div> |
| <script> |
| function filterTable() { |
| const search = document.getElementById('search').value.toLowerCase(); |
| const empFilter = document.getElementById('empFilter').value; |
| const trs = document.getElementById('ordersTable').getElementsByTagName('tbody')[0].getElementsByTagName('tr'); |
| for (let i = 0; i < trs.length; i++) { |
| const text = trs[i].innerText.toLowerCase(); |
| const empCell = trs[i].getElementsByTagName('td')[2].innerText; |
| let matchSearch = text.includes(search); |
| let matchEmp = true; |
| if (empFilter === 'none') { matchEmp = empCell === 'Прямой заказ'; } else if (empFilter !== 'all') { matchEmp = empCell === empFilter; } |
| trs[i].style.display = matchSearch && matchEmp ? '' : 'none'; |
| } |
| } |
| function sortTable(n) { |
| const table = document.getElementById("ordersTable"); |
| let rows, switching, i, x, y, shouldSwitch, dir, switchcount = 0; |
| switching = true; dir = "asc"; |
| while (switching) { |
| switching = false; rows = table.rows; |
| for (i = 1; i < (rows.length - 1); i++) { |
| shouldSwitch = false; |
| x = rows[i].getElementsByTagName("TD")[n]; y = rows[i + 1].getElementsByTagName("TD")[n]; |
| let cmpX = x.innerText.toLowerCase(); let cmpY = y.innerText.toLowerCase(); |
| if (n === 4) { cmpX = parseFloat(cmpX); cmpY = parseFloat(cmpY); } |
| if (dir == "asc") { if (cmpX > cmpY) { shouldSwitch = true; break; } } else if (dir == "desc") { if (cmpX < cmpY) { shouldSwitch = true; break; } } |
| } |
| if (shouldSwitch) { rows[i].parentNode.insertBefore(rows[i + 1], rows[i]); switching = true; switchcount ++; } |
| else { if (switchcount == 0 && dir == "asc") { dir = "desc"; switching = true; } } |
| } |
| } |
| </script> |
| </body> |
| </html> |
| ''' |
|
|
| POS_TEMPLATE = ''' |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>POS Касса - {{ settings.organization_name }}</title> |
| <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet"> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> |
| <style> |
| * { box-sizing: border-box; margin: 0; padding: 0; } |
| body { font-family: 'Montserrat', sans-serif; background-color: #f8f9fa; color: #333; height: 100vh; overflow: hidden; display: flex; flex-direction: column;} |
| |
| .top-bar { padding: 10px 15px; background: #1a5e63; display: flex; gap: 15px; align-items: center; flex-shrink: 0;} |
| .cart-toggle { background: transparent; border: none; color: white; font-size: 1.8rem; cursor: pointer; position: relative; display: flex; align-items: center;} |
| .cart-badge { position: absolute; top: -8px; right: -12px; background: #E57373; color: white; font-size: 0.75rem; font-weight: bold; border-radius: 50%; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; border: 2px solid #1a5e63;} |
| |
| .search-input { flex-grow: 1; padding: 12px 15px; border-radius: 8px; border: none; font-size: 1rem; outline: none; } |
| .products-grid { flex: 1; overflow-y: auto; padding: 15px; display: flex; flex-direction: column; gap: 12px; background: #f4f6f9; } |
| .product-card { background: #fff; border: 1px solid #e0e0e0; border-radius: 12px; display: flex; flex-direction: row; align-items: center; padding: 15px; gap: 15px; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.02); } |
| .product-card img { width: 80px; height: 80px; object-fit: cover; border-radius: 8px; flex-shrink: 0; } |
| .product-info { flex-grow: 1; display: flex; flex-direction: column; justify-content: center; gap: 5px; } |
| .product-name { font-weight: 600; font-size: 1.1rem; color: #333; } |
| .product-price { color: #135D66; font-weight: 700; font-size: 1.1rem; } |
| .product-stock { font-size: 0.9rem; color: #888; } |
| |
| .cart-sidebar { position: fixed; top: 0; left: -100%; width: 100%; max-width: 400px; height: 100%; height: 100dvh; background: #fdfdff; z-index: 1000; transition: left 0.3s ease; display: flex; flex-direction: column; box-shadow: 2px 0 10px rgba(0,0,0,0.2); } |
| .cart-sidebar.open { left: 0; } |
| .cart-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 999; display: none; opacity: 0; transition: opacity 0.3s ease;} |
| .cart-overlay.open { display: block; opacity: 1; } |
| .cart-header { padding: 20px; background: #fff; border-bottom: 1px solid #e0e0e0; font-size: 1.2rem; font-weight: bold; color: #135D66; display: flex; justify-content: space-between; align-items: center; } |
| .close-cart { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #666; } |
| .cart-items { flex: 1; overflow-y: auto; padding: 15px; } |
| .cart-item { display: flex; justify-content: space-between; align-items: center; padding: 12px; background: #fff; border: 1px solid #eee; border-radius: 8px; margin-bottom: 10px; } |
| .cart-item-details { flex-grow: 1; } |
| .cart-item-name { font-weight: 600; font-size: 0.9rem; margin-bottom: 5px; } |
| .cart-item-price { font-size: 0.85rem; color: #666; } |
| .cart-controls { display: flex; align-items: center; gap: 8px; } |
| .qty-btn { width: 28px; height: 28px; background: #eee; border: none; border-radius: 4px; font-weight: bold; cursor: pointer; } |
| |
| .checkout-panel { padding: 20px; padding-bottom: calc(20px + env(safe-area-inset-bottom)); background: #fff; border-top: 1px solid #e0e0e0; flex-shrink: 0; } |
| .total-row { display: flex; justify-content: space-between; font-size: 1.3rem; font-weight: bold; color: #135D66; margin-bottom: 15px; } |
| .input-group { margin-bottom: 15px; } |
| .input-group label { display: block; font-size: 0.85rem; margin-bottom: 5px; color: #555; } |
| .input-group input { width: 100%; padding: 12px; border: 1px solid #ccc; border-radius: 8px; font-size: 1rem; outline: none;} |
| .btn-checkout { width: 100%; padding: 15px; background: #48D1CC; color: #003C43; border: none; border-radius: 8px; font-size: 1.05rem; font-weight: bold; cursor: pointer; transition: background 0.2s; } |
| .btn-checkout:hover { background: #77E4D8; } |
| .btn-clear { width: 100%; padding: 10px; background: #eee; color: #555; border: none; border-radius: 8px; font-size: 0.9rem; cursor: pointer; margin-bottom: 10px; } |
| |
| .pos-modal { display: none; position: fixed; z-index: 2000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); align-items: center; justify-content: center; } |
| .pos-modal-content { background: #fff; padding: 20px; border-radius: 12px; width: 90%; max-width: 400px; display: flex; flex-direction: column; gap: 15px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); } |
| .pos-modal-header { display: flex; justify-content: space-between; font-weight: bold; font-size: 1.1rem; color: #135D66; } |
| .pos-modal-body { display: flex; flex-direction: column; gap: 15px; } |
| .unit-selector { display: flex; gap: 10px; } |
| .unit-selector label { flex: 1; border: 1px solid #ccc; border-radius: 8px; text-align: center; cursor: pointer; overflow: hidden; } |
| .unit-selector input[type="radio"] { display: none; } |
| .unit-selector input[type="radio"]:checked + div { background: #48D1CC; font-weight: bold; color: #003C43; } |
| .unit-selector div { padding: 10px; font-size: 0.9rem; transition: background 0.2s; } |
| .qty-controls { display: flex; align-items: center; justify-content: center; gap: 15px; } |
| .qty-controls button { width: 44px; height: 44px; font-size: 1.5rem; border-radius: 8px; border: none; background: #eee; cursor: pointer; color: #333; } |
| .qty-controls input { width: 70px; height: 44px; text-align: center; font-size: 1.2rem; border: 1px solid #ccc; border-radius: 8px; font-weight: bold; } |
| .pos-modal-footer button { width: 100%; padding: 14px; background: #48D1CC; color: #003C43; border: none; border-radius: 8px; font-size: 1.1rem; font-weight: bold; cursor: pointer; transition: background 0.2s; } |
| .pos-modal-footer button:hover { background: #77E4D8; } |
| </style> |
| </head> |
| <body> |
| <div class="top-bar"> |
| <button class="cart-toggle" onclick="toggleCart()"> |
| <i class="fas fa-shopping-cart"></i> |
| <span class="cart-badge" id="cart-badge" style="display:none;">0</span> |
| </button> |
| <input type="text" class="search-input" id="search-input" placeholder="Поиск товаров..." oninput="renderProducts()"> |
| </div> |
| |
| <div class="products-grid" id="products-grid"></div> |
| |
| <div id="cart-overlay" class="cart-overlay" onclick="toggleCart()"></div> |
| |
| <div id="cart-sidebar" class="cart-sidebar"> |
| <div class="cart-header"> |
| <span>Текущий заказ</span> |
| <button onclick="toggleCart()" class="close-cart"><i class="fas fa-times"></i></button> |
| </div> |
| <div class="cart-items" id="cart-items"></div> |
| <div class="checkout-panel"> |
| <button class="btn-clear" onclick="clearCart()">Очистить корзину</button> |
| <div class="total-row"> |
| <span>Итого:</span> |
| <span id="cart-total">0.00 {{ currency_code }}</span> |
| </div> |
| <div class="input-group"> |
| <label>Номер WhatsApp клиента (для отправки чека):</label> |
| <input type="tel" id="client-phone" placeholder="+996..."> |
| </div> |
| <button class="btn-checkout" onclick="checkout()">Оформить заказ</button> |
| </div> |
| </div> |
| |
| <div id="pos-modal" class="pos-modal" onclick="closePosModal(event)"> |
| <div class="pos-modal-content" onclick="event.stopPropagation()"> |
| <div class="pos-modal-header"> |
| <span id="pos-modal-title">Товар</span> |
| <button onclick="closePosModal()" style="background:none;border:none;font-size:1.5rem;cursor:pointer;color:#666;"><i class="fas fa-times"></i></button> |
| </div> |
| <div class="pos-modal-body"> |
| <div id="pos-modal-units" class="unit-selector"></div> |
| <div class="qty-controls"> |
| <button onclick="changePosModalQty(-1)">-</button> |
| <input type="number" id="pos-modal-qty" value="1" min="1" onchange="validatePosModalQty()"> |
| <button onclick="changePosModalQty(1)">+</button> |
| </div> |
| </div> |
| <div class="pos-modal-footer"> |
| <button onclick="confirmAddToCart()">Добавить (<span id="pos-modal-sum">0</span>)</button> |
| </div> |
| </div> |
| </div> |
| |
| <script> |
| const allProducts = {{ products_json|safe }}; |
| const envId = '{{ env_id }}'; |
| const empId = '{{ emp_id }}'; |
| const currencyCode = '{{ currency_code }}'; |
| const repoId = '{{ repo_id }}'; |
| const bType = '{{ settings.business_type }}'; |
| let cart = []; |
| let posFilteredTags =[]; |
| let posCurrentRendered = 0; |
| const POS_CHUNK_SIZE = 20; |
| |
| function toggleCart() { |
| document.getElementById('cart-sidebar').classList.toggle('open'); |
| document.getElementById('cart-overlay').classList.toggle('open'); |
| } |
| |
| function renderProducts() { |
| const query = document.getElementById('search-input').value.toLowerCase(); |
| posFilteredTags =[]; |
| |
| allProducts.forEach(p => { |
| if(query && !p.name.toLowerCase().includes(query)) { |
| let tagMatches = false; |
| p.tags.forEach(tag => { |
| if(tag.name.toLowerCase().includes(query)) tagMatches = true; |
| }); |
| if(!tagMatches) return; |
| } |
| |
| p.tags.forEach(tag => { |
| if(query && !tag.name.toLowerCase().includes(query) && !p.name.toLowerCase().includes(query)) return; |
| posFilteredTags.push({p, tag}); |
| }); |
| }); |
| |
| document.getElementById('products-grid').innerHTML = ''; |
| posCurrentRendered = 0; |
| loadMorePosProducts(); |
| } |
| |
| function loadMorePosProducts() { |
| const grid = document.getElementById('products-grid'); |
| let html = ''; |
| const end = Math.min(posCurrentRendered + POS_CHUNK_SIZE, posFilteredTags.length); |
| |
| for (let i = posCurrentRendered; i < end; i++) { |
| const {p, tag} = posFilteredTags[i]; |
| const photoUrl = p.photos && p.photos[tag.photo_index] |
| ? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${p.photos[tag.photo_index]}` |
| : 'https://via.placeholder.com/150'; |
| |
| const stockText = tag.stock !== undefined ? `Остаток: ${tag.stock}` : 'В наличии'; |
| |
| const tagData = JSON.stringify({ |
| id: tag.id, pid: p.product_id, name: `${p.name} - ${tag.name}`, |
| price: tag.price, box_price: tag.box_price, box_qty: tag.box_qty, photo: p.photos ? p.photos[tag.photo_index] : null |
| }).replace(/"/g, '"'); |
| |
| html += ` |
| <div class="product-card" onclick="openPosModal(${tagData})"> |
| <img src="${photoUrl}" alt="${tag.name}"> |
| <div class="product-info"> |
| <div class="product-name">${p.name} - ${tag.name}</div> |
| <div class="product-price">${tag.price} ${currencyCode}</div> |
| <div class="product-stock">${stockText}</div> |
| </div> |
| </div> |
| `; |
| } |
| grid.insertAdjacentHTML('beforeend', html); |
| posCurrentRendered = end; |
| } |
| |
| document.getElementById('products-grid').addEventListener('scroll', function() { |
| if (this.scrollTop + this.clientHeight >= this.scrollHeight - 50) { |
| if (posCurrentRendered < posFilteredTags.length) { |
| loadMorePosProducts(); |
| } |
| } |
| }); |
| |
| let currentModalData = null; |
| |
| function openPosModal(data) { |
| currentModalData = data; |
| document.getElementById('pos-modal-title').textContent = data.name; |
| |
| let unitsHtml = ''; |
| if (bType === 'combined') { |
| unitsHtml = ` |
| <label> |
| <input type="radio" name="pos_unit" value="piece" checked onchange="updatePosModalSum()"> |
| <div>Шт. (${data.price})</div> |
| </label> |
| <label> |
| <input type="radio" name="pos_unit" value="box" onchange="updatePosModalSum()"> |
| <div>Упак. (${data.box_qty} шт - ${data.box_price})</div> |
| </label> |
| `; |
| } else if (bType === 'wholesale') { |
| unitsHtml = ` |
| <label> |
| <input type="radio" name="pos_unit" value="box" checked onchange="updatePosModalSum()"> |
| <div>Упак. (${data.box_qty} шт - ${data.box_price})</div> |
| </label> |
| `; |
| } else { |
| unitsHtml = ` |
| <label> |
| <input type="radio" name="pos_unit" value="piece" checked onchange="updatePosModalSum()"> |
| <div>Шт. (${data.price})</div> |
| </label> |
| `; |
| } |
| document.getElementById('pos-modal-units').innerHTML = unitsHtml; |
| document.getElementById('pos-modal-qty').value = 1; |
| updatePosModalSum(); |
| document.getElementById('pos-modal').style.display = 'flex'; |
| } |
| |
| function closePosModal(e) { |
| if(e) e.stopPropagation(); |
| document.getElementById('pos-modal').style.display = 'none'; |
| } |
| |
| function changePosModalQty(delta) { |
| let qty = parseInt(document.getElementById('pos-modal-qty').value) || 1; |
| qty += delta; |
| if(qty < 1) qty = 1; |
| document.getElementById('pos-modal-qty').value = qty; |
| updatePosModalSum(); |
| } |
| |
| function validatePosModalQty() { |
| let qty = parseInt(document.getElementById('pos-modal-qty').value) || 1; |
| if(qty < 1) qty = 1; |
| document.getElementById('pos-modal-qty').value = qty; |
| updatePosModalSum(); |
| } |
| |
| function updatePosModalSum() { |
| if(!currentModalData) return; |
| const unitEl = document.querySelector('input[name="pos_unit"]:checked'); |
| const unit = unitEl ? unitEl.value : (bType === 'wholesale' ? 'box' : 'piece'); |
| const qty = parseInt(document.getElementById('pos-modal-qty').value) || 1; |
| const price = unit === 'box' ? currentModalData.box_price : currentModalData.price; |
| document.getElementById('pos-modal-sum').textContent = (price * qty).toFixed(2) + ' ' + currencyCode; |
| } |
| |
| function confirmAddToCart() { |
| if(!currentModalData) return; |
| const unitEl = document.querySelector('input[name="pos_unit"]:checked'); |
| const unitType = unitEl ? unitEl.value : (bType === 'wholesale' ? 'box' : 'piece'); |
| const qty = parseInt(document.getElementById('pos-modal-qty').value) || 1; |
| |
| const cartItemId = `${currentModalData.pid}-${currentModalData.id}-${unitType}`; |
| const existing = cart.find(i => i.id === cartItemId); |
| |
| if(existing) { |
| existing.quantity += qty; |
| } else { |
| let itemName = currentModalData.name + (unitType === 'box' ? `[Упак: ${currentModalData.box_qty}шт]` : '[Шт]'); |
| let itemPrice = unitType === 'box' ? currentModalData.box_price : currentModalData.price; |
| |
| cart.push({ |
| id: cartItemId, |
| product_id: currentModalData.pid, |
| name: itemName, |
| price: itemPrice, |
| orig_price: currentModalData.price, |
| box_price: currentModalData.box_price, |
| box_qty: currentModalData.box_qty || 1, |
| quantity: qty, |
| unit_type: unitType, |
| photo: currentModalData.photo |
| }); |
| } |
| renderCart(); |
| |
| const badge = document.getElementById('cart-badge'); |
| badge.style.transform = 'scale(1.3)'; |
| setTimeout(() => badge.style.transform = 'scale(1)', 200); |
| closePosModal(); |
| } |
| |
| function updateQty(id, delta) { |
| const item = cart.find(i => i.id === id); |
| if(item) { |
| item.quantity += delta; |
| if(item.quantity <= 0) cart = cart.filter(i => i.id !== id); |
| renderCart(); |
| } |
| } |
| |
| function clearCart() { |
| if(confirm('Очистить корзину?')) { cart =[]; renderCart(); } |
| } |
| |
| function renderCart() { |
| const container = document.getElementById('cart-items'); |
| let html = ''; |
| let total = 0; |
| let totalQty = 0; |
| |
| cart.forEach(item => { |
| let effPrice = item.unit_type === 'box' ? item.box_price : item.orig_price; |
| if (item.unit_type === 'piece' && item.box_qty > 1 && item.quantity >= item.box_qty) { |
| effPrice = item.box_price / item.box_qty; |
| } |
| item.price = effPrice; |
| const itemTotal = effPrice * item.quantity; |
| total += itemTotal; |
| totalQty += item.quantity; |
| |
| html += ` |
| <div class="cart-item"> |
| <div class="cart-item-details"> |
| <div class="cart-item-name">${item.name}</div> |
| <div class="cart-item-price">${effPrice.toFixed(2)} x ${item.quantity} = <strong>${itemTotal.toFixed(2)}</strong></div> |
| </div> |
| <div class="cart-controls"> |
| <button class="qty-btn" onclick="updateQty('${item.id}', -1)">-</button> |
| <span style="width: 20px; text-align:center; font-weight:bold;">${item.quantity}</span> |
| <button class="qty-btn" onclick="updateQty('${item.id}', 1)">+</button> |
| </div> |
| </div> |
| `; |
| }); |
| container.innerHTML = html; |
| document.getElementById('cart-total').textContent = total.toFixed(2) + ' ' + currencyCode; |
| |
| const badge = document.getElementById('cart-badge'); |
| badge.textContent = totalQty; |
| badge.style.display = totalQty > 0 ? 'flex' : 'none'; |
| } |
| |
| function checkout() { |
| if(cart.length === 0) return alert('Корзина пуста'); |
| const phone = document.getElementById('client-phone').value.trim(); |
| |
| const orderData = { |
| cart: cart, |
| emp_id: empId, |
| customer_data: { phone: phone }, |
| source: 'pos' |
| }; |
| |
| fetch(`/${envId}/create_order`, { |
| method: 'POST', headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify(orderData) |
| }).then(r => r.json()).then(data => { |
| if(data.order_id) { |
| if(phone) { |
| const url = `${window.location.origin}/${envId}/order/${data.order_id}`; |
| const msg = `Ваш чек на покупку: ${url}`; |
| window.open(`https://api.whatsapp.com/send?phone=${phone.replace(/\D/g,'')}&text=${encodeURIComponent(msg)}`, '_blank'); |
| } |
| cart =[]; |
| renderCart(); |
| toggleCart(); |
| alert('Заказ успешно создан!'); |
| location.reload(); |
| } |
| }).catch(e => alert('Ошибка')); |
| } |
| |
| renderProducts(); |
| </script> |
| </body> |
| </html> |
| ''' |
|
|
| REPORTS_TEMPLATE = ''' |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Отчеты - {{ settings.organization_name }}</title> |
| <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600&display=swap" rel="stylesheet"> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> |
| <style> |
| body { font-family: 'Montserrat', sans-serif; background: #f4f6f9; color: #333; padding: 20px; margin: 0; } |
| .container { max-width: 1000px; margin: 0 auto; background: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.05); } |
| h1 { color: #135D66; margin-bottom: 20px; } |
| .summary-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 30px; } |
| .card { background: #e0f2f1; padding: 20px; border-radius: 8px; text-align: center; border: 1px solid #b2dfdb; } |
| .card h3 { margin: 0 0 10px 0; font-size: 1rem; color: #004D40; } |
| .card .value { font-size: 1.5rem; font-weight: bold; color: #135D66; } |
| table { width: 100%; border-collapse: collapse; margin-bottom: 30px; } |
| th, td { padding: 12px; border-bottom: 1px solid #eee; text-align: left; } |
| th { background: #f8f9fa; color: #135D66; } |
| .btn-back { display: inline-flex; align-items: center; gap: 8px; margin-bottom: 20px; color: #135D66; text-decoration: none; font-weight: 600; } |
| .button { padding: 10px 20px; border: none; border-radius: 8px; background-color: #48D1CC; color: #003C43; font-weight: 600; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 5px; min-height: 44px; } |
| .button:hover { background-color: #77E4D8; } |
| input[type="date"] { padding: 10px; border: 1px solid #ddd; border-radius: 8px; font-family: inherit; font-size: 1rem; outline: none; } |
| .filter-form { display: flex; flex-wrap: wrap; gap: 15px; align-items: center; background: #fdfdff; padding: 15px; border-radius: 8px; border: 1px solid #e0e0e0; margin-bottom: 20px; } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <a href="{{ url_for('admin', env_id=env_id) }}" class="btn-back"><i class="fas fa-arrow-left"></i> Назад в админ-панель</a> |
| <h1><i class="fas fa-chart-pie"></i> Отчеты (Режим 2 в 1)</h1> |
| |
| <form class="filter-form" method="GET" action="{{ url_for('reports_page', env_id=env_id) }}"> |
| <label style="font-weight: 600;">Период с:</label> |
| <input type="date" name="start_date" value="{{ start_date }}" required> |
| <label style="font-weight: 600;">По:</label> |
| <input type="date" name="end_date" value="{{ end_date }}" required> |
| <button type="submit" class="button"><i class="fas fa-filter"></i> Применить</button> |
| </form> |
| |
| <div class="summary-cards"> |
| <div class="card"> |
| <h3>Всего заказов</h3> |
| <div class="value">{{ total_orders }}</div> |
| </div> |
| <div class="card"> |
| <h3>Общая выручка</h3> |
| <div class="value">{{ "%.2f"|format(total_revenue) }} {{ currency_code }}</div> |
| </div> |
| <div class="card"> |
| <h3>Заказы с кассы (POS)</h3> |
| <div class="value">{{ pos_orders }}</div> |
| </div> |
| <div class="card"> |
| <h3>Онлайн заказы</h3> |
| <div class="value">{{ online_orders }}</div> |
| </div> |
| </div> |
| |
| <h2><i class="fas fa-users"></i> Продажи по сотрудникам</h2> |
| <table> |
| <tr><th>Сотрудник</th><th>Кол-во заказов</th><th>Выручка</th></tr> |
| {% for emp, data in emp_stats.items() %} |
| <tr> |
| <td>{{ emp }}</td> |
| <td>{{ data.count }}</td> |
| <td>{{ "%.2f"|format(data.revenue) }} {{ currency_code }}</td> |
| </tr> |
| {% endfor %} |
| </table> |
| |
| <h2><i class="fas fa-box"></i> Топ продаваемых товаров</h2> |
| <table> |
| <tr><th>Название товара</th><th>Продано шт.</th></tr> |
| {% for item in top_products %} |
| <tr> |
| <td>{{ item.name }}</td> |
| <td>{{ item.qty }}</td> |
| </tr> |
| {% endfor %} |
| </table> |
| </div> |
| </body> |
| </html> |
| ''' |
|
|
| INVENTORY_TEMPLATE = ''' |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Остатки - {{ settings.organization_name }}</title> |
| <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600&display=swap" rel="stylesheet"> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> |
| <style> |
| :root { --bg-light: #f4f6f9; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-dark: #333; --danger: #E57373; --success: #28a745; } |
| * { box-sizing: border-box; } |
| body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-light); color: var(--text-dark); padding: 20px; line-height: 1.6; margin: 0; } |
| .container { max-width: 1200px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); } |
| h1 { color: var(--bg-medium); margin-bottom: 20px; } |
| .btn-back { display: inline-flex; align-items: center; gap: 8px; margin-bottom: 20px; color: var(--bg-medium); text-decoration: none; font-weight: 600; min-height: 44px; } |
| .tabs { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; } |
| .tab { padding: 10px 20px; border-radius: 8px; cursor: pointer; font-weight: 600; background: #eee; border: none; transition: 0.2s; } |
| .tab.active { background: var(--bg-medium); color: white; } |
| .search-box { margin-bottom: 20px; width: 100%; } |
| .search-box input { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; outline: none; } |
| table { width: 100%; border-collapse: collapse; min-width: 800px; } |
| .table-container { overflow-x: auto; } |
| th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; } |
| th { background-color: #f8f9fa; color: var(--bg-medium); font-weight: 600; } |
| .action-btn { padding: 8px 12px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; margin-right: 5px; color: white; font-size: 0.85rem; } |
| .btn-add { background: var(--success); } |
| .btn-writeoff { background: var(--danger); } |
| .btn-history { background: #6c757d; } |
| .low-stock { color: var(--danger); font-weight: bold; } |
| .modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.6); align-items: center; justify-content: center; } |
| .modal-content { background: #fff; padding: 25px; border-radius: 12px; width: 90%; max-width: 500px; display: flex; flex-direction: column; gap: 15px; } |
| .modal-header { display: flex; justify-content: space-between; font-weight: bold; font-size: 1.2rem; color: var(--bg-medium); } |
| .close-modal { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #666; } |
| .form-group { display: flex; flex-direction: column; gap: 5px; } |
| .form-group input[type="number"], .form-group input[type="text"] { padding: 10px; border: 1px solid #ccc; border-radius: 6px; font-size: 1rem; } |
| .form-group label { font-size: 0.9rem; font-weight: 600; color: #555; } |
| .submit-btn { padding: 12px; background: var(--accent); color: #003C43; border: none; border-radius: 8px; font-weight: bold; cursor: pointer; font-size: 1rem; } |
| .history-list { max-height: 300px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; } |
| .history-item { background: #f9f9f9; padding: 10px; border-radius: 6px; border: 1px solid #eee; font-size: 0.9rem; } |
| .history-date { color: #888; font-size: 0.8rem; margin-bottom: 5px; } |
| .badge { background: red; color: white; border-radius: 50%; padding: 2px 6px; font-size: 0.8rem; margin-left: 5px; } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <a href="{{ url_for('admin', env_id=env_id) }}" class="btn-back"><i class="fas fa-arrow-left"></i> Назад в админ-панель</a> |
| <h1><i class="fas fa-boxes"></i> Управление остатками</h1> |
| |
| <div class="tabs"> |
| <button class="tab active" onclick="filterTab('all')">Все товары</button> |
| <button class="tab" onclick="filterTab('low')">Заканчивающиеся {% if low_stock_count > 0 %}<span class="badge">{{ low_stock_count }}</span>{% endif %}</button> |
| </div> |
| |
| <div class="search-box"> |
| <input type="text" id="searchInput" placeholder="Поиск по названию..." oninput="filterTable()"> |
| </div> |
| |
| <div class="table-container"> |
| <table id="invTable"> |
| <thead> |
| <tr> |
| <th>Товар</th> |
| <th>Вариант</th> |
| <th>Остаток</th> |
| <th>Цена шт.</th> |
| <th>Действия</th> |
| </tr> |
| </thead> |
| <tbody> |
| {% for item in items %} |
| <tr class="inv-row" data-name="{{ item.product_name | lower }} {{ item.tag_name | lower }}" data-is-low="{{ 'true' if item.is_low else 'false' }}"> |
| <td>{{ item.product_name }}</td> |
| <td>{{ item.tag_name }}</td> |
| <td class="{{ 'low-stock' if item.is_low else '' }}">{{ item.stock }}</td> |
| <td>{{ item.price }} {{ currency_code }}</td> |
| <td> |
| <button class="action-btn btn-add" onclick="openAddModal('{{ item.product_id }}', '{{ item.tag_id }}', '{{ item.product_name }} - {{ item.tag_name }}')"><i class="fas fa-plus"></i></button> |
| <button class="action-btn btn-writeoff" onclick="openWriteOffModal('{{ item.product_id }}', '{{ item.tag_id }}', '{{ item.product_name }} - {{ item.tag_name }}')"><i class="fas fa-minus"></i></button> |
| <button class="action-btn btn-history" onclick="openHistoryModal('{{ item.product_id }}', '{{ item.tag_id }}')"><i class="fas fa-history"></i></button> |
| </td> |
| </tr> |
| {% endfor %} |
| </tbody> |
| </table> |
| </div> |
| </div> |
| |
| <div id="addModal" class="modal" onclick="closeModal('addModal')"> |
| <div class="modal-content" onclick="event.stopPropagation()"> |
| <div class="modal-header"> |
| <span>Оприходовать</span> |
| <button class="close-modal" onclick="closeModal('addModal')">×</button> |
| </div> |
| <div style="font-weight: 600; color: var(--bg-medium);" id="addTitle"></div> |
| <div class="form-group"> |
| <label>Количество (шт):</label> |
| <input type="number" id="addQty" min="1" value="1"> |
| </div> |
| <div class="form-group" style="flex-direction: row; align-items: center;"> |
| <input type="checkbox" id="addCheckNewPrice" onchange="document.getElementById('newPriceFields').style.display = this.checked ? 'flex' : 'none';" style="width:auto;"> |
| <label for="addCheckNewPrice" style="margin:0; cursor:pointer;">Оприходовать по новой цене</label> |
| </div> |
| <div id="newPriceFields" style="display: none; flex-direction: column; gap: 15px;"> |
| <div class="form-group"> |
| <label>Новая цена за шт:</label> |
| <input type="number" id="addPrice" step="0.01"> |
| </div> |
| {% if settings.business_type != 'retail' %} |
| <div class="form-group"> |
| <label>Новая цена за упаковку:</label> |
| <input type="number" id="addBoxPrice" step="0.01"> |
| </div> |
| {% endif %} |
| </div> |
| <button class="submit-btn" onclick="submitAction('add')">Применить</button> |
| </div> |
| </div> |
| |
| <div id="writeoffModal" class="modal" onclick="closeModal('writeoffModal')"> |
| <div class="modal-content" onclick="event.stopPropagation()"> |
| <div class="modal-header"> |
| <span>Списать</span> |
| <button class="close-modal" onclick="closeModal('writeoffModal')">×</button> |
| </div> |
| <div style="font-weight: 600; color: var(--danger);" id="woTitle"></div> |
| <div class="form-group"> |
| <label>Количество (шт):</label> |
| <input type="number" id="woQty" min="1" value="1"> |
| </div> |
| <button class="submit-btn" style="background: var(--danger); color: white;" onclick="submitAction('write_off')">Списать</button> |
| </div> |
| </div> |
| |
| <div id="historyModal" class="modal" onclick="closeModal('historyModal')"> |
| <div class="modal-content" onclick="event.stopPropagation()"> |
| <div class="modal-header"> |
| <span>История движений</span> |
| <button class="close-modal" onclick="closeModal('historyModal')">×</button> |
| </div> |
| <div class="history-list" id="historyList">Загрузка...</div> |
| </div> |
| </div> |
| |
| <script> |
| let currentFilter = 'all'; |
| let actionData = {}; |
| const envId = '{{ env_id }}'; |
| |
| function filterTab(type) { |
| currentFilter = type; |
| document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); |
| event.target.classList.add('active'); |
| filterTable(); |
| } |
| |
| function filterTable() { |
| const search = document.getElementById('searchInput').value.toLowerCase(); |
| document.querySelectorAll('.inv-row').forEach(row => { |
| const name = row.getAttribute('data-name'); |
| const isLow = row.getAttribute('data-is-low') === 'true'; |
| const matchSearch = name.includes(search); |
| const matchTab = currentFilter === 'all' || (currentFilter === 'low' && isLow); |
| row.style.display = (matchSearch && matchTab) ? '' : 'none'; |
| }); |
| } |
| |
| function openAddModal(pId, tId, title) { |
| actionData = { pId, tId }; |
| document.getElementById('addTitle').textContent = title; |
| document.getElementById('addQty').value = 1; |
| document.getElementById('addCheckNewPrice').checked = false; |
| document.getElementById('newPriceFields').style.display = 'none'; |
| document.getElementById('addPrice').value = ''; |
| const bp = document.getElementById('addBoxPrice'); |
| if(bp) bp.value = ''; |
| document.getElementById('addModal').style.display = 'flex'; |
| } |
| |
| function openWriteOffModal(pId, tId, title) { |
| actionData = { pId, tId }; |
| document.getElementById('woTitle').textContent = title; |
| document.getElementById('woQty').value = 1; |
| document.getElementById('writeoffModal').style.display = 'flex'; |
| } |
| |
| function closeModal(id) { |
| document.getElementById(id).style.display = 'none'; |
| } |
| |
| function submitAction(action) { |
| const qty = parseInt(document.getElementById(action === 'add' ? 'addQty' : 'woQty').value); |
| if(isNaN(qty) || qty <= 0) return alert('Неверное количество'); |
| |
| const payload = { |
| product_id: actionData.pId, |
| tag_id: actionData.tId, |
| action: action, |
| qty: qty |
| }; |
| |
| if(action === 'add' && document.getElementById('addCheckNewPrice').checked) { |
| const price = document.getElementById('addPrice').value; |
| if(!price) return alert('Укажите новую цену'); |
| payload.new_price = price; |
| const bp = document.getElementById('addBoxPrice'); |
| if(bp && bp.value) payload.new_box_price = bp.value; |
| } |
| |
| fetch(`/${envId}/inventory_action`, { |
| method: 'POST', headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify(payload) |
| }).then(r => r.json()).then(data => { |
| if(data.success) { |
| location.reload(); |
| } else { |
| alert(data.error || 'Ошибка'); |
| } |
| }).catch(e => alert('Сетевая ошибка')); |
| } |
| |
| function openHistoryModal(pId, tId) { |
| document.getElementById('historyModal').style.display = 'flex'; |
| const list = document.getElementById('historyList'); |
| list.innerHTML = 'Загрузка...'; |
| |
| fetch(`/${envId}/inventory_history/${pId}/${tId}`) |
| .then(r => r.json()) |
| .then(data => { |
| if(data.length === 0) { |
| list.innerHTML = 'История пуста'; |
| return; |
| } |
| list.innerHTML = data.map(h => { |
| let typeColor = h.type === 'add' ? 'green' : (h.type === 'sale' ? 'blue' : 'red'); |
| let sign = h.type === 'add' ? '+' : '-'; |
| return `<div class="history-item"> |
| <div class="history-date">${h.timestamp}</div> |
| <div><strong style="color:${typeColor}">${sign}${h.qty} шт</strong> - ${h.details}</div> |
| </div>`; |
| }).join(''); |
| }).catch(e => list.innerHTML = 'Ошибка загрузки'); |
| } |
| </script> |
| </body> |
| </html> |
| ''' |
|
|
| ADMIN_TEMPLATE = ''' |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Админ-панель - {{ settings.organization_name }}</title> |
| <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600&display=swap" rel="stylesheet"> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> |
| <style> |
| :root { --bg-light: #f4f6f9; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-dark: #333; --text-on-accent: #003C43; --danger: #E57373; --danger-hover: #EF5350; } |
| * { box-sizing: border-box; } |
| body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-light); color: var(--text-dark); padding: 20px; line-height: 1.6; margin: 0; } |
| .container { max-width: 1200px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); } |
| .header { padding-bottom: 15px; margin-bottom: 25px; border-bottom: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;} |
| .header .logo-title-container img { height: 50px; width: 50px; border-radius: 50%; object-fit: cover; border: 2px solid var(--bg-medium);} |
| h1, h2, h3 { font-weight: 600; color: var(--bg-medium); margin-bottom: 15px; } |
| h1 { font-size: 1.8rem; } |
| h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; } |
| h3 { font-size: 1.2rem; color: #004D40; margin-top: 20px; } |
| .section { margin-bottom: 30px; padding: 20px; background-color: #fdfdff; border: 1px solid #e0e0e0; border-radius: 8px; } |
| form { margin-bottom: 20px; } |
| label { font-weight: 500; margin-top: 10px; display: block; color: #666; font-size: 0.95rem;} |
| input[type="text"], input[type="number"], input[type="password"], input[type="tel"], textarea, select { width: 100%; padding: 12px; margin-top: 5px; border: 1px solid #e0e0e0; border-radius: 8px; font-size: 1rem; box-sizing: border-box; transition: border-color 0.3s ease; background-color: #fff; min-height: 44px;} |
| input:focus, textarea:focus, select:focus { border-color: var(--bg-medium); outline: none; box-shadow: 0 0 0 2px rgba(19, 93, 102, 0.1); } |
| textarea { min-height: 80px; resize: vertical; } |
| input[type="file"] { padding: 8px; background-color: #ffffff; cursor: pointer; border: 1px solid #e0e0e0;} |
| input[type="file"]::file-selector-button { padding: 8px 12px; border-radius: 6px; background-color: #f0f0f0; border: 1px solid #e0e0e0; cursor: pointer; margin-right: 10px;} |
| input[type="checkbox"] { margin-right: 8px; vertical-align: middle; width: 20px; height: 20px; cursor: pointer;} |
| label.inline-label { display: inline-block; margin-top: 10px; font-weight: normal; cursor: pointer; } |
| button, .button { padding: 10px 20px; border: none; border-radius: 8px; background-color: var(--accent); color: var(--text-on-accent); font-weight: 600; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; margin-top: 15px; font-size: 1rem; display: inline-flex; align-items: center; gap: 8px; text-decoration: none; line-height: 1.2; min-height: 44px; justify-content: center;} |
| button:hover, .button:hover { background-color: var(--accent-hover); } |
| button:active, .button:active { transform: scale(0.98); } |
| .delete-button { background-color: var(--danger); color: white; } |
| .delete-button:hover { background-color: var(--danger-hover); } |
| .add-button { background-color: var(--bg-medium); color: white; } |
| .add-button:hover { background-color: #003C43; } |
| .item-list { display: grid; gap: 20px; } |
| .item { background: #fff; padding: 15px 20px; border-radius: 12px; box-shadow: 0 4px 10px rgba(0,0,0,0.05); border: 1px solid #f0f0f0; } |
| .item p { margin: 5px 0; font-size: 0.95rem; color: #666; } |
| .item strong { color: var(--text-dark); } |
| .item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; } |
| .edit-form-container { margin-top: 15px; padding: 20px; background: #E0F2F1; border: 1px dashed #B2DFDB; border-radius: 8px; display: none; } |
| details { background-color: #fdfdff; border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 20px; } |
| details > summary { cursor: pointer; font-weight: 600; color: var(--bg-medium); display: block; padding: 18px 20px; list-style: none; position: relative; font-size: 1.1rem; } |
| details > summary:hover { background-color: #fafafa; } |
| details > summary::after { content: '\\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; right: 20px; top: 50%; transform: translateY(-50%); transition: transform 0.2s ease; color: var(--bg-medium); } |
| details[open] > summary::after { transform: translateY(-50%) rotate(180deg); } |
| details[open] > summary { border-bottom: 1px solid #e0e0e0; } |
| details .form-content { padding: 20px; } |
| .photo-preview img { max-width: 80px; max-height: 80px; border-radius: 8px; margin: 5px 5px 0 0; border: 1px solid #e0e0e0; object-fit: cover;} |
| |
| .flex-container { display: flex; flex-wrap: wrap; gap: 20px; } |
| .flex-item { flex: 1; min-width: 100%; } |
| @media (min-width: 768px) { .flex-item { min-width: calc(50% - 10px); } } |
| |
| .message { padding: 12px 15px; border-radius: 8px; margin-bottom: 15px; font-size: 0.95rem; font-weight: 500;} |
| .message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;} |
| .message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;} |
| .message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; } |
| |
| .status-indicator { display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 0.8rem; font-weight: 600; margin-left: 10px; vertical-align: middle; } |
| .status-indicator.in-stock { background-color: #d4edda; color: #155724; } |
| .status-indicator.out-of-stock { background-color: #f8d7da; color: #721c24; } |
| .status-indicator.top-product { background-color: #FFF9C4; color: #F57F17; margin-left: 5px;} |
| |
| .ai-generate-button { background-color: #8D6EC8; color: white; margin-top: 10px; margin-bottom: 10px; } |
| .ai-generate-button:hover { background-color: #7B4DB5; } |
| .modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.6); } |
| .modal-content { background: #fff; margin: 10% auto; padding: 25px; border-radius: 15px; width: 95%; max-width: 600px; } |
| .current-avatar { max-width: 60px; max-height: 60px; border-radius: 50%; vertical-align: middle; margin-left: 10px; border: 2px solid var(--bg-medium);} |
| .btn-history { background-color: #004D40; color: white; } |
| .btn-history:hover { background-color: #00332a; } |
| |
| .tagging-container { position: relative; display: inline-block; max-width: 100%; margin-top: 10px; border: 1px solid #ccc; background: #fff; border-radius: 8px; overflow: hidden;} |
| .tagging-img { max-width: 100%; display: none; cursor: crosshair; } |
| .tag-marker { position: absolute; width: 16px; height: 16px; background-color: var(--accent); border-radius: 50%; border: 2px solid #fff; box-shadow: 0 0 5px rgba(0,0,0,0.5); transform: translate(-50%, -50%); cursor: pointer; z-index: 10; } |
| .tag-marker::after { content: ''; position: absolute; top: -20px; bottom: -20px; left: -20px; right: -20px; border-radius: 50%; background: transparent; cursor: pointer; } |
| .tag-list-item { display: flex; justify-content: space-between; align-items: center; background: #f0f0f0; padding: 10px; margin-top: 8px; border-radius: 8px; font-size: 0.95rem; } |
| .thumbnail-row { display: flex; gap: 10px; overflow-x: auto; padding: 10px 0; scrollbar-width: none;} |
| .thumbnail-img { width: 60px; height: 60px; object-fit: cover; border: 3px solid transparent; cursor: pointer; border-radius: 8px; } |
| .thumbnail-img.active { border-color: var(--accent); box-shadow: 0 0 5px var(--accent); } |
| |
| .block-item { display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 15px; border: 1px solid #e0e0e0; border-radius: 12px; margin-bottom: 10px; flex-wrap: wrap; gap: 10px;} |
| .block-controls { display: flex; gap: 8px; } |
| .btn-small { padding: 8px 12px; font-size: 0.9rem; min-height: auto;} |
| |
| #admin-ai-widget { position: fixed; bottom: 20px; right: 20px; width: 350px; background: white; border-radius: 15px; box-shadow: 0 5px 20px rgba(0,0,0,0.2); display: flex; flex-direction: column; z-index: 9999; border: 1px solid #e0e0e0; overflow: hidden; transition: height 0.3s; } |
| .ai-chat-header { background: var(--bg-medium); color: white; padding: 18px; font-weight: bold; display: flex; justify-content: space-between; cursor: pointer; font-size: 1.1rem;} |
| .ai-chat-body { display: flex; flex-direction: column; height: 400px; } |
| .ai-chat-messages { flex-grow: 1; overflow-y: auto; padding: 15px; background: #f9f9f9; display: flex; flex-direction: column; gap: 10px; } |
| .ai-quick-btn { background: #e0f2f1; color: var(--bg-medium); border: none; padding: 8px 15px; border-radius: 20px; font-size: 0.85rem; cursor: pointer; transition: background 0.2s; white-space: nowrap; margin-right: 8px; font-weight: 600;} |
| .ai-quick-btn:hover { background: #b2dfdb; } |
| |
| .pagination { display: flex; justify-content: center; gap: 8px; margin-top: 30px; flex-wrap: wrap; align-items: center; } |
| .pagination .button { min-width: 44px; text-align: center; padding: 10px 15px; margin: 0; border-radius: 8px; } |
| |
| #loadingOverlay { |
| display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; |
| background: rgba(0,0,0,0.8); z-index: 10000; flex-direction: column; |
| justify-content: center; align-items: center; color: white; text-align: center; |
| } |
| .spinner { |
| width: 60px; height: 60px; border: 6px solid #f3f3f3; |
| border-top: 6px solid var(--accent); border-radius: 50%; |
| animation: spin 1s linear infinite; margin-bottom: 20px; |
| } |
| @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } |
| |
| @media (max-width: 600px) { |
| #admin-ai-widget { |
| width: calc(100% - 40px); |
| bottom: 10px; |
| right: 20px; |
| left: 20px; |
| z-index: 9000; |
| } |
| .ai-chat-body { |
| height: 350px; |
| } |
| .header { flex-direction: column; align-items: flex-start; } |
| } |
| </style> |
| </head> |
| <body> |
| <div id="loadingOverlay"> |
| <div class="spinner"></div> |
| <h2>Сохранение данных...</h2> |
| <p>Пожалуйста, подождите, идет обработка и загрузка.</p> |
| </div> |
| <div class="container"> |
| <div class="header"> |
| <div class="logo-title-container" style="display: flex; align-items: center; gap: 15px;"> |
| <img src="{{ chat_avatar_url }}" alt="Logo"> |
| <h1><i class="fas fa-tools"></i> Админ-панель {{ settings.organization_name }} (Среда: {{ env_id }})</h1> |
| </div> |
| <div style="display: flex; gap: 10px; flex-wrap: wrap;"> |
| <a href="{{ url_for('history_page', env_id=env_id) }}" class="button btn-history"><i class="fas fa-history"></i> История продаж</a> |
| {% if settings.env_mode == '2in1' %} |
| <a href="{{ url_for('inventory_page', env_id=env_id) }}" class="button" style="background-color: #17a2b8; color: white;"> |
| <i class="fas fa-boxes"></i> Остатки |
| {% if low_stock_count > 0 %}<span style="background:red;color:white;border-radius:50%;padding:2px 6px;font-size:0.8rem;margin-left:5px;">{{ low_stock_count }}</span>{% endif %} |
| </a> |
| <a href="{{ url_for('reports_page', env_id=env_id) }}" class="button" style="background-color: #f39c12; color: white;"><i class="fas fa-chart-pie"></i> Отчеты</a> |
| {% endif %} |
| <a href="{{ url_for('catalog', env_id=env_id) }}" class="button" style="background-color: var(--bg-medium); color: white;"><i class="fas fa-store"></i> Каталог</a> |
| {% if settings.admin_password_enabled %} |
| <a href="{{ url_for('admin_logout', env_id=env_id) }}" class="button" style="background-color: #6c757d; color: white;"><i class="fas fa-sign-out-alt"></i> Выход</a> |
| {% endif %} |
| </div> |
| </div> |
| |
| {% with messages = get_flashed_messages(with_categories=true) %} |
| {% if messages %} |
| {% for category, message in messages %} |
| <div class="message {{ category }}">{{ message }}</div> |
| {% endfor %} |
| {% endif %} |
| {% endwith %} |
| |
| <div class="section"> |
| <h2><i class="fas fa-users"></i> Сотрудники (Менеджеры)</h2> |
| <details> |
| <summary><i class="fas fa-user-plus"></i> Добавить сотрудника</summary> |
| <div class="form-content"> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}"> |
| <input type="hidden" name="action" value="add_employee"> |
| <label>Имя сотрудника:</label> |
| <input type="text" name="emp_name" required> |
| <label>Номер WhatsApp (например, +996...):</label> |
| <input type="tel" name="emp_whatsapp" required> |
| <button type="submit" class="add-button"><i class="fas fa-plus"></i> Добавить</button> |
| </form> |
| </div> |
| </details> |
| {% if employees %} |
| <div class="item-list" style="margin-top: 15px;"> |
| {% for emp in employees %} |
| <div class="item" style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px;"> |
| <div><strong style="font-size: 1.1rem;">{{ emp.name }}</strong><br><span style="font-size: 0.95rem; color: #666;">{{ emp.whatsapp }}</span></div> |
| <div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;"> |
| <button type="button" class="button" style="background-color: #6c757d; font-size: 0.9rem;" onclick="copyEmpLink('{{ request.host_url }}{{ env_id }}/catalog?emp={{ emp.id }}')"><i class="fas fa-copy"></i> Ссылка менеджера</button> |
| {% if settings.env_mode == '2in1' %} |
| <a href="{{ url_for('pos_page', env_id=env_id) }}?emp={{ emp.id }}" class="button" style="background-color: #28a745; font-size: 0.9rem;" target="_blank"><i class="fas fa-desktop"></i> Ссылка на кассу</a> |
| {% endif %} |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin: 0;" onsubmit="if(!confirm('Удалить сотрудника?')) return false;"> |
| <input type="hidden" name="action" value="delete_employee"> |
| <input type="hidden" name="emp_id" value="{{ emp.id }}"> |
| <button type="submit" class="delete-button" style="margin: 0;"><i class="fas fa-trash-alt"></i></button> |
| </form> |
| </div> |
| </div> |
| {% endfor %} |
| </div> |
| {% else %} |
| <p style="margin-top: 10px; font-size: 1.05rem;">Сотрудники не добавлены.</p> |
| {% endif %} |
| </div> |
| |
| <div class="section"> |
| <h2><i class="fas fa-layer-group"></i> Блоки в каталоге (Ссылки и Инфо)</h2> |
| <details> |
| <summary><i class="fas fa-plus-circle"></i> Добавить блок</summary> |
| <div class="form-content"> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}"> |
| <input type="hidden" name="action" value="add_block"> |
| <label>Тип блока:</label> |
| <select name="block_type" id="block_type" onchange="toggleBlockFields()"> |
| <option value="link">Кнопка-ссылка</option> |
| <option value="text">Текстовый блок</option> |
| </select> |
| <label>Заголовок / Текст кнопки:</label> |
| <input type="text" name="block_title" required> |
| <div id="block_url_div"> |
| <label>URL (Ссылка):</label> |
| <input type="text" name="block_url" placeholder="https://..."> |
| </div> |
| <div id="block_content_div" style="display: none;"> |
| <label>Текст блока:</label> |
| <textarea name="block_content" rows="3"></textarea> |
| </div> |
| <button type="submit" class="add-button"><i class="fas fa-plus"></i> Добавить</button> |
| </form> |
| </div> |
| </details> |
| <div style="margin-top: 15px;"> |
| {% if blocks %} |
| {% for block in blocks %} |
| <div class="block-item"> |
| <div style="flex-grow: 1;"> |
| <strong style="font-size: 1.1rem;">{{ block.title }}</strong> <span style="color: #888; font-size: 0.9rem;">({{ 'Ссылка' if block.type == 'link' else 'Текст' }})</span> |
| {% if block.type == 'link' %}<br><small style="font-size: 0.9rem;"><a href="{{ block.url }}" target="_blank" rel="noopener noreferrer">{{ block.url }}</a></small>{% endif %} |
| </div> |
| <div class="block-controls"> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin: 0;"> |
| <input type="hidden" name="action" value="move_block_up"> |
| <input type="hidden" name="block_id" value="{{ block.id }}"> |
| <button type="submit" class="button btn-small" style="background: #6c757d;" {% if loop.first %}disabled{% endif %}><i class="fas fa-arrow-up"></i></button> |
| </form> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin: 0;"> |
| <input type="hidden" name="action" value="move_block_down"> |
| <input type="hidden" name="block_id" value="{{ block.id }}"> |
| <button type="submit" class="button btn-small" style="background: #6c757d;" {% if loop.last %}disabled{% endif %}><i class="fas fa-arrow-down"></i></button> |
| </form> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin: 0;" onsubmit="if(!confirm('Удалить блок?')) return false;"> |
| <input type="hidden" name="action" value="delete_block"> |
| <input type="hidden" name="block_id" value="{{ block.id }}"> |
| <button type="submit" class="button delete-button btn-small"><i class="fas fa-trash-alt"></i></button> |
| </form> |
| </div> |
| </div> |
| {% endfor %} |
| {% else %} |
| <p style="font-size: 1.05rem;">Блоки не добавлены.</p> |
| {% endif %} |
| </div> |
| <script> |
| function toggleBlockFields() { |
| const type = document.getElementById('block_type').value; |
| document.getElementById('block_url_div').style.display = type === 'link' ? 'block' : 'none'; |
| document.getElementById('block_content_div').style.display = type === 'text' ? 'block' : 'none'; |
| } |
| </script> |
| </div> |
| |
| <div class="section"> |
| <details> |
| <summary><i class="fas fa-cog"></i> Настройки магазина</summary> |
| <div class="form-content"> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" enctype="multipart/form-data" onsubmit="showLoadingOverlay()"> |
| <input type="hidden" name="action" value="update_settings"> |
| |
| <div style="background: #f1f3f5; padding: 20px; border-radius: 12px; margin-bottom: 25px;"> |
| <h4 style="margin-top: 0; color: var(--bg-medium); font-size: 1.1rem;"><i class="fas fa-lock"></i> Доступ к админ-панели</h4> |
| <label style="display: flex; align-items: center; gap: 8px;"><input type="checkbox" name="admin_password_enabled" {% if settings.admin_password_enabled %}checked{% endif %}> Включить пароль для входа в админ-панель</label> |
| <label style="margin-top: 15px;">Пароль:</label> |
| <input type="text" name="admin_password" value="{{ settings.admin_password }}" placeholder="Текущий пароль"> |
| </div> |
| |
| <label for="organization_name">Название организации (магазина):</label> |
| <input type="text" id="organization_name" name="organization_name" value="{{ settings.organization_name }}"> |
| |
| <label for="whatsapp_number">Номер WhatsApp для заказов (по умолчанию):</label> |
| <input type="tel" id="whatsapp_number" name="whatsapp_number" value="{{ settings.whatsapp_number }}"> |
| |
| <label for="currency_code">Валюта магазина:</label> |
| <select id="currency_code" name="currency_code"> |
| {% for code, name in currencies.items() %} |
| <option value="{{ code }}" {% if settings.currency_code == code %}selected{% endif %}>{{ name }} ({{ code }})</option> |
| {% endfor %} |
| </select> |
| |
| <label for="business_type">Тип бизнеса:</label> |
| <select id="business_type" name="business_type"> |
| <option value="retail" {% if settings.business_type == 'retail' %}selected{% endif %}>Розничный</option> |
| <option value="wholesale" {% if settings.business_type == 'wholesale' %}selected{% endif %}>Оптовый</option> |
| <option value="combined" {% if settings.business_type == 'combined' %}selected{% endif %}>Оптово-розничный</option> |
| </select> |
| <small style="color: #666; display: block; margin-bottom: 10px;">Влияет на выбор упаковка/штучно при добавлении товара.</small> |
| |
| <div style="background: #E0F2F1; padding: 20px; border-radius: 12px; margin-bottom: 25px; border: 1px solid #B2DFDB;"> |
| <h4 style="margin-top: 0; color: var(--bg-medium); font-size: 1.1rem;"><i class="fas fa-truck"></i> Данные клиента при заказе</h4> |
| <label style="display: flex; align-items: center; gap: 8px;"><input type="checkbox" name="checkout_fields_enabled" id="checkout_fields_enabled" onchange="toggleCheckoutFields()" {% if settings.checkout_fields_enabled %}checked{% endif %}> Запрашивать данные для доставки у клиента</label> |
| <div id="checkout_fields_list" style="margin-top: 15px; display: {% if settings.checkout_fields_enabled %}flex{% else %}none{% endif %}; flex-direction: column; gap: 10px;"> |
| <label style="display: flex; align-items: center; gap: 8px;"><input type="checkbox" name="cf_name" {% if settings.checkout_fields.name %}checked{% endif %}> Имя</label> |
| <label style="display: flex; align-items: center; gap: 8px;"><input type="checkbox" name="cf_phone" {% if settings.checkout_fields.phone %}checked{% endif %}> Номер телефона</label> |
| <label style="display: flex; align-items: center; gap: 8px;"><input type="checkbox" name="cf_city" {% if settings.checkout_fields.city %}checked{% endif %}> Город</label> |
| <label style="display: flex; align-items: center; gap: 8px;"><input type="checkbox" name="cf_address" {% if settings.checkout_fields.address %}checked{% endif %}> Адрес доставки</label> |
| <label style="display: flex; align-items: center; gap: 8px;"><input type="checkbox" name="cf_zip" {% if settings.checkout_fields.zip %}checked{% endif %}> Почтовый индекс</label> |
| </div> |
| </div> |
| |
| <label for="chat_avatar">Аватар магазина:</label> |
| <input type="file" id="chat_avatar" name="chat_avatar" accept="image/*"> |
| {% if settings.chat_avatar %} |
| <p style="font-size: 0.95rem; margin-top: 10px;">Текущий аватар: <img src="{{ chat_avatar_url }}" class="current-avatar"></p> |
| {% endif %} |
| |
| <label for="color_scheme">Цветовая схема:</label> |
| <select id="color_scheme" name="color_scheme"> |
| {% for key, name in color_schemes.items() %} |
| <option value="{{ key }}" {% if settings.color_scheme == key %}selected{% endif %}>{{ name }}</option> |
| {% endfor %} |
| </select> |
| |
| <div style="background: #E0F2F1; padding: 20px; border-radius: 12px; margin-top: 25px; margin-bottom: 25px; border: 1px solid #B2DFDB;"> |
| <h4 style="margin-top: 0; color: var(--bg-medium); font-size: 1.1rem;"><i class="fas fa-th-list"></i> Внешний вид каталога</h4> |
| <label style="display: flex; align-items: center; gap: 8px;"> |
| <input type="checkbox" name="categories_as_lines" {% if settings.categories_as_lines %}checked{% endif %}> |
| Отображать категории линиями (2 строки товаров с горизонтальной прокруткой) |
| </label> |
| </div> |
| |
| <button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Сохранить настройки</button> |
| </form> |
| </div> |
| </details> |
| </div> |
| |
| <div class="flex-container"> |
| <div class="flex-item"> |
| <div class="section"> |
| <h2><i class="fas fa-tags"></i> Управление категориями</h2> |
| <details> |
| <summary><i class="fas fa-plus-circle"></i> Добавить новую категорию</summary> |
| <div class="form-content"> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}"> |
| <input type="hidden" name="action" value="add_category"> |
| <label for="add_category_name">Название новой категории:</label> |
| <input type="text" id="add_category_name" name="category_name" required> |
| <button type="submit" class="add-button"><i class="fas fa-plus"></i> Добавить</button> |
| </form> |
| </div> |
| </details> |
| <h3>Существующие категории:</h3> |
| {% if categories %} |
| <div class="item-list"> |
| {% for category in categories %} |
| <div class="item" style="display: flex; justify-content: space-between; align-items: center;"> |
| <span style="font-size: 1.05rem; font-weight: 500;">{{ category }}</span> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin: 0;" onsubmit="if(!confirm('Вы уверены? Товары этой категории будут помечены как \\'Без категории\\'.')) return false;"> |
| <input type="hidden" name="action" value="delete_category"> |
| <input type="hidden" name="category_name" value="{{ category }}"> |
| <button type="submit" class="delete-button" style="margin: 0;"><i class="fas fa-trash-alt"></i></button> |
| </form> |
| </div> |
| {% endfor %} |
| </div> |
| {% else %} |
| <p style="font-size: 1.05rem;">Категорий пока нет.</p> |
| {% endif %} |
| </div> |
| </div> |
| |
| <div class="flex-item"> |
| <div class="section"> |
| <h2><i class="fas fa-info-circle"></i> Информация о магазине</h2> |
| <details> |
| <summary><i class="fas fa-chevron-down"></i> Развернуть/Свернуть</summary> |
| <div class="form-content"> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}"> |
| <input type="hidden" name="action" value="update_org_info"> |
| <label>О нас:</label><textarea name="about_us" rows="4">{{ organization_info.get('about_us', '') }}</textarea> |
| <label>Доставка:</label><textarea name="shipping" rows="4">{{ organization_info.get('shipping', '') }}</textarea> |
| <label>Возврат:</label><textarea name="returns" rows="4">{{ organization_info.get('returns', '') }}</textarea> |
| <label>Контакты:</label><textarea name="contact" rows="4">{{ organization_info.get('contact', '') }}</textarea> |
| <button type="submit" class="add-button"><i class="fas fa-save"></i> Сохранить</button> |
| </form> |
| </div> |
| </details> |
| </div> |
| </div> |
| </div> |
| |
| <div class="section"> |
| <h2><i class="fas fa-box-open"></i> Управление товарами</h2> |
| <details> |
| <summary><i class="fas fa-plus-circle"></i> Добавить новый товар</summary> |
| <div class="form-content"> |
| <form id="add-product-form" method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" enctype="multipart/form-data" onsubmit="showLoadingOverlay()"> |
| <input type="hidden" name="action" value="add_product"> |
| <label>Название товара *:</label> |
| <input type="text" name="name" required> |
| |
| <label>Фотографии (до 10 шт.):</label> |
| <input type="file" id="add_photos" name="photos" accept="image/*" multiple onchange="handleFileSelect(event, 'add')"> |
| |
| <div style="margin-top: 20px; border: 1px solid #ccc; padding: 15px; border-radius: 8px; background: #fafafa;"> |
| <h4 style="margin-top: 0;"><i class="fas fa-tags"></i> Отметки товаров на фото</h4> |
| <p style="font-size: 0.9rem; color: #666; margin-bottom: 10px;">Загрузите фото, выберите миниатюру и кликните по большому фото, чтобы отметить вариант товара.</p> |
| <div id="thumbs-add" class="thumbnail-row"></div> |
| <input type="hidden" name="tags_json" id="tags_json_add" value="[]"> |
| <div id="tagging-container-add" class="tagging-container"> |
| <img id="tagging-img-add" class="tagging-img" onclick="handleTagClick(event, 'add')"> |
| <div id="tag-markers-add"></div> |
| </div> |
| <div id="tags-list-add" style="margin-top: 15px;"></div> |
| </div> |
| |
| <label>Описание:</label> |
| <textarea id="add_description" name="description" rows="4"></textarea> |
| <div style="display: flex; gap: 10px; align-items: center; margin-top: 10px; flex-wrap: wrap;"> |
| <button type="button" class="button ai-generate-button" style="margin: 0;" onclick="generateDescription('add_photos', 'add_description', 'add_gen_lang')"><i class="fas fa-magic"></i> Сгенерировать</button> |
| <select id="add_gen_lang" name="gen_lang" style="width: auto; margin: 0;"> |
| <option value="Русский">Русский</option><option value="Кыргызский">Кыргызский</option><option value="Казахский">Казахский</option><option value="Узбекский">Узбекский</option> |
| </select> |
| </div> |
| |
| <label style="margin-top: 15px;">Категория:</label> |
| <select name="category"> |
| <option value="Без категории">Без категории</option> |
| {% for category in categories %} |
| <option value="{{ category }}">{{ category }}</option> |
| {% endfor %} |
| </select> |
| |
| <div style="margin-top: 20px; display: flex; align-items: center; gap: 8px;"> |
| <input type="checkbox" id="add_in_stock" name="in_stock" checked><label for="add_in_stock" class="inline-label" style="margin: 0;">В наличии</label> |
| </div> |
| <div style="margin-top: 10px; display: flex; align-items: center; gap: 8px;"> |
| <input type="checkbox" id="add_is_top" name="is_top"><label for="add_is_top" class="inline-label" style="margin: 0;">Топ товар</label> |
| </div> |
| <br> |
| <button type="submit" class="add-button" style="margin-top: 25px;"><i class="fas fa-save"></i> Добавить товар</button> |
| </form> |
| </div> |
| </details> |
| |
| <h3 style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; margin-top: 30px;">Список товаров:</h3> |
| <form method="GET" action="{{ url_for('admin', env_id=env_id) }}" style="margin-bottom: 20px; display: flex; gap: 10px; flex-wrap: wrap;" id="admin-search-form"> |
| <input type="text" name="q" value="{{ search_q }}" placeholder="Поиск по товарам..." style="flex-grow: 1; margin: 0;" id="admin-search-input"> |
| <button type="submit" class="button" style="margin: 0;"><i class="fas fa-search"></i> Поиск</button> |
| {% if search_q %} |
| <a href="{{ url_for('admin', env_id=env_id) }}" class="button" style="background-color: #6c757d; margin: 0; justify-content: center;"><i class="fas fa-times"></i> Сброс</a> |
| {% endif %} |
| </form> |
| |
| {% if paginated_products %} |
| <div class="item-list" id="admin-products-list"> |
| {% for product in paginated_products %} |
| <div class="item"> |
| <div style="display: flex; gap: 15px; align-items: flex-start;"> |
| <div class="photo-preview" style="flex-shrink: 0;"> |
| {% if product.get('photos') %} |
| <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" alt="Фото"> |
| {% else %} |
| <img src="https://via.placeholder.com/80x80.png?text=N/A" alt="Нет фото"> |
| {% endif %} |
| </div> |
| <div style="flex-grow: 1;"> |
| <h3 style="margin-top: 0; margin-bottom: 5px; color: var(--text-dark); font-size: 1.15rem;"> |
| {{ product['name'] }} |
| {% if product.get('in_stock', True) %} |
| <span class="status-indicator in-stock">В наличии</span> |
| {% else %} |
| <span class="status-indicator out-of-stock">Нет</span> |
| {% endif %} |
| </h3> |
| <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p> |
| {% set min_price = 0 %} |
| {% if product.tags %} |
| {% if settings.business_type == 'wholesale' %} |
| {% set prices = product.tags | map(attribute='box_price') | list %} |
| {% else %} |
| {% set prices = product.tags | map(attribute='price') | list %} |
| {% endif %} |
| {% if prices %} |
| {% set min_price = prices | min %} |
| {% endif %} |
| {% endif %} |
| <p><strong>Цена от:</strong> {% if min_price > 0 %}{{ "%.2f"|format(min_price) }} {{ currency_code }}{% else %}Не указана{% endif %}</p> |
| {% if product.get('tags') %} |
| <p><strong>Отметок:</strong> {{ product.tags|length }}</p> |
| {% endif %} |
| <p><strong>Просмотров:</strong> {{ product.get('views', 0) }}</p> |
| </div> |
| </div> |
| |
| <div class="item-actions"> |
| <button type="button" class="button" onclick="toggleEditForm('edit-form-{{ product.product_id }}', '{{ product.product_id }}')"><i class="fas fa-edit"></i> Редактировать</button> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin:0;" onsubmit="if(!confirm('Удалить товар?')) return false; showLoadingOverlay(); return true;"> |
| <input type="hidden" name="action" value="delete_product"> |
| <input type="hidden" name="product_id" value="{{ product.get('product_id', '') }}"> |
| <button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button> |
| </form> |
| </div> |
| |
| <div id="edit-form-{{ product.product_id }}" class="edit-form-container"> |
| <h4 style="margin-top: 0; font-size: 1.1rem;"><i class="fas fa-edit"></i> Редактирование</h4> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" enctype="multipart/form-data" onsubmit="showLoadingOverlay()"> |
| <input type="hidden" name="action" value="edit_product"> |
| <input type="hidden" name="product_id" value="{{ product.get('product_id', '') }}"> |
| <label>Название *:</label> |
| <input type="text" name="name" value="{{ product['name'] }}" required> |
| |
| <label>Заменить фотографии (выбор новых удалит старые):</label> |
| <input type="file" id="edit_photos_{{ product.product_id }}" name="photos" accept="image/*" multiple onchange="handleFileSelect(event, 'edit_{{ product.product_id }}')"> |
| |
| <div style="margin-top: 20px; border: 1px solid #ccc; padding: 15px; border-radius: 8px; background: #fafafa;"> |
| <h4 style="margin-top: 0;"><i class="fas fa-tags"></i> Отметки товаров на фото</h4> |
| <div id="thumbs-edit_{{ product.product_id }}" class="thumbnail-row"></div> |
| <input type="hidden" name="tags_json" id="tags_json_edit_{{ product.product_id }}" value='{{ product.get("tags",[])|tojson|safe }}'> |
| <div id="tagging-container-edit_{{ product.product_id }}" class="tagging-container"> |
| <img id="tagging-img-edit_{{ product.product_id }}" class="tagging-img" onclick="handleTagClick(event, 'edit_{{ product.product_id }}')"> |
| <div id="tag-markers-edit_{{ product.product_id }}"></div> |
| </div> |
| <div id="tags-list-edit_{{ product.product_id }}" style="margin-top: 15px;"></div> |
| </div> |
| |
| <label>Описание:</label> |
| <textarea id="edit_description_{{ product.product_id }}" name="description" rows="4">{{ product.get('description', '') }}</textarea> |
| |
| <label>Категория:</label> |
| <select name="category"> |
| <option value="Без категории">Без категории</option> |
| {% for category in categories %} |
| <option value="{{ category }}" {% if product.get('category') == category %}selected{% endif %}>{{ category }}</option> |
| {% endfor %} |
| </select> |
| |
| <div style="margin-top: 20px; display: flex; align-items: center; gap: 8px;"> |
| <input type="checkbox" id="edit_in_stock_{{ product.product_id }}" name="in_stock" {% if product.get('in_stock', True) %}checked{% endif %}> |
| <label for="edit_in_stock_{{ product.product_id }}" class="inline-label" style="margin: 0;">В наличии</label> |
| </div> |
| <div style="margin-top: 10px; display: flex; align-items: center; gap: 8px;"> |
| <input type="checkbox" id="edit_is_top_{{ product.product_id }}" name="is_top" {% if product.get('is_top', False) %}checked{% endif %}> |
| <label for="edit_is_top_{{ product.product_id }}" class="inline-label" style="margin: 0;">Топ товар</label> |
| </div> |
| <br> |
| <button type="submit" class="add-button" style="margin-top: 25px;"><i class="fas fa-save"></i> Сохранить</button> |
| </form> |
| </div> |
| </div> |
| {% endfor %} |
| </div> |
| |
| {% if total_pages > 1 %} |
| <div class="pagination"> |
| {% if page > 1 %} |
| <a href="{{ url_for('admin', env_id=env_id, p=page-1, q=search_q) }}" class="button">«</a> |
| {% endif %} |
| |
| {% for p_num in range(1, total_pages + 1) %} |
| {% if p_num == 1 or p_num == total_pages or (p_num >= page - 2 and p_num <= page + 2) %} |
| <a href="{{ url_for('admin', env_id=env_id, p=p_num, q=search_q) }}" class="button {% if p_num == page %}active{% endif %}" style="{% if p_num == page %}background-color: var(--accent); color: var(--text-dark);{% else %}background-color: var(--bg-medium); color: white;{% endif %}">{{ p_num }}</a> |
| {% elif p_num == page - 3 or p_num == page + 3 %} |
| <span style="padding: 10px; color: var(--bg-medium); font-weight: bold;">...</span> |
| {% endif %} |
| {% endfor %} |
| |
| {% if page < total_pages %} |
| <a href="{{ url_for('admin', env_id=env_id, p=page+1, q=search_q) }}" class="button">»</a> |
| {% endif %} |
| </div> |
| {% endif %} |
| |
| {% else %} |
| <p style="font-size: 1.1rem; text-align: center; padding: 40px;">Товаров пока нет или по вашему запросу ничего не найдено.</p> |
| {% endif %} |
| </div> |
| </div> |
| |
| <div id="admin-ai-widget"> |
| <div class="ai-chat-header" onclick="toggleAiChat()"> |
| <span><i class="fas fa-robot"></i> AI Аналитик</span> |
| <i class="fas fa-chevron-up" id="ai-chat-chevron"></i> |
| </div> |
| <div class="ai-chat-body" id="ai-chat-body" style="display: none;"> |
| <div class="ai-chat-messages" id="ai-chat-messages"></div> |
| <div style="padding: 12px; background: white; border-top: 1px solid #eee; display: flex; gap: 8px; overflow-x: auto; white-space: nowrap; scrollbar-width: none;"> |
| <button class="ai-quick-btn" onclick="sendAdminAi('Самые просматриваемые товары в моем каталоге')">Топ просмотров</button> |
| <button class="ai-quick-btn" onclick="sendAdminAi('Какие товары лучше продаются?')">Лучшие продажи</button> |
| <button class="ai-quick-btn" onclick="sendAdminAi('На сколько у меня заказали в этом месяце?')">Выручка за месяц</button> |
| </div> |
| <div style="display: flex; padding: 12px; background: white; border-top: 1px solid #eee;"> |
| <input type="text" id="ai-chat-input" style="flex-grow: 1; border: 1px solid #ccc; border-radius: 20px; padding: 10px 15px; outline: none; font-family: inherit; margin: 0;" placeholder="Спросите AI..."> |
| <button onclick="sendAdminAi()" style="background: none; border: none; color: var(--accent); font-size: 1.4rem; cursor: pointer; padding: 0 10px; margin: 0;"><i class="fas fa-paper-plane"></i></button> |
| </div> |
| </div> |
| </div> |
| |
| <script> |
| const allProductsForAdmin = {{ paginated_products|tojson|safe }}; |
| const repoId = '{{ repo_id }}'; |
| const bType = '{{ settings.business_type }}'; |
| const envMode = '{{ settings.env_mode }}'; |
| const formStates = {}; |
| let adminAiHistory =[]; |
| |
| document.addEventListener("DOMContentLoaded", function() { |
| const adminSearchInput = document.getElementById('admin-search-input'); |
| const adminSearchForm = document.getElementById('admin-search-form'); |
| |
| if (adminSearchInput && adminSearchForm) { |
| if (adminSearchInput.value.length > 0) { |
| adminSearchInput.focus(); |
| const valLen = adminSearchInput.value.length; |
| adminSearchInput.setSelectionRange(valLen, valLen); |
| } |
| |
| let debounceTimer; |
| adminSearchInput.addEventListener('input', function() { |
| clearTimeout(debounceTimer); |
| debounceTimer = setTimeout(() => { |
| adminSearchForm.submit(); |
| }, 700); |
| }); |
| } |
| }); |
| |
| function showLoadingOverlay() { |
| document.getElementById('loadingOverlay').style.display = 'flex'; |
| } |
| |
| function toggleCheckoutFields() { |
| const cb = document.getElementById('checkout_fields_enabled'); |
| const list = document.getElementById('checkout_fields_list'); |
| if(cb && list) { list.style.display = cb.checked ? 'flex' : 'none'; } |
| } |
| |
| function initScope(scope) { |
| if(!formStates[scope]) formStates[scope] = { tags:[], fileUrls:[], currentIdx: 0, isEdit: scope.startsWith('edit_') }; |
| } |
| |
| function toggleEditForm(formId, productId) { |
| const formContainer = document.getElementById(formId); |
| if (formContainer) { |
| const isOpening = formContainer.style.display === 'none' || formContainer.style.display === ''; |
| formContainer.style.display = isOpening ? 'block' : 'none'; |
| if (isOpening) { |
| const scope = `edit_${productId}`; |
| const product = allProductsForAdmin.find(p => p.product_id === productId); |
| initScope(scope); |
| |
| let tags =[]; |
| try { tags = JSON.parse(document.getElementById(`tags_json_${scope}`).value); } catch(e){} |
| formStates[scope].tags = tags; |
| |
| formStates[scope].fileUrls =[]; |
| if (product.photos && product.photos.length > 0) { |
| product.photos.forEach(p => { |
| formStates[scope].fileUrls.push(`https://huggingface.co/datasets/${repoId}/resolve/main/photos/${p}`); |
| }); |
| } |
| formStates[scope].currentIdx = 0; |
| renderThumbnails(scope); |
| selectPhoto(scope, 0); |
| } |
| } |
| } |
| |
| function handleFileSelect(event, scope) { |
| initScope(scope); |
| const files = event.target.files; |
| if (!files || files.length === 0) return; |
| |
| formStates[scope].fileUrls = []; |
| formStates[scope].tags =[]; |
| document.getElementById(`tags_json_${scope}`).value = '[]'; |
| formStates[scope].currentIdx = 0; |
| |
| let loadedCount = 0; |
| Array.from(files).forEach((file, idx) => { |
| const reader = new FileReader(); |
| reader.onload = (e) => { |
| formStates[scope].fileUrls[idx] = e.target.result; |
| loadedCount++; |
| if (loadedCount === files.length) { |
| renderThumbnails(scope); |
| selectPhoto(scope, 0); |
| } |
| }; |
| reader.readAsDataURL(file); |
| }); |
| } |
| |
| function renderThumbnails(scope) { |
| const thumbsContainer = document.getElementById(`thumbs-${scope}`); |
| if (!thumbsContainer) return; |
| thumbsContainer.innerHTML = ''; |
| formStates[scope].fileUrls.forEach((url, idx) => { |
| const img = document.createElement('img'); |
| img.src = url; |
| img.className = 'thumbnail-img' + (idx === formStates[scope].currentIdx ? ' active' : ''); |
| img.onclick = () => selectPhoto(scope, idx); |
| thumbsContainer.appendChild(img); |
| }); |
| } |
| |
| function selectPhoto(scope, index) { |
| if(!formStates[scope] || !formStates[scope].fileUrls[index]) return; |
| formStates[scope].currentIdx = index; |
| renderThumbnails(scope); |
| |
| const mainImg = document.getElementById(`tagging-img-${scope}`); |
| if(mainImg) { |
| mainImg.src = formStates[scope].fileUrls[index]; |
| mainImg.style.display = 'block'; |
| } |
| renderTags(scope); |
| } |
| |
| function handleTagClick(event, scope) { |
| initScope(scope); |
| if(formStates[scope].fileUrls.length === 0) return; |
| const img = event.target; |
| const rect = img.getBoundingClientRect(); |
| const x = ((event.clientX - rect.left) / rect.width) * 100; |
| const y = ((event.clientY - rect.top) / rect.height) * 100; |
| |
| const name = prompt("Введите название товара (отметки):"); |
| if (!name) return; |
| |
| const priceStr = prompt("Введите цену за единицу:"); |
| if (!priceStr) return; |
| const price = parseFloat(priceStr.replace(',', '.')); |
| if (isNaN(price) || price < 0) { alert("Неверная цена."); return; } |
| |
| let boxQty = 1; |
| let boxPrice = price; |
| |
| if (bType === 'wholesale' || bType === 'combined') { |
| const qtyStr = prompt("Количество штук в упаковке/коробке:", "1"); |
| boxQty = parseInt(qtyStr) || 1; |
| const bpStr = prompt("Цена за упаковку/коробку целиком:", String(price * boxQty)); |
| boxPrice = parseFloat(bpStr.replace(',', '.')) || (price * boxQty); |
| } |
| |
| const variants = prompt("Варианты через запятую (например: S, M, L). Оставьте пустым, если нет:"); |
| |
| let stock = 0; |
| if (envMode === '2in1') { |
| const stockStr = prompt("Остаток на складе (в шт):", "0"); |
| stock = parseInt(stockStr) || 0; |
| } |
| |
| formStates[scope].tags.push({ |
| id: Math.random().toString(36).substr(2, 9), |
| photo_index: formStates[scope].currentIdx, |
| x: x, |
| y: y, |
| name: name, |
| price: price, |
| box_qty: boxQty, |
| box_price: boxPrice, |
| variants: variants || "", |
| stock: stock, |
| stock_batches: [{"qty": stock, "price": price, "box_price": boxPrice}] |
| }); |
| |
| updateHiddenTags(scope); |
| renderTags(scope); |
| } |
| |
| function updateHiddenTags(scope) { |
| const input = document.getElementById(`tags_json_${scope}`); |
| if(input) input.value = JSON.stringify(formStates[scope].tags ||[]); |
| } |
| |
| function removeTag(scope, id) { |
| if(formStates[scope]) { |
| formStates[scope].tags = formStates[scope].tags.filter(t => t.id !== id); |
| updateHiddenTags(scope); |
| renderTags(scope); |
| } |
| } |
| |
| function renderTags(scope) { |
| const markersContainer = document.getElementById(`tag-markers-${scope}`); |
| const listContainer = document.getElementById(`tags-list-${scope}`); |
| if(!markersContainer || !listContainer) return; |
| |
| markersContainer.innerHTML = ''; |
| listContainer.innerHTML = ''; |
| |
| const tags = formStates[scope].tags ||[]; |
| tags.forEach(tag => { |
| if (tag.photo_index === formStates[scope].currentIdx) { |
| const marker = document.createElement('div'); |
| marker.className = 'tag-marker'; |
| marker.style.left = tag.x + '%'; |
| marker.style.top = tag.y + '%'; |
| marker.title = `${tag.name} - ${tag.price}`; |
| markersContainer.appendChild(marker); |
| } |
| |
| const listItem = document.createElement('div'); |
| listItem.className = 'tag-list-item'; |
| let varText = tag.variants ? `[Вар: ${tag.variants}]` : ''; |
| let boxText = (bType === 'wholesale' || bType === 'combined') ? `[Упак: ${tag.box_qty}шт за ${tag.box_price}]` : ''; |
| let stockText = tag.stock !== null && tag.stock !== undefined ? `[Остаток: ${tag.stock}]` : ''; |
| listItem.innerHTML = ` |
| <span>[Фото ${tag.photo_index + 1}] ${tag.name} (Шт: ${tag.price})${boxText}${varText}${stockText}</span> |
| <button type="button" class="delete-button" style="padding: 5px 10px; font-size: 0.9rem; margin:0;" onclick="removeTag('${scope}', '${tag.id}')"><i class="fas fa-times"></i></button> |
| `; |
| listContainer.appendChild(listItem); |
| }); |
| } |
| |
| async function generateDescription(photoInputId, descriptionTextareaId, languageSelectId) { |
| const photoInput = document.getElementById(photoInputId); |
| const descriptionTextarea = document.getElementById(descriptionTextareaId); |
| const languageSelect = document.getElementById(languageSelectId); |
| if (!photoInput.files || photoInput.files.length === 0) return alert("Загрузите фото."); |
| descriptionTextarea.value = 'Генерация...'; |
| const reader = new FileReader(); |
| reader.onload = async (e) => { |
| const base64Image = e.target.result.split(',')[1]; |
| try { |
| const response = await fetch('/generate_description_ai', { |
| method: 'POST', headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ image: base64Image, language: languageSelect.value }) |
| }); |
| const result = await response.json(); |
| descriptionTextarea.value = result.text || result.error; |
| } catch (error) { descriptionTextarea.value = `Ошибка: ${error.message}`; } |
| }; |
| reader.readAsDataURL(photoInput.files[0]); |
| } |
| |
| function copyEmpLink(link) { |
| navigator.clipboard.writeText(link).then(() => { alert("Ссылка скопирована!"); }).catch(err => { alert("Ошибка копирования"); }); |
| } |
| |
| window.onclick = function(event) { if (event.target.classList.contains('modal')) document.querySelectorAll('.modal').forEach(m => m.style.display = 'none'); } |
| |
| function toggleAiChat() { |
| const body = document.getElementById('ai-chat-body'); |
| const chevron = document.getElementById('ai-chat-chevron'); |
| if (body.style.display === 'none') { |
| body.style.display = 'flex'; |
| chevron.classList.remove('fa-chevron-up'); |
| chevron.classList.add('fa-chevron-down'); |
| } else { |
| body.style.display = 'none'; |
| chevron.classList.remove('fa-chevron-down'); |
| chevron.classList.add('fa-chevron-up'); |
| } |
| } |
| |
| function addAiMessageUI(text, role) { |
| const msgDiv = document.createElement('div'); |
| msgDiv.style.maxWidth = '85%'; |
| msgDiv.style.padding = '12px 16px'; |
| msgDiv.style.borderRadius = '15px'; |
| msgDiv.style.lineHeight = '1.4'; |
| msgDiv.style.fontSize = '0.95rem'; |
| if (role === 'user') { |
| msgDiv.style.alignSelf = 'flex-end'; |
| msgDiv.style.background = 'var(--bg-medium)'; |
| msgDiv.style.color = 'white'; |
| } else { |
| msgDiv.style.alignSelf = 'flex-start'; |
| msgDiv.style.background = '#e0e0e0'; |
| msgDiv.style.color = '#333'; |
| } |
| |
| const productRegex = /\[POST:\\s*([a-fA-F0-9]+)\\s*Название:\\s*([^\]]+)\]/g; |
| let formattedText = text.replace(/</g, "<").replace(/>/g, ">"); |
| formattedText = formattedText.replace(productRegex, (match, pid, name) => { |
| return `<div class="ai-post-link" onclick="openAdminPost('${pid}')" style="background: #fff; border: 1px solid #ccc; padding: 10px; border-radius: 8px; margin: 8px 0; cursor: pointer; color: var(--bg-medium); font-weight: 600;"><i class="fas fa-box"></i> ${name}</div>`; |
| }); |
| msgDiv.innerHTML = formattedText.replace(/\\n/g, '<br>'); |
| document.getElementById('ai-chat-messages').appendChild(msgDiv); |
| document.getElementById('ai-chat-messages').scrollTop = document.getElementById('ai-chat-messages').scrollHeight; |
| } |
| |
| window.openAdminPost = function(pid) { |
| const el = document.getElementById(`edit-form-${pid}`); |
| if (el && (el.style.display === 'none' || el.style.display === '')) { |
| toggleEditForm(`edit-form-${pid}`, pid); |
| } |
| if (el) el.scrollIntoView({behavior: 'smooth', block: 'center'}); |
| }; |
| |
| async function sendAdminAi(predefinedText) { |
| const input = document.getElementById('ai-chat-input'); |
| const message = predefinedText || input.value.trim(); |
| if (!message) return; |
| addAiMessageUI(message, 'user'); |
| adminAiHistory.push({ role: 'user', text: message }); |
| input.value = ''; |
| |
| try { |
| const response = await fetch(`/{{ env_id }}/admin_ai_chat`, { |
| method: 'POST', headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ message: message, history: adminAiHistory }) |
| }); |
| const result = await response.json(); |
| if (result.text) { |
| addAiMessageUI(result.text, 'ai'); |
| adminAiHistory.push({ role: 'ai', text: result.text }); |
| } |
| } catch (e) { |
| addAiMessageUI("Ошибка соединения.", 'ai'); |
| } |
| } |
| |
| document.getElementById('ai-chat-input').addEventListener('keypress', e => { |
| if (e.key === 'Enter') sendAdminAi(); |
| }); |
| </script> |
| </body> |
| </html> |
| ''' |
| @app.route('/') |
| def index(): |
| return render_template_string(LANDING_PAGE_TEMPLATE) |
|
|
| @app.route('/admhosto', methods=['GET']) |
| def admhosto(): |
| data = load_data() |
| environments_data =[] |
| for env_id, env_data in data.items(): |
| settings = env_data.get('settings', {}) |
| org_name = settings.get("organization_name", "Gippo312") |
| env_mode = settings.get("env_mode", "external") |
| environments_data.append({ |
| "id": env_id, |
| "org_name": org_name, |
| "mode": env_mode, |
| "pwd_enabled": settings.get("admin_password_enabled", False), |
| "password": settings.get("admin_password", "") |
| }) |
| environments_data.sort(key=lambda x: x['id']) |
| return render_template_string(ADMHOSTO_TEMPLATE, environments=environments_data) |
|
|
| @app.route('/admhosto/create', methods=['POST']) |
| def create_environment(): |
| all_data = load_data() |
| env_mode = request.form.get('env_mode', 'external') |
| while True: |
| new_id = ''.join(random.choices(string.digits, k=6)) |
| if new_id not in all_data: |
| break |
| all_data[new_id] = { |
| 'products': [], 'categories':[], 'orders': {}, 'employees':[], 'blocks':[], |
| 'organization_info': { |
| "about_us": "Мы — Gippo312, ваш надежный партнер в мире уникальных товаров.", |
| "shipping": "Доставка осуществляется по всему Кыргызстану.", |
| "returns": "Возврат и обмен товара возможен в течение 14 дней.", |
| "contact": "Наш магазин находится по адресу: ... Связаться с нами можно по телефону ..." |
| }, |
| 'settings': { |
| "organization_name": "Gippo312", "whatsapp_number": "+996701202013", |
| "currency_code": "KGS", "chat_name": "EVA", "chat_avatar": None, |
| "color_scheme": "default", |
| "business_type": "retail", "env_mode": env_mode, "welcome_message_enabled": False, "welcome_message_text": "Добро пожаловать в наш магазин!", |
| "inventory_tracking": False, "admin_password_enabled": False, "admin_password": "", |
| "checkout_fields_enabled": False, "checkout_fields": {"name": False, "phone": False, "city": False, "address": False, "zip": False}, |
| "categories_as_lines": False |
| }, |
| 'inventory_history':[] |
| } |
| save_data(all_data) |
| flash(f'Новая среда с ID {new_id} успешно создана.', 'success') |
| return redirect(url_for('admhosto')) |
|
|
| @app.route('/admhosto/update_mode/<env_id>', methods=['POST']) |
| def update_env_mode(env_id): |
| all_data = load_data() |
| if env_id in all_data: |
| new_mode = request.form.get('env_mode', 'external') |
| all_data[env_id]['settings']['env_mode'] = new_mode |
| save_data(all_data) |
| flash(f'Режим среды {env_id} обновлен.', 'success') |
| else: |
| flash(f'Среда {env_id} не найдена.', 'error') |
| return redirect(url_for('admhosto')) |
|
|
| @app.route('/admhosto/update_pwd/<env_id>', methods=['POST']) |
| def update_env_pwd(env_id): |
| all_data = load_data() |
| if env_id in all_data: |
| pwd_enabled = 'pwd_enabled' in request.form |
| password = request.form.get('password', '').strip() |
| all_data[env_id]['settings']['admin_password_enabled'] = pwd_enabled |
| all_data[env_id]['settings']['admin_password'] = password |
| save_data(all_data) |
| flash(f'Пароль для среды {env_id} обновлен.', 'success') |
| else: |
| flash(f'Среда {env_id} не найдена.', 'error') |
| return redirect(url_for('admhosto')) |
|
|
| @app.route('/admhosto/delete/<env_id>', methods=['POST']) |
| def delete_environment(env_id): |
| all_data = load_data() |
| if env_id in all_data: |
| del all_data[env_id] |
| save_data(all_data) |
| flash(f'Среда {env_id} была удалена.', 'success') |
| else: |
| flash(f'Среда {env_id} не найдена.', 'error') |
| return redirect(url_for('admhosto')) |
|
|
| @app.route('/<env_id>/login', methods=['GET', 'POST']) |
| def admin_login(env_id): |
| data = get_env_data(env_id) |
| settings = data.get('settings', {}) |
| |
| if not settings.get('admin_password_enabled'): |
| return redirect(url_for('admin', env_id=env_id)) |
| |
| if request.method == 'POST': |
| pwd = request.form.get('password', '') |
| if pwd == settings.get('admin_password', ''): |
| session[f'admin_auth_{env_id}'] = True |
| return redirect(url_for('admin', env_id=env_id)) |
| else: |
| flash('Неверный пароль', 'error') |
| |
| return render_template_string(LOGIN_TEMPLATE, env_id=env_id) |
|
|
| @app.route('/<env_id>/logout') |
| def admin_logout(env_id): |
| session.pop(f'admin_auth_{env_id}', None) |
| return redirect(url_for('admin_login', env_id=env_id)) |
|
|
| def update_tag_price_from_batches(tag): |
| if 'stock_batches' in tag: |
| for batch in tag['stock_batches']: |
| if batch.get('qty', 0) > 0: |
| tag['price'] = batch.get('price', tag.get('price')) |
| tag['box_price'] = batch.get('box_price', tag.get('box_price')) |
| break |
|
|
| @app.route('/<env_id>/catalog') |
| def catalog(env_id): |
| data = get_env_data(env_id) |
| all_products_raw = data.get('products',[]) |
| settings = data.get('settings', {}) |
| blocks = data.get('blocks',[]) |
| env_mode = settings.get('env_mode', 'external') |
| |
| product_categories = set(p.get('category', 'Без категории') for p in all_products_raw) |
| admin_categories = set(data.get('categories',[])) |
| all_cat_names = sorted(list(product_categories.union(admin_categories))) |
| |
| products_in_stock =[] |
| for p in all_products_raw: |
| if not p.get('in_stock', True): |
| continue |
| |
| if env_mode == '2in1': |
| valid_tags =[t for t in p.get('tags', []) if t.get('stock', 0) > 0] |
| if not valid_tags and p.get('tags',[]): |
| continue |
| |
| p_copy = p.copy() |
| p_copy['tags'] = valid_tags |
| products_in_stock.append(p_copy) |
| else: |
| products_in_stock.append(p) |
|
|
| products_sorted_for_js = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower())) |
| |
| products_by_category = {cat:[] for cat in all_cat_names} |
| for product in products_in_stock: |
| products_by_category[product.get('category', 'Без категории')].append(product) |
| ordered_categories =[cat for cat in all_cat_names if products_by_category.get(cat)] |
| |
| chat_avatar_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/avatars/{settings['chat_avatar']}" if settings.get('chat_avatar') else "https://huggingface.co/spaces/gippo312/admin/resolve/main/Picsart_25-11-04_12-02-21-390.png" |
| return render_template_string( |
| CATALOG_TEMPLATE, ordered_categories=ordered_categories, |
| products_json=json.dumps(products_sorted_for_js), repo_id=REPO_ID, currency_code=settings.get('currency_code', 'KGS'), |
| settings=settings, chat_avatar_url=chat_avatar_url, env_id=env_id, blocks=blocks |
| ) |
|
|
| @app.route('/<env_id>/pos') |
| def pos_page(env_id): |
| data = get_env_data(env_id) |
| settings = data.get('settings', {}) |
| if settings.get('env_mode') != '2in1': |
| return "POS доступен только в режиме '2 в 1'", 403 |
| emp_id = request.args.get('emp', '') |
| all_products_raw = data.get('products',[]) |
| |
| products_in_stock =[] |
| for p in all_products_raw: |
| if not p.get('in_stock', True): |
| continue |
| valid_tags =[t for t in p.get('tags', []) if t.get('stock', 0) > 0] |
| if not valid_tags and p.get('tags',[]): |
| continue |
| p_copy = p.copy() |
| p_copy['tags'] = valid_tags |
| products_in_stock.append(p_copy) |
| |
| return render_template_string(POS_TEMPLATE, products_json=json.dumps(products_in_stock), settings=settings, currency_code=settings.get('currency_code', 'KGS'), env_id=env_id, emp_id=emp_id, repo_id=REPO_ID) |
|
|
| @app.route('/<env_id>/inventory') |
| def inventory_page(env_id): |
| data = get_env_data(env_id) |
| settings = data.get('settings', {}) |
| if settings.get('env_mode') != '2in1': |
| return "Остатки доступны только в режиме '2 в 1'", 403 |
| if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'): |
| return redirect(url_for('admin_login', env_id=env_id)) |
| |
| products = data.get('products', []) |
| items =[] |
| low_stock_count = 0 |
| |
| for p in products: |
| for t in p.get('tags',[]): |
| stock = t.get('stock', 0) |
| is_low = stock <= 50 |
| if is_low: low_stock_count += 1 |
| |
| items.append({ |
| 'product_id': p.get('product_id'), |
| 'product_name': p.get('name'), |
| 'tag_id': t.get('id'), |
| 'tag_name': t.get('name'), |
| 'stock': stock, |
| 'price': t.get('price', 0), |
| 'is_low': is_low |
| }) |
| |
| items.sort(key=lambda x: x['product_name']) |
|
|
| return render_template_string( |
| INVENTORY_TEMPLATE, |
| env_id=env_id, settings=settings, currency_code=settings.get('currency_code', 'KGS'), |
| items=items, low_stock_count=low_stock_count |
| ) |
|
|
| @app.route('/<env_id>/inventory_action', methods=['POST']) |
| def inventory_action(env_id): |
| data = get_env_data(env_id) |
| settings = data.get('settings', {}) |
| if settings.get('env_mode') != '2in1': |
| return jsonify({"error": "Только для режима 2 в 1"}), 403 |
| |
| req = request.get_json() |
| p_id = req.get('product_id') |
| t_id = req.get('tag_id') |
| action = req.get('action') |
| qty = int(req.get('qty', 0)) |
| |
| if qty <= 0: |
| return jsonify({"error": "Количество должно быть больше 0"}), 400 |
| |
| products = data.get('products',[]) |
| product = next((p for p in products if p.get('product_id') == p_id), None) |
| if not product: return jsonify({"error": "Товар не найден"}), 404 |
| |
| tag = next((t for t in product.get('tags',[]) if t.get('id') == t_id), None) |
| if not tag: return jsonify({"error": "Вариант не найден"}), 404 |
|
|
| if 'stock_batches' not in tag: |
| tag['stock_batches'] =[{"qty": tag.get('stock', 0), "price": tag.get('price', 0), "box_price": tag.get('box_price', 0)}] |
| |
| history_entry = { |
| 'id': uuid4().hex, |
| 'product_id': p_id, |
| 'tag_id': t_id, |
| 'type': action, |
| 'qty': qty, |
| 'timestamp': datetime.now(ALMATY_TZ).strftime('%Y-%m-%d %H:%M:%S'), |
| 'details': '' |
| } |
|
|
| if action == 'add': |
| new_price = req.get('new_price') |
| new_box_price = req.get('new_box_price') |
| |
| if new_price is not None: |
| new_price = float(new_price) |
| new_box_price = float(new_box_price) if new_box_price else new_price * tag.get('box_qty', 1) |
| tag['stock_batches'].append({'qty': qty, 'price': new_price, 'box_price': new_box_price}) |
| history_entry['details'] = f'Оприходование (новая цена: {new_price})' |
| else: |
| if tag['stock_batches']: |
| tag['stock_batches'][-1]['qty'] += qty |
| else: |
| tag['stock_batches'].append({'qty': qty, 'price': tag.get('price', 0), 'box_price': tag.get('box_price', 0)}) |
| history_entry['details'] = 'Оприходование (старая цена)' |
| |
| tag['stock'] = tag.get('stock', 0) + qty |
| update_tag_price_from_batches(tag) |
|
|
| elif action == 'write_off': |
| if tag.get('stock', 0) < qty: |
| return jsonify({"error": "Недостаточно остатков для списания"}), 400 |
| |
| remaining_to_deduct = qty |
| for batch in tag['stock_batches']: |
| if batch['qty'] > 0: |
| if batch['qty'] >= remaining_to_deduct: |
| batch['qty'] -= remaining_to_deduct |
| remaining_to_deduct = 0 |
| break |
| else: |
| remaining_to_deduct -= batch['qty'] |
| batch['qty'] = 0 |
| |
| tag['stock'] -= qty |
| history_entry['details'] = 'Ручное списание' |
| update_tag_price_from_batches(tag) |
| |
| else: |
| return jsonify({"error": "Неизвестное действие"}), 400 |
|
|
| if 'inventory_history' not in data: |
| data['inventory_history'] = [] |
| data['inventory_history'].append(history_entry) |
| |
| save_env_data(env_id, data) |
| return jsonify({"success": True}) |
|
|
| @app.route('/<env_id>/inventory_history/<p_id>/<t_id>') |
| def inventory_history(env_id, p_id, t_id): |
| data = get_env_data(env_id) |
| history = data.get('inventory_history',[]) |
| item_history =[h for h in history if h.get('product_id') == p_id and h.get('tag_id') == t_id] |
| item_history.sort(key=lambda x: x['timestamp'], reverse=True) |
| return jsonify(item_history) |
|
|
| @app.route('/<env_id>/reports') |
| def reports_page(env_id): |
| data = get_env_data(env_id) |
| settings = data.get('settings', {}) |
| if settings.get('env_mode') != '2in1': |
| return "Отчеты доступны только в режиме '2 в 1'", 403 |
| if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'): |
| return redirect(url_for('admin_login', env_id=env_id)) |
| |
| now = datetime.now(ALMATY_TZ) |
| default_start = now.replace(day=1).strftime('%Y-%m-%d') |
| default_end = now.strftime('%Y-%m-%d') |
| |
| start_date = request.args.get('start_date', default_start) |
| end_date = request.args.get('end_date', default_end) |
| |
| orders = data.get('orders', {}).values() |
| filtered_orders =[] |
| |
| for o in orders: |
| created_at = o.get('created_at', '') |
| if created_at: |
| date_part = created_at.split(' ')[0] |
| if start_date <= date_part <= end_date: |
| filtered_orders.append(o) |
| |
| total_orders = len(filtered_orders) |
| total_revenue = sum(o.get('total_price', 0) for o in filtered_orders) |
| pos_orders = sum(1 for o in filtered_orders if o.get('source') == 'pos') |
| online_orders = total_orders - pos_orders |
|
|
| emp_stats = {} |
| product_sales = {} |
| |
| for o in filtered_orders: |
| emp = o.get('employee_name') or 'Прямой заказ' |
| if emp not in emp_stats: |
| emp_stats[emp] = {'count': 0, 'revenue': 0} |
| emp_stats[emp]['count'] += 1 |
| emp_stats[emp]['revenue'] += o.get('total_price', 0) |
| |
| for item in o.get('cart',[]): |
| name = item.get('name', 'Неизвестно') |
| qty = item.get('quantity', 0) |
| if name not in product_sales: |
| product_sales[name] = 0 |
| product_sales[name] += qty |
| |
| top_products =[{'name': k, 'qty': v} for k, v in product_sales.items()] |
| top_products.sort(key=lambda x: x['qty'], reverse=True) |
|
|
| return render_template_string( |
| REPORTS_TEMPLATE, env_id=env_id, settings=settings, currency_code=settings.get('currency_code', 'KGS'), |
| total_orders=total_orders, total_revenue=total_revenue, pos_orders=pos_orders, online_orders=online_orders, |
| emp_stats=emp_stats, top_products=top_products[:20], start_date=start_date, end_date=end_date |
| ) |
|
|
| @app.route('/<env_id>/track_view/<product_id>', methods=['POST']) |
| def track_view(env_id, product_id): |
| data = get_env_data(env_id) |
| for p in data['products']: |
| if p.get('product_id') == product_id: |
| p['views'] = p.get('views', 0) + 1 |
| break |
| save_env_data(env_id, data) |
| return jsonify({"status": "ok"}) |
|
|
| @app.route('/<env_id>/product/<product_id>') |
| def product_detail(env_id, product_id): |
| data = get_env_data(env_id) |
| all_products_raw = data.get('products',[]) |
| settings = data.get('settings', {}) |
| env_mode = settings.get('env_mode', 'external') |
| |
| products_in_stock =[] |
| for p in all_products_raw: |
| if not p.get('in_stock', True): |
| continue |
| if env_mode == '2in1': |
| valid_tags =[t for t in p.get('tags', []) if t.get('stock', 0) > 0] |
| if not valid_tags and p.get('tags',[]): |
| continue |
| p_copy = p.copy() |
| p_copy['tags'] = valid_tags |
| products_in_stock.append(p_copy) |
| else: |
| products_in_stock.append(p) |
| |
| products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower())) |
| |
| product = next((p for p in products_sorted if p.get('product_id') == product_id), None) |
| if not product: |
| return "Товар не найден или отсутствует в наличии.", 404 |
| |
| return render_template_string( |
| PRODUCT_DETAIL_TEMPLATE, product=product, repo_id=REPO_ID, |
| currency_code=settings.get('currency_code', 'KGS'), settings=settings, env_id=env_id |
| ) |
|
|
| @app.route('/<env_id>/create_order', methods=['POST']) |
| def create_order(env_id): |
| order_data = request.get_json() |
| if not order_data or 'cart' not in order_data or not order_data['cart']: |
| return jsonify({"error": "Корзина пуста или не передана."}), 400 |
| |
| data = get_env_data(env_id) |
| settings = data.get('settings', {}) |
| products = data.get('products',[]) |
| env_mode = settings.get('env_mode', 'external') |
|
|
| cart_items = order_data['cart'] |
| customer_data = order_data.get('customer_data', {}) |
| emp_id = order_data.get('emp_id') |
| source = order_data.get('source', 'catalog') |
| emp_name = None |
| emp_whatsapp = None |
|
|
| if emp_id: |
| employees = data.get('employees',[]) |
| for emp in employees: |
| if emp.get('id') == emp_id: |
| emp_name = emp.get('name') |
| emp_whatsapp = emp.get('whatsapp') |
| break |
|
|
| total_price = 0 |
| processed_cart =[] |
| order_timestamp = datetime.now(ALMATY_TZ).strftime('%Y-%m-%d %H:%M:%S') |
| |
| for item in cart_items: |
| if not all(k in item for k in ('name', 'quantity')): |
| return jsonify({"error": "Неверный формат товара в корзине."}), 400 |
| try: |
| quantity = int(item['quantity']) |
| if quantity <= 0: |
| raise ValueError("Invalid quantity") |
| |
| p_id = item.get('product_id') |
| c_color = item.get('color', 'N/A') |
| tx = item.get('tag_x') |
| ty = item.get('tag_y') |
| u_type = item.get('unit_type', 'piece') |
| |
| product_ref = next((p for p in products if p.get('product_id') == p_id), None) |
| if not product_ref: |
| return jsonify({"error": f"Товар {p_id} не найден."}), 400 |
| |
| tag_ref = None |
| if 'TAG_' in c_color: |
| tag_id = c_color.split('_VAR_')[0].replace('TAG_', '') |
| tag_ref = next((t for t in product_ref.get('tags',[]) if t.get('id') == tag_id), None) |
| elif item.get('id') and len(item['id'].split('-')) >= 2: |
| tag_id = item['id'].split('-')[1] |
| tag_ref = next((t for t in product_ref.get('tags',[]) if t.get('id') == tag_id), None) |
|
|
| price = float(item.get('price', 0)) |
| discount_applied = False |
| |
| if tag_ref: |
| orig_price = float(tag_ref.get('price', 0)) |
| box_price = float(tag_ref.get('box_price', orig_price)) |
| box_qty = int(tag_ref.get('box_qty', 1)) |
| |
| if u_type == 'piece' and box_qty > 1 and quantity >= box_qty: |
| price = box_price / box_qty |
| discount_applied = True |
| elif u_type == 'box': |
| price = box_price |
| else: |
| price = orig_price |
| |
| if env_mode == '2in1': |
| deduction = quantity |
| if u_type == 'box': |
| deduction = quantity * box_qty |
| |
| if tag_ref.get('stock', 0) < deduction: |
| return jsonify({"error": f"Недостаточно остатков для товара {item['name']}."}), 400 |
| |
| if 'stock_batches' not in tag_ref: |
| tag_ref['stock_batches'] =[{"qty": tag_ref.get('stock', 0), "price": tag_ref.get('price', 0), "box_price": tag_ref.get('box_price', 0)}] |
| |
| remaining_to_deduct = deduction |
| for batch in tag_ref['stock_batches']: |
| if batch['qty'] > 0: |
| if batch['qty'] >= remaining_to_deduct: |
| batch['qty'] -= remaining_to_deduct |
| remaining_to_deduct = 0 |
| break |
| else: |
| remaining_to_deduct -= batch['qty'] |
| batch['qty'] = 0 |
| |
| tag_ref['stock'] -= deduction |
| update_tag_price_from_batches(tag_ref) |
| |
| if 'inventory_history' not in data: |
| data['inventory_history'] = [] |
| data['inventory_history'].append({ |
| 'id': uuid4().hex, |
| 'product_id': p_id, |
| 'tag_id': tag_ref['id'], |
| 'type': 'sale', |
| 'qty': deduction, |
| 'timestamp': order_timestamp, |
| 'details': f"Продажа ({source})" |
| }) |
|
|
| processed_cart.append({ |
| "product_id": p_id, "name": item['name'], "price": price, "quantity": quantity, |
| "color": c_color, "photo": item.get('photo'), "tag_x": tx, "tag_y": ty, "unit_type": u_type, |
| "discount_applied": discount_applied, |
| "photo_url": f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{item['photo']}" if item.get('photo') else "https://via.placeholder.com/60x60.png?text=N/A" |
| }) |
| total_price += price * quantity |
| except (ValueError, TypeError) as e: |
| return jsonify({"error": "Неверная цена или количество в товаре."}), 400 |
|
|
| order_id = f"{datetime.now(ALMATY_TZ).strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6]}" |
|
|
| new_order = { |
| "id": order_id, "created_at": order_timestamp, "cart": processed_cart, |
| "total_price": round(total_price, 2), "status": "new", |
| "employee_id": emp_id, "employee_name": emp_name, "employee_whatsapp": emp_whatsapp, |
| "customer_data": customer_data, "source": source |
| } |
|
|
| try: |
| if 'orders' not in data or not isinstance(data.get('orders'), dict): |
| data['orders'] = {} |
| data['orders'][order_id] = new_order |
| data['products'] = products |
| save_env_data(env_id, data) |
| return jsonify({"order_id": order_id}), 201 |
| except Exception: |
| return jsonify({"error": "Ошибка сервера при сохранении заказа."}), 500 |
|
|
| @app.route('/<env_id>/update_order/<order_id>', methods=['POST']) |
| def update_order(env_id, order_id): |
| data = get_env_data(env_id) |
| order = data.get('orders', {}).get(order_id) |
| if not order: |
| return jsonify({"error": "Заказ не найден."}), 404 |
|
|
| req = request.get_json() |
| idx = req.get('index') |
| action = req.get('action') |
|
|
| if idx is None or action not in['inc', 'dec', 'set', 'remove']: |
| return jsonify({"error": "Некорректный запрос."}), 400 |
|
|
| try: |
| idx = int(idx) |
| cart = order.get('cart',[]) |
| if idx < 0 or idx >= len(cart): |
| return jsonify({"error": "Товар не найден."}), 404 |
|
|
| if action == 'inc': |
| cart[idx]['quantity'] += 1 |
| elif action == 'dec': |
| cart[idx]['quantity'] -= 1 |
| if cart[idx]['quantity'] <= 0: |
| cart.pop(idx) |
| elif action == 'set': |
| val = int(req.get('value', 1)) |
| if val <= 0: |
| cart.pop(idx) |
| else: |
| cart[idx]['quantity'] = val |
| elif action == 'remove': |
| cart.pop(idx) |
|
|
| total = sum(float(item['price']) * int(item['quantity']) for item in cart) |
| order['total_price'] = round(total, 2) |
| order['cart'] = cart |
|
|
| save_env_data(env_id, data) |
| return jsonify({"success": True}) |
| except Exception as e: |
| return jsonify({"error": str(e)}), 500 |
|
|
| @app.route('/<env_id>/delete_order/<order_id>', methods=['POST']) |
| def delete_order(env_id, order_id): |
| data = get_env_data(env_id) |
| if 'orders' in data and order_id in data['orders']: |
| del data['orders'][order_id] |
| save_env_data(env_id, data) |
| flash("Заказ успешно удален.", "success") |
| else: |
| flash("Заказ не найден.", "error") |
| return redirect(url_for('history_page', env_id=env_id)) |
|
|
| @app.route('/<env_id>/order/<order_id>') |
| def view_order(env_id, order_id): |
| data = get_env_data(env_id) |
| order = data.get('orders', {}).get(order_id) |
| settings = data.get('settings', {}) |
| return render_template_string(ORDER_TEMPLATE, order=order, repo_id=REPO_ID, currency_code=settings.get('currency_code', 'KGS'), settings=settings, env_id=env_id) |
|
|
| @app.route('/<env_id>/history') |
| def history_page(env_id): |
| data = get_env_data(env_id) |
| settings = data.get('settings', {}) |
| if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'): |
| return redirect(url_for('admin_login', env_id=env_id)) |
| |
| orders = list(data.get('orders', {}).values()) |
| orders.sort(key=lambda x: x.get('created_at', ''), reverse=True) |
| employees = data.get('employees',[]) |
| return render_template_string(HISTORY_TEMPLATE, orders=orders, employees=employees, settings=settings, currency_code=settings.get('currency_code', 'KGS'), env_id=env_id) |
|
|
| @app.route('/<env_id>/admin_ai_chat', methods=['POST']) |
| def admin_ai_chat(env_id): |
| data = get_env_data(env_id) |
| settings = data.get('settings', {}) |
| if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'): |
| return jsonify({"text": "Доступ запрещен."}) |
| |
| if not configure_gemini(): |
| return jsonify({"text": "AI не настроен."}) |
| |
| req = request.get_json() |
| message = req.get('message') |
| history = req.get('history',[]) |
|
|
| orders = data.get('orders', {}) |
| products = data.get('products',[]) |
|
|
| now = datetime.now(ALMATY_TZ) |
| current_month = now.strftime('%Y-%m') |
|
|
| monthly_revenue = 0 |
| product_sales_counts = {} |
|
|
| for o in orders.values(): |
| if o.get('created_at', '').startswith(current_month): |
| monthly_revenue += o.get('total_price', 0) |
| for item in o.get('cart',[]): |
| pid = item.get('product_id') |
| product_sales_counts[pid] = product_sales_counts.get(pid, 0) + item.get('quantity', 0) |
|
|
| sorted_views = sorted(products, key=lambda x: x.get('views', 0), reverse=True)[:5] |
| views_str_list = [f"[POST: {p['product_id']} Название: {p['name']}] (просмотров: {p.get('views', 0)})" for p in sorted_views if p.get('views', 0) > 0] |
| views_str = ", ".join(views_str_list) if views_str_list else "Нет просмотров" |
|
|
| sorted_sales_pids = sorted(product_sales_counts.items(), key=lambda x: x[1], reverse=True)[:5] |
| sales_str_list =[] |
| for pid, qty in sorted_sales_pids: |
| p = next((x for x in products if x['product_id'] == pid), None) |
| if p: |
| sales_str_list.append(f"[POST: {pid} Название: {p['name']}] (продано: {qty} шт)") |
| sales_str = ", ".join(sales_str_list) if sales_str_list else "Пока нет продаж" |
| |
| currency = data['settings'].get('currency_code', 'KGS') |
|
|
| sys_prompt = f"""Ты — умный AI-ассистент администратора магазина. |
| Текущее время (Алматы): {now.strftime('%Y-%m-%d %H:%M:%S')}. |
| Выручка за этот месяц: {monthly_revenue} {currency}. |
| Самые просматриваемые товары (Топ-5): {views_str}. |
| Самые продаваемые товары (Топ-5): {sales_str}. |
| Если упоминаешь товар, используй точный формат:[POST: <product_id> Название: <product_name>]. |
| Помогай владельцу анализировать продажи и отвечать на бизнес-вопросы.""" |
|
|
| try: |
| model = genai.GenerativeModel('gemma-4-31b-it') |
| messages =[{'role': 'user', 'parts':[{'text': sys_prompt}]}] |
| for h in history: |
| messages.append({'role': 'model' if h['role'] == 'ai' else 'user', 'parts':[{'text': h['text']}]}) |
| chat = model.start_chat(history=messages) |
| resp = chat.send_message(message) |
| return jsonify({'text': resp.text}) |
| except Exception as e: |
| return jsonify({'text': f"Ошибка AI: {str(e)}"}) |
|
|
| @app.route('/<env_id>/admin', methods=['GET', 'POST']) |
| def admin(env_id): |
| data = get_env_data(env_id) |
| settings = data.get('settings', {}) |
| |
| if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'): |
| return redirect(url_for('admin_login', env_id=env_id)) |
| |
| products = data.get('products',[]) |
| categories = data.get('categories',[]) |
| organization_info = data.get('organization_info', {}) |
| employees = data.get('employees',[]) |
| blocks = data.get('blocks',[]) |
|
|
| page = request.args.get('p', 1, type=int) |
| search_q = request.args.get('q', '').strip() |
|
|
| if 'orders' not in data or not isinstance(data.get('orders'), dict): |
| data['orders'] = {} |
| |
| if request.method == 'POST': |
| action = request.form.get('action') |
| try: |
| if action == 'add_block': |
| b_type = request.form.get('block_type') |
| b_title = request.form.get('block_title', '').strip() |
| b_url = request.form.get('block_url', '').strip() |
| if b_url and not b_url.startswith(('http://', 'https://')): |
| b_url = 'https://' + b_url |
| b_content = request.form.get('block_content', '').strip() |
| blocks.append({ |
| 'id': uuid4().hex[:8], |
| 'type': b_type, |
| 'title': b_title, |
| 'url': b_url, |
| 'content': b_content |
| }) |
| data['blocks'] = blocks |
| save_env_data(env_id, data) |
| flash("Блок добавлен.", "success") |
| elif action == 'delete_block': |
| b_id = request.form.get('block_id') |
| data['blocks'] =[b for b in blocks if b.get('id') != b_id] |
| save_env_data(env_id, data) |
| flash("Блок удален.", "success") |
| elif action == 'move_block_up': |
| b_id = request.form.get('block_id') |
| idx = next((i for i, b in enumerate(blocks) if b.get('id') == b_id), -1) |
| if idx > 0: |
| blocks[idx], blocks[idx-1] = blocks[idx-1], blocks[idx] |
| data['blocks'] = blocks |
| save_env_data(env_id, data) |
| flash("Блок перемещен выше.", "success") |
| elif action == 'move_block_down': |
| b_id = request.form.get('block_id') |
| idx = next((i for i, b in enumerate(blocks) if b.get('id') == b_id), -1) |
| if idx != -1 and idx < len(blocks) - 1: |
| blocks[idx], blocks[idx+1] = blocks[idx+1], blocks[idx] |
| data['blocks'] = blocks |
| save_env_data(env_id, data) |
| flash("Блок перемещен ниже.", "success") |
| elif action == 'add_employee': |
| emp_name = request.form.get('emp_name', '').strip() |
| emp_whatsapp = request.form.get('emp_whatsapp', '').strip() |
| if emp_name and emp_whatsapp: |
| emp_id = uuid4().hex[:8] |
| employees.append({'id': emp_id, 'name': emp_name, 'whatsapp': emp_whatsapp}) |
| data['employees'] = employees |
| save_env_data(env_id, data) |
| flash("Сотрудник добавлен.", "success") |
| elif action == 'delete_employee': |
| emp_id = request.form.get('emp_id') |
| employees =[e for e in employees if e.get('id') != emp_id] |
| data['employees'] = employees |
| save_env_data(env_id, data) |
| flash("Сотрудник удален.", "success") |
| elif action == 'add_category': |
| category_name = request.form.get('category_name', '').strip() |
| if category_name and category_name not in categories: |
| categories.append(category_name) |
| data['categories'] = categories |
| save_env_data(env_id, data) |
| flash(f"Категория '{category_name}' успешно добавлена.", 'success') |
| elif not category_name: |
| flash("Название категории не может быть пустым.", 'error') |
| else: |
| flash(f"Категория '{category_name}' уже существует.", 'error') |
|
|
| elif action == 'delete_category': |
| category_to_delete = request.form.get('category_name') |
| if category_to_delete and category_to_delete in categories: |
| categories.remove(category_to_delete) |
| updated_count = 0 |
| for product in products: |
| if product.get('category') == category_to_delete: |
| product['category'] = 'Без категории' |
| updated_count += 1 |
| data['categories'] = categories |
| data['products'] = products |
| save_env_data(env_id, data) |
| flash(f"Категория '{category_to_delete}' удалена. {updated_count} товаров обновлено.", 'success') |
| else: |
| flash(f"Не удалось удалить категорию '{category_to_delete}'.", 'error') |
|
|
| elif action == 'update_org_info': |
| organization_info['about_us'] = request.form.get('about_us', '').strip() |
| organization_info['shipping'] = request.form.get('shipping', '').strip() |
| organization_info['returns'] = request.form.get('returns', '').strip() |
| organization_info['contact'] = request.form.get('contact', '').strip() |
| data['organization_info'] = organization_info |
| save_env_data(env_id, data) |
| flash("Информация о магазине успешно обновлена.", 'success') |
| |
| elif action == 'update_settings': |
| settings['admin_password_enabled'] = 'admin_password_enabled' in request.form |
| settings['admin_password'] = request.form.get('admin_password', '').strip() |
| |
| settings['organization_name'] = request.form.get('organization_name', 'Gippo312').strip() |
| settings['whatsapp_number'] = request.form.get('whatsapp_number', '').strip() |
| settings['currency_code'] = request.form.get('currency_code', 'KGS') |
| settings['business_type'] = request.form.get('business_type', 'retail') |
| settings['color_scheme'] = request.form.get('color_scheme', 'default') |
| |
| settings['checkout_fields_enabled'] = 'checkout_fields_enabled' in request.form |
| settings['checkout_fields'] = { |
| 'name': 'cf_name' in request.form, |
| 'phone': 'cf_phone' in request.form, |
| 'city': 'cf_city' in request.form, |
| 'address': 'cf_address' in request.form, |
| 'zip': 'cf_zip' in request.form |
| } |
| settings['categories_as_lines'] = 'categories_as_lines' in request.form |
|
|
| avatar_file = request.files.get('chat_avatar') |
| if avatar_file and avatar_file.filename: |
| if HF_TOKEN_WRITE: |
| try: |
| api = HfApi() |
| old_avatar = settings.get('chat_avatar') |
| if old_avatar: |
| try: api.delete_files(repo_id=REPO_ID, paths_in_repo=[f"avatars/{old_avatar}"], repo_type="dataset", token=HF_TOKEN_WRITE) |
| except Exception: pass |
| ext = os.path.splitext(avatar_file.filename)[1].lower() |
| avatar_filename = f"avatar_{env_id}_{int(time.time())}{ext}" |
| uploads_dir = 'uploads_temp' |
| os.makedirs(uploads_dir, exist_ok=True) |
| temp_path = os.path.join(uploads_dir, avatar_filename) |
| avatar_file.save(temp_path) |
| api.upload_file(path_or_fileobj=temp_path, path_in_repo=f"avatars/{avatar_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE) |
| settings['chat_avatar'] = avatar_filename |
| os.remove(temp_path) |
| flash("Аватар успешно обновлен.", 'success') |
| except Exception as e: |
| flash(f"Ошибка при загрузке аватара: {e}", 'error') |
| else: |
| flash("HF_TOKEN (write) не настроен. Аватар не был загружен.", "warning") |
|
|
| data['settings'] = settings |
| save_env_data(env_id, data) |
| flash("Настройки успешно обновлены.", 'success') |
|
|
| elif action == 'add_product' or action == 'edit_product': |
| product_id = request.form.get('product_id') |
| product_data = {} |
| is_edit = action == 'edit_product' |
| |
| if is_edit: |
| product_data = next((p for p in products if p.get('product_id') == product_id), None) |
| if not product_data: |
| flash(f"Ошибка: товар с ID {product_id} не найден.", 'error') |
| return redirect(url_for('admin', env_id=env_id, p=page, q=search_q)) |
| else: |
| product_data['views'] = 0 |
| |
| product_data['name'] = request.form.get('name', '').strip() |
| product_data['description'] = request.form.get('description', '').strip() |
| category = request.form.get('category') |
| product_data['category'] = category if category in categories else 'Без категории' |
| |
| tags_raw = request.form.get('tags_json', '[]') |
| try: |
| parsed_tags = json.loads(tags_raw) |
| for t in parsed_tags: |
| if 'stock_batches' not in t: |
| t['stock_batches'] =[{"qty": t.get('stock', 0), "price": t.get('price', 0), "box_price": t.get('box_price', 0)}] |
| product_data['tags'] = parsed_tags |
| except: |
| product_data['tags'] =[] |
| |
| product_data['in_stock'] = 'in_stock' in request.form |
| product_data['is_top'] = 'is_top' in request.form |
|
|
| if not product_data['name']: |
| flash("Название товара обязательно.", 'error') |
| return redirect(url_for('admin', env_id=env_id, p=page, q=search_q)) |
|
|
| photos_files = request.files.getlist('photos') |
| if photos_files and any(f.filename for f in photos_files): |
| if HF_TOKEN_WRITE: |
| uploads_dir = 'uploads_temp' |
| os.makedirs(uploads_dir, exist_ok=True) |
| api = HfApi() |
| new_photos_list =[] |
| photo_limit = 10 |
| uploaded_count = 0 |
| for photo in photos_files: |
| if uploaded_count >= photo_limit: break |
| if photo and photo.filename: |
| try: |
| ext = os.path.splitext(photo.filename)[1].lower() |
| if ext not in['.jpg', '.jpeg', '.png', '.gif', '.webp']: continue |
| safe_name = secure_filename(product_data['name'].replace(' ', '_'))[:50] |
| photo_filename = f"{safe_name}_{uuid4().hex[:8]}{ext}" |
| temp_path = os.path.join(uploads_dir, photo_filename) |
| photo.save(temp_path) |
| api.upload_file(path_or_fileobj=temp_path, path_in_repo=f"photos/{photo_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE) |
| new_photos_list.append(photo_filename) |
| os.remove(temp_path) |
| uploaded_count += 1 |
| except Exception as e: |
| flash(f"Ошибка при загрузке фото {photo.filename}: {e}", 'error') |
| if new_photos_list and is_edit and product_data.get('photos'): |
| try: api.delete_files(repo_id=REPO_ID, paths_in_repo=[f"photos/{p}" for p in product_data['photos']], repo_type="dataset", token=HF_TOKEN_WRITE) |
| except Exception: pass |
| if new_photos_list: |
| product_data['photos'] = new_photos_list |
| else: |
| flash("HF_TOKEN не настроен. Фотографии не загружены.", "warning") |
| |
| if is_edit: |
| product_index = next((i for i, p in enumerate(products) if p.get('product_id') == product_id), -1) |
| if product_index != -1: |
| products[product_index] = product_data |
| flash(f"Товар '{product_data['name']}' обновлен.", 'success') |
| else: |
| product_data['product_id'] = uuid4().hex |
| products.append(product_data) |
| flash(f"Товар '{product_data['name']}' добавлен.", 'success') |
| |
| data['products'] = products |
| save_env_data(env_id, data) |
|
|
| elif action == 'delete_product': |
| product_id = request.form.get('product_id') |
| product_index = next((i for i, p in enumerate(products) if p.get('product_id') == product_id), -1) |
| if product_index == -1: |
| flash(f"Ошибка удаления: товар не найден.", 'error') |
| return redirect(url_for('admin', env_id=env_id, p=page, q=search_q)) |
| deleted_product = products.pop(product_index) |
| product_name = deleted_product.get('name', 'N/A') |
| photos_to_delete = deleted_product.get('photos',[]) |
| if photos_to_delete and HF_TOKEN_WRITE: |
| try: |
| api = HfApi() |
| api.delete_files(repo_id=REPO_ID, paths_in_repo=[f"photos/{p}" for p in photos_to_delete], repo_type="dataset", token=HF_TOKEN_WRITE) |
| except Exception: pass |
| data['products'] = products |
| save_env_data(env_id, data) |
| flash(f"Товар '{product_name}' удален.", 'success') |
| else: |
| flash(f"Неизвестное действие: {action}", 'warning') |
| return redirect(url_for('admin', env_id=env_id, p=page, q=search_q)) |
| except Exception as e: |
| flash(f"Ошибка при выполнении действия.", 'error') |
| return redirect(url_for('admin', env_id=env_id, p=page, q=search_q)) |
|
|
| filtered_products = products |
| if search_q: |
| q_lower = search_q.lower() |
| filtered_products =[p for p in products if q_lower in p.get('name', '').lower() or q_lower in p.get('description', '').lower()] |
| |
| filtered_products = sorted(filtered_products, key=lambda p: p.get('name', '').lower()) |
| |
| PER_PAGE = 20 |
| total_items = len(filtered_products) |
| total_pages = math.ceil(total_items / PER_PAGE) if total_items > 0 else 1 |
| |
| if page < 1: page = 1 |
| if page > total_pages: page = total_pages |
| |
| start_idx = (page - 1) * PER_PAGE |
| end_idx = start_idx + PER_PAGE |
| paginated_products = filtered_products[start_idx:end_idx] |
|
|
| display_categories = sorted(categories) |
| display_organization_info = organization_info |
| display_settings = settings |
| chat_status = { "active": False, "expires_soon": False, "expires_date": "N/A" } |
| chat_avatar_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/avatars/{display_settings['chat_avatar']}" if display_settings.get('chat_avatar') else "https://huggingface.co/spaces/gippo312/admin/resolve/main/Picsart_25-11-04_12-02-21-390.png" |
|
|
| low_stock_count = 0 |
| if settings.get('env_mode') == '2in1': |
| for p in products: |
| for t in p.get('tags',[]): |
| if t.get('stock', 0) <= 50: |
| low_stock_count += 1 |
|
|
| return render_template_string( |
| ADMIN_TEMPLATE, paginated_products=paginated_products, total_pages=total_pages, page=page, search_q=search_q, categories=display_categories, |
| organization_info=display_organization_info, chats={}, settings=display_settings, employees=employees, |
| blocks=blocks, repo_id=REPO_ID, currency_code=display_settings.get('currency_code', 'KGS'), chat_avatar_url=chat_avatar_url, |
| currencies=CURRENCIES, color_schemes=COLOR_SCHEMES, env_id=env_id, chat_status=chat_status, low_stock_count=low_stock_count |
| ) |
|
|
| @app.route('/generate_description_ai', methods=['POST']) |
| def handle_generate_description_ai(): |
| request_data = request.get_json() |
| base64_image = request_data.get('image') |
| language = request_data.get('language', 'Русский') |
| if not base64_image: return jsonify({"error": "Изображение не найдено в запросе."}), 400 |
| try: |
| image_data = base64.b64decode(base64_image) |
| result_text = generate_ai_description_from_image(image_data, language) |
| return jsonify({"text": result_text}) |
| except ValueError as ve: return jsonify({"error": str(ve)}), 400 |
| except Exception as e: return jsonify({"error": f"Внутренняя ошибка сервера: {e}"}), 500 |
|
|
| if __name__ == '__main__': |
| configure_gemini() |
| download_db_from_hf() |
| load_data() |
| if HF_TOKEN_WRITE: |
| backup_thread = threading.Thread(target=periodic_backup, daemon=True) |
| backup_thread.start() |
| port = int(os.environ.get('PORT', 7860)) |
| app.run(debug=False, host='0.0.0.0', port=port) |