lite / app.py
Kgshop's picture
Update app.py
7bb3860 verified
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)">&times;</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, '&quot;');
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')">&times;</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')">&times;</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')">&times;</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">&laquo;</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">&raquo;</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, "&lt;").replace(/>/g, "&gt;");
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)