bc / app.py
Kgshop's picture
Update app.py
697e3c2 verified
Raw
History Blame Contribute Delete
189 kB
import os
import io
import base64
import json
import logging
import threading
import time
import math
import re
import urllib.parse
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, make_response
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/bcnew"
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
DOWNLOAD_RETRIES = 3
DOWNLOAD_DELAY = 5
ALMATY_TZ = timezone(timedelta(hours=6))
db_lock = threading.RLock()
CURRENCIES = {
'KGS': 'Кыргызский сом',
'KZT': 'Казахстанский тенге',
'UAH': 'Украинская гривна',
'RUB': 'Российский рубль',
'USD': 'Доллар США',
'EUR': 'Евро',
'UZS': 'Узбекский сум'
}
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': 'Аметистовый блеск'
}
ICONS = {
'fa-link': 'Ссылка (вебсайт)',
'fa-phone': 'Телефон',
'fa-whatsapp': 'WhatsApp',
'fa-telegram': 'Telegram',
'fa-instagram': 'Instagram',
'fa-envelope': 'Email',
'fa-map-marker-alt': 'Локация/Адрес',
'fa-youtube': 'YouTube',
'fa-tiktok': 'TikTok'
}
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:
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_settings = {
"vcard_firstname": "Имя",
"vcard_lastname": "Фамилия",
"vcard_job": "Специалист",
"organization_name": "Моя Компания",
"currency_code": "KGS",
"chat_avatar": None,
"color_scheme": "default",
"admin_password_enabled": False,
"admin_password": "",
"categories_as_lines": False,
"about_text": "Привет! Это моя онлайн-визитка.",
"enable_cart": False,
"order_messenger": "whatsapp",
"order_contact": ""
}
env_data = all_data.get(env_id, {})
if not env_data:
env_data = {
'products': [], 'categories':[], 'blocks':[], 'orders': [],
'settings': default_settings
}
if 'products' not in env_data: env_data['products'] = []
if 'categories' not in env_data: env_data['categories'] = []
if 'settings' not in env_data: env_data['settings'] = default_settings
if 'blocks' not in env_data: env_data['blocks'] = []
if 'orders' not in env_data: env_data['orders'] = []
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 'price' not in product:
product['price'] = 0.0
products_changed = True
if 'archived' not in product:
product['archived'] = False
products_changed = True
if 'description' not in product:
product['description'] = ""
products_changed = True
if 'variants' not in product:
product['variants'] = []
products_changed = True
if 'search_keywords' not in product:
product['search_keywords'] = ""
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)
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>Платформа Онлайн-Визиток</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: 24px; box-shadow: 0 10px 40px rgba(0,0,0,0.05); text-align: center; width: 100%; max-width: 350px; }
h2 { color: #135D66; margin-bottom: 20px; font-weight: 600; }
input[type="password"] { width: 100%; padding: 16px; margin-bottom: 20px; border: 1px solid transparent; background: #f1f3f5; border-radius: 14px; font-size: 1rem; transition: all 0.3s; }
input[type="password"]:focus { background: #fff; border-color: #48D1CC; box-shadow: 0 0 0 4px rgba(72, 209, 204, 0.2); outline: none; }
button { width: 100%; padding: 16px; background-color: #48D1CC; color: #003C43; border: none; border-radius: 14px; font-weight: 600; cursor: pointer; font-size: 1rem; transition: background 0.3s; }
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: 30px; border-radius: 24px; box-shadow: 0 10px 40px rgba(0,0,0,0.04); }
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; }
#search-env { width: 100%; padding: 16px; border: 1px solid transparent; background: #f1f3f5; border-radius: 14px; font-size: 1rem; font-family: 'Montserrat', sans-serif; transition: all 0.3s; }
#search-env:focus { background: #fff; border-color: var(--accent); box-shadow: 0 0 0 4px rgba(72, 209, 204, 0.2); outline: none; }
.button { padding: 12px 20px; border: none; border-radius: 12px; 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: 8px; min-height: 48px; }
.button:hover { background-color: var(--accent-hover); }
.env-list { list-style: none; padding: 0; }
.env-item { background: #fff; border: 1px solid #f0f0f0; border-radius: 16px; padding: 20px; margin-bottom: 15px; display: flex; flex-direction: column; gap: 15px; box-shadow: 0 4px 15px rgba(0,0,0,0.02); transition: transform 0.2s; }
.env-item:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.05); }
.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: #f8f9fa; padding: 15px; border-radius: 12px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.env-pwd input[type="text"] { padding: 12px; border: 1px solid #e0e0e0; background: #fff; border-radius: 10px; flex-grow: 1; min-height: 44px; outline: none; transition: border-color 0.3s; }
.env-pwd input[type="text"]:focus { border-color: var(--bg-medium); }
.delete-button { background-color: var(--danger); color: white; }
.message { padding: 15px; border-radius: 12px; margin-bottom: 15px; text-align: center; 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; }
input[type="checkbox"] { width: 22px; height: 22px; accent-color: var(--bg-medium); cursor: pointer; }
</style>
</head>
<body>
<div class="container">
<h1><i class="fas fa-id-card"></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">
<button type="submit" class="button" style="font-size:1.1rem; padding: 16px 30px; border-radius: 16px;"><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>
</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-external-link-alt"></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: 15px; align-items: center; flex-wrap: wrap; width: 100%;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 500;"><input type="checkbox" name="pwd_enabled" {% if env.pwd_enabled %}checked{% endif %}> Вкл. пароль</label>
<input type="text" name="password" value="{{ env.password }}" placeholder="Пароль">
<button type="submit" class="button" style="font-size: 0.95rem; min-height: 44px;">Сохранить</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p style="text-align:center; color:#888;">Пока не создано ни одной визитки.</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.vcard_firstname }} {{ settings.vcard_lastname }} - Визитка</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; --card-bg: rgba(255,255,255,0.08); --border-color: rgba(255,255,255,0.15); }
{% 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; --card-bg: rgba(255,255,255,0.08); --border-color: rgba(255,255,255,0.15); }
{% 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; --card-bg: rgba(255,255,255,0.08); --border-color: rgba(255,255,255,0.15); }
{% 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; --card-bg: rgba(255,255,255,0.08); --border-color: rgba(255,255,255,0.15); }
{% 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; --card-bg: rgba(255,255,255,0.08); --border-color: rgba(255,255,255,0.15); }
{% 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; --card-bg: rgba(255,255,255,0.05); --border-color: rgba(255,255,255,0.1); }
{% 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; --card-bg: rgba(255,255,255,0.08); --border-color: rgba(255,255,255,0.15); }
{% 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; --card-bg: rgba(255,255,255,0.08); --border-color: rgba(255,255,255,0.15); }
{% 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; --card-bg: rgba(255,255,255,0.08); --border-color: rgba(255,255,255,0.15); }
{% 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; --card-bg: rgba(255,255,255,0.08); --border-color: rgba(255,255,255,0.15); }
{% 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; --card-bg: rgba(255,255,255,0.08); --border-color: rgba(255,255,255,0.15); }
{% 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; --card-bg: rgba(255,255,255,0.08); --border-color: rgba(255,255,255,0.15); }
{% 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; --card-bg: rgba(255,255,255,0.08); --border-color: rgba(255,255,255,0.15); }
{% 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; --card-bg: rgba(255,255,255,0.4); --border-color: rgba(255,255,255,0.6); }
{% 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; --card-bg: rgba(255,255,255,0.08); --border-color: rgba(255,255,255,0.15); }
{% 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; --card-bg: rgba(255,255,255,0.05); --border-color: rgba(255,255,255,0.1); }
{% 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; --card-bg: rgba(255,255,255,0.4); --border-color: rgba(255,255,255,0.6); }
{% 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; --card-bg: rgba(255,255,255,0.4); --border-color: rgba(255,255,255,0.6); }
{% 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; --card-bg: rgba(255,255,255,0.05); --border-color: rgba(255,255,255,0.1); }
{% 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; --card-bg: rgba(255,255,255,0.6); --border-color: rgba(0,0,0,0.1); }
{% 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; --card-bg: rgba(255,255,255,0.05); --border-color: rgba(255,255,255,0.1); }
{% 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; --card-bg: rgba(255,255,255,0.05); --border-color: rgba(255,255,255,0.1); }
{% 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; --card-bg: rgba(255,255,255,0.05); --border-color: rgba(255,255,255,0.1); }
{% 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; --card-bg: rgba(255,255,255,0.08); --border-color: rgba(255,255,255,0.15); }
{% 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; --card-bg: rgba(255,255,255,0.05); --border-color: rgba(255,255,255,0.1); }
{% 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; --card-bg: rgba(255,255,255,0.08); --border-color: rgba(255,255,255,0.15); }
{% else %}
:root { --bg-dark: #003C43; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-light: #E3FEF7; --text-dark: #003C43; --danger: #E57373; --card-bg: rgba(255,255,255,0.08); --border-color: rgba(255,255,255,0.15); }
{% 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; overflow-x: hidden; }
.main-wrapper { max-width: 680px; margin: 0 auto; padding: 40px 20px 90px 20px; display: flex; flex-direction: column; align-items: center; position: relative; }
.qr-btn { position: absolute; top: 20px; right: 20px; font-size: 1.5rem; color: var(--accent); background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 50%; width: 45px; height: 45px; display: flex; align-items: center; justify-content: center; cursor: pointer; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 100; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.qr-btn:hover { background: var(--accent); color: var(--text-dark); transform: scale(1.1); box-shadow: 0 8px 24px rgba(0,0,0,0.2); }
.profile-header { text-align: center; margin-bottom: 35px; width: 100%; display: flex; flex-direction: column; align-items: center; }
.avatar { width: 140px; height: 140px; border-radius: 50%; border: 4px solid var(--accent); box-shadow: 0 12px 30px rgba(0,0,0,0.2); object-fit: cover; margin-bottom: 20px; transition: transform 0.3s; }
.avatar:hover { transform: scale(1.05); }
.profile-name { font-size: 1.8rem; font-weight: 800; margin-bottom: 5px; color: var(--text-light); letter-spacing: -0.5px; }
.profile-job { font-size: 1.15rem; font-weight: 500; opacity: 0.9; margin-bottom: 5px; }
.profile-company { font-size: 1.05rem; font-weight: 400; opacity: 0.7; }
.profile-about { margin-top: 20px; font-size: 1.05rem; line-height: 1.7; opacity: 0.85; max-width: 500px; text-align: center; }
.save-contact-btn { display: flex; align-items: center; justify-content: center; gap: 12px; width: 100%; max-width: 400px; padding: 20px; background: var(--accent); color: var(--text-dark); border-radius: 30px; text-decoration: none; font-weight: 700; font-size: 1.15rem; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 8px 25px rgba(0,0,0,0.2); margin-bottom: 40px; border: none; cursor: pointer; letter-spacing: 0.5px; }
.save-contact-btn:hover { transform: translateY(-4px); box-shadow: 0 12px 30px rgba(0,0,0,0.3); background: var(--accent-hover); }
.save-contact-btn:active { transform: translateY(0); box-shadow: 0 4px 15px rgba(0,0,0,0.2); }
.blocks-container { width: 100%; display: flex; flex-direction: column; gap: 16px; margin-bottom: 45px; }
.block-link { display: flex; align-items: center; justify-content: center; position: relative; background: var(--card-bg); color: var(--text-light); text-align: center; padding: 20px 24px; border-radius: 24px; text-decoration: none; font-weight: 600; font-size: 1.15rem; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid var(--border-color); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); min-height: 70px; overflow: hidden; box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
.block-link:hover { transform: translateY(-3px) scale(1.02); background: var(--bg-medium); border-color: var(--accent); box-shadow: 0 8px 25px rgba(0,0,0,0.2); }
.block-link:active { transform: translateY(0) scale(1); }
.block-icon { position: absolute; left: 24px; font-size: 1.8rem; color: var(--accent); transition: transform 0.3s; }
.block-link:hover .block-icon { transform: scale(1.1); }
.block-text { background: var(--card-bg); padding: 26px; border-radius: 24px; border: 1px solid var(--border-color); text-align: center; color: var(--text-light); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
.block-text h3 { margin-bottom: 15px; font-size: 1.35rem; color: var(--accent); font-weight: 700; }
.catalog-section { width: 100%; margin-top: 10px; }
.search-filter-container { display: flex; gap: 12px; margin-bottom: 25px; position: sticky; top: 15px; z-index: 90; }
.search-box { flex: 1; display: flex; align-items: center; background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 30px; padding: 0 20px; backdrop-filter: blur(15px); -webkit-backdrop-filter: blur(15px); box-shadow: 0 8px 25px rgba(0,0,0,0.15); transition: border-color 0.3s; }
.search-box:focus-within { border-color: var(--accent); }
.search-box i { color: var(--accent); font-size: 1.2rem; margin-right: 12px; }
.search-box input { flex: 1; background: transparent; border: none; color: var(--text-light); font-size: 1.05rem; padding: 18px 0; outline: none; font-family: 'Montserrat', sans-serif; }
.search-box input::placeholder { color: var(--text-light); opacity: 0.6; }
.filter-btn { width: 60px; height: 60px; border-radius: 50%; background: var(--accent); color: var(--text-dark); border: none; display: flex; justify-content: center; align-items: center; font-size: 1.3rem; cursor: pointer; box-shadow: 0 8px 25px rgba(0,0,0,0.2); transition: all 0.3s; flex-shrink: 0; }
.filter-btn:hover { background: var(--accent-hover); transform: translateY(-3px); }
.filter-btn.active-filter { border: 3px solid var(--text-light); }
.category-chips-container { margin-bottom: 30px; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding: 5px 0 10px 0; }
.category-chips-container::-webkit-scrollbar { display: none; }
.category-chips { display: inline-flex; gap: 12px; padding: 0 5px; }
.chip { padding: 12px 26px; border-radius: 30px; background-color: var(--card-bg); color: var(--text-light); border: 1px solid var(--border-color); font-size: 1.05rem; font-weight: 600; cursor: pointer; transition: all 0.3s ease; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); box-shadow: 0 4px 10px rgba(0,0,0,0.05); }
.chip:hover { border-color: rgba(255,255,255,0.4); transform: translateY(-2px); }
.chip.active { background-color: var(--accent); color: var(--text-dark); border-color: var(--accent); box-shadow: 0 6px 15px rgba(0,0,0,0.2); transform: scale(1.05); }
.product-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; width: 100%; }
.product-card { position: relative; border-radius: 24px; overflow: hidden; cursor: pointer; aspect-ratio: 1/1; background: var(--card-bg); border: 1px solid var(--border-color); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 8px 20px rgba(0,0,0,0.15); }
.product-card:hover { transform: translateY(-8px); border-color: rgba(255,255,255,0.4); box-shadow: 0 15px 30px rgba(0,0,0,0.3); }
.product-image-container { width: 100%; height: 100%; position: relative; }
.product-image-container img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.5s; }
.product-card:hover .product-image-container img { transform: scale(1.08); }
.product-price-tag { position: absolute; bottom: 12px; right: 12px; background: rgba(0,0,0,0.8); color: #fff; padding: 8px 14px; border-radius: 16px; font-weight: 700; font-size: 1rem; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 10px rgba(0,0,0,0.3); }
.no-results-message { text-align: center; padding: 60px 20px; font-size: 1.25rem; opacity: 0.7; grid-column: 1 / -1; background: var(--card-bg); border-radius: 24px; border: 1px solid var(--border-color); backdrop-filter: blur(10px); }
.modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.85); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); overflow-y: auto; -webkit-overflow-scrolling: touch; align-items: flex-end; justify-content: center; }
.modal-content { background: var(--bg-dark); color: var(--text-light); margin: auto 0 0 0; padding: 0; border-radius: 36px 36px 0 0; width: 100%; max-width: 600px; min-height: auto; padding-bottom: 40px; position: relative; box-shadow: 0 -15px 40px rgba(0,0,0,0.4); animation: slideUp 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); border: 1px solid rgba(255,255,255,0.05); border-bottom: none; }
@media (min-width: 600px) { .modal-content { margin: auto; border-radius: 36px; padding-bottom: 0; border-bottom: 1px solid rgba(255,255,255,0.05); } .modal { align-items: center; } }
@keyframes slideUp { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@keyframes fadeInScale { from { transform: scale(0.9); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.qr-modal-content { background: var(--bg-dark); color: var(--text-light); margin: auto; padding: 45px 30px; border-radius: 36px; width: 90%; max-width: 380px; position: relative; box-shadow: 0 20px 50px rgba(0,0,0,0.5); text-align: center; animation: fadeInScale 0.3s cubic-bezier(0.2, 0.8, 0.2, 1); border: 1px solid rgba(255,255,255,0.1); }
.close-btn { position: absolute; top: 20px; right: 20px; width: 48px; height: 48px; background: rgba(0,0,0,0.6); border-radius: 50%; display: flex; justify-content: center; align-items: center; color: white; font-size: 1.6rem; cursor: pointer; z-index: 10; border: 1px solid rgba(255,255,255,0.1); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); transition: all 0.3s; }
.close-btn:hover { background: var(--danger); transform: scale(1.1); border-color: var(--danger); }
.pagination { display: flex; justify-content: center; gap: 12px; margin-top: 45px; padding-bottom: 30px; align-items: center; flex-wrap: wrap; }
.pagination button { width: 48px; height: 48px; border: 1px solid var(--border-color); border-radius: 16px; background: var(--card-bg); color: var(--text-light); font-weight: 700; cursor: pointer; transition: all 0.3s; font-size: 1.1rem; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); }
.pagination button.active { background: var(--accent); color: var(--text-dark); border-color: var(--accent); transform: scale(1.1); box-shadow: 0 6px 15px rgba(0,0,0,0.2); }
.pagination button:hover:not(.active) { background: rgba(255,255,255,0.15); transform: translateY(-2px); }
.dark-theme .modal-content, .dark-theme .qr-modal-content { background: #16161a; }
.cart-fab { position: fixed; bottom: 30px; right: 30px; background: var(--accent); color: var(--text-dark); width: 70px; height: 70px; border-radius: 35px; display: flex; justify-content: center; align-items: center; font-size: 1.8rem; cursor: pointer; box-shadow: 0 10px 30px rgba(0,0,0,0.3); z-index: 999; display: none; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); border: none; }
.cart-fab:hover { transform: scale(1.1) translateY(-5px); box-shadow: 0 15px 40px rgba(0,0,0,0.4); }
.cart-badge { position: absolute; top: -5px; right: -5px; background: var(--danger); color: white; border-radius: 50%; width: 30px; height: 30px; font-size: 0.95rem; display: flex; justify-content: center; align-items: center; font-weight: 800; border: 3px solid var(--bg-dark); box-shadow: 0 4px 10px rgba(0,0,0,0.2); }
.cart-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; margin-top: 25px; padding: 0 10px;}
.clear-cart-btn { background: transparent; color: var(--danger); border: 2px solid var(--danger); padding: 12px 20px; border-radius: 20px; font-weight: 700; cursor: pointer; font-size: 1rem; transition: all 0.3s; }
.clear-cart-btn:hover { background: var(--danger); color: white; transform: translateY(-2px); box-shadow: 0 6px 15px rgba(229, 115, 115, 0.3); }
.cart-item { display: flex; justify-content: space-between; align-items: center; padding: 20px 10px; border-bottom: 1px solid rgba(255,255,255,0.05); gap: 16px; transition: background 0.3s; border-radius: 20px;}
.cart-item:hover { background: rgba(255,255,255,0.03); }
.cart-item img { width: 85px; height: 85px; border-radius: 16px; object-fit: cover; transition: transform 0.3s; cursor: zoom-in; box-shadow: 0 4px 10px rgba(0,0,0,0.2); }
.cart-item img.zoomed {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 90vw !important; height: 90vh !important; object-fit: contain !important;
z-index: 99999; background: rgba(0,0,0,0.95); box-shadow: 0 0 0 100vmax rgba(0,0,0,0.95);
cursor: zoom-out; border-radius: 0 !important; border: none; padding: 20px; backdrop-filter: blur(10px);
}
.cart-item-info { flex-grow: 1; display:flex; flex-direction:column; justify-content:center; gap: 10px;}
.cart-item-price { font-size: 1.15rem; font-weight: 800; color: var(--accent); }
.cart-controls { display: flex; justify-content: space-between; align-items: center; background: var(--bg-medium); border-radius: 16px; overflow: hidden; height: 40px; width: 120px; box-shadow: inset 0 2px 5px rgba(0,0,0,0.1); }
.cart-controls button { background: var(--accent); color: var(--text-dark); border: none; width: 40px; height: 100%; font-weight: bold; cursor: pointer; font-size: 1.4rem; transition: background 0.2s; }
.cart-controls button:hover { background: var(--accent-hover); }
.cart-controls input { width: 40px; text-align: center; border: none; background: transparent; color: white; font-weight: 700; font-size: 1.15rem; outline: none; -moz-appearance: textfield; }
.cart-controls input::-webkit-outer-spin-button, .cart-controls input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.cart-remove-btn { background: var(--danger); color: white; border: none; border-radius: 16px; width: 48px; height: 48px; font-size: 1.3rem; cursor: pointer; display: flex; justify-content: center; align-items: center; transition: all 0.3s; box-shadow: 0 4px 10px rgba(229, 115, 115, 0.2); }
.cart-remove-btn:hover { background: #ff4444; transform: scale(1.1); }
.add-to-cart-btn { background: var(--accent); color: var(--text-dark); border: none; padding: 16px; border-radius: 30px; font-weight: 800; cursor: pointer; width: 100%; font-size: 1.15rem; box-shadow: 0 8px 25px rgba(0,0,0,0.2); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); letter-spacing: 0.5px; }
.add-to-cart-btn:hover { background: var(--accent-hover); transform: translateY(-3px); box-shadow: 0 12px 30px rgba(0,0,0,0.3); }
.add-to-cart-btn:active { transform: translateY(0); box-shadow: 0 4px 10px rgba(0,0,0,0.2); }
.swiper-zoom-container { width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; }
.swiper-zoom-container img { max-width: 100%; max-height: 100%; object-fit: contain; }
.swiper-pagination-bullet { background: #fff !important; opacity: 0.4; width: 10px; height: 10px; transition: all 0.3s; }
.swiper-pagination-bullet-active { opacity: 1; background: var(--accent) !important; transform: scale(1.3); }
.input-glass { width: 100%; padding: 18px 20px; margin-bottom: 16px; border-radius: 20px; border: 1px solid var(--border-color); outline: none; background: var(--card-bg); color: var(--text-light); font-family: 'Montserrat', sans-serif; font-size: 1.05rem; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); transition: border-color 0.3s, box-shadow 0.3s; }
.input-glass:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(72, 209, 204, 0.2); background: rgba(255,255,255,0.1); }
.input-glass::placeholder { color: var(--text-light); opacity: 0.6; }
</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="main-wrapper">
<div class="qr-btn" onclick="openQrModal()">
<i class="fas fa-qrcode"></i>
</div>
<div class="profile-header">
<img src="{{ chat_avatar_url }}" alt="Avatar" class="avatar" loading="lazy" decoding="async">
<div class="profile-name">{{ settings.vcard_firstname }} {{ settings.vcard_lastname }}</div>
{% if settings.vcard_job %}<div class="profile-job">{{ settings.vcard_job }}</div>{% endif %}
{% if settings.organization_name %}<div class="profile-company">{{ settings.organization_name }}</div>{% endif %}
{% if settings.about_text %}<div class="profile-about">{{ settings.about_text|replace('\\n', '<br>')|safe }}</div>{% endif %}
</div>
<a href="{{ url_for('download_vcard', env_id=env_id) }}" class="save-contact-btn">
<i class="fas fa-address-book"></i> Сохранить в контакты
</a>
{% 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">
{% if block.icon %}
{% set prefix = 'fab' if block.icon in ['fa-whatsapp', 'fa-telegram', 'fa-instagram', 'fa-youtube', 'fa-tiktok'] else 'fas' %}
<i class="{{ prefix }} {{ block.icon }} block-icon"></i>
{% endif %}
<span>{{ block.title }}</span>
</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>
{% elif block.type == 'pdf' %}
<div class="block-link" onclick="openPdfModal('https://huggingface.co/datasets/{{ repo_id }}/resolve/main/pdfs/{{ block.url }}')" style="cursor: pointer;">
{% if block.icon %}
{% set prefix = 'fab' if block.icon in ['fa-whatsapp', 'fa-telegram', 'fa-instagram', 'fa-youtube', 'fa-tiktok'] else 'fas' %}
<i class="{{ prefix }} {{ block.icon }} block-icon"></i>
{% endif %}
<span>{{ block.title }}</span>
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if products_json != '[]' %}
<div class="catalog-section">
<div class="search-filter-container">
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" id="searchInput" placeholder="Поиск (название, описание, слова)..." onkeyup="handleSearch()">
</div>
<button id="filterBtnIcon" class="filter-btn" onclick="openFilterModal()"><i class="fas fa-sliders-h"></i></button>
</div>
<div class="category-chips-container">
<div class="category-chips" id="category-chips"></div>
</div>
<div id="catalog-content"></div>
</div>
{% endif %}
</div>
<button id="cart-fab" class="cart-fab" onclick="openCartModal()">
<i class="fas fa-shopping-cart"></i>
<span id="cart-badge" class="cart-badge">0</span>
</button>
<div id="productModal" class="modal">
<div class="modal-content">
<button class="close-btn" onclick="closeModal('productModal')"><i class="fas fa-times"></i></button>
<div id="modalContent"></div>
</div>
</div>
<div id="filterModal" class="modal" style="align-items: center;">
<div class="qr-modal-content" style="max-width: 400px; text-align: left; padding: 40px 30px;">
<button class="close-btn" onclick="closeModal('filterModal')" style="top: -15px; right: -15px; background: var(--danger); border-color: var(--danger);"><i class="fas fa-times"></i></button>
<h2 style="margin-bottom: 25px; color: var(--accent); text-align: center; font-weight: 800;">Фильтр по цене</h2>
<div style="display: flex; flex-direction: column; gap: 15px;">
<div>
<label style="display:block; margin-bottom: 8px; font-weight: 600; opacity:0.9;">Цена от:</label>
<input type="number" id="minPriceInput" class="input-glass" placeholder="Минимум">
</div>
<div>
<label style="display:block; margin-bottom: 8px; font-weight: 600; opacity:0.9;">Цена до:</label>
<input type="number" id="maxPriceInput" class="input-glass" placeholder="Максимум">
</div>
</div>
<button class="save-contact-btn" style="width: 100%; margin-top: 30px; margin-bottom: 15px;" onclick="applyFilter()">Применить фильтр</button>
<button class="clear-cart-btn" style="width: 100%; text-align: center;" onclick="clearFilter()">Сбросить всё</button>
</div>
</div>
<div id="qrModal" class="modal" style="align-items: center;">
<div class="qr-modal-content">
<button class="close-btn" onclick="closeModal('qrModal')" style="top: -15px; right: -15px; background: var(--danger); border-color: var(--danger);"><i class="fas fa-times"></i></button>
<h2 style="margin-bottom: 25px; color: var(--accent); font-weight: 800;">Мой QR-код</h2>
<div style="background: white; padding: 20px; border-radius: 24px; display: inline-block; box-shadow: 0 10px 25px rgba(0,0,0,0.2);">
<img id="qrImage" src="" alt="QR Code" style="width: 220px; height: 220px; display: block;" loading="lazy" decoding="async">
</div>
<p style="margin-top: 25px; font-size: 1rem; opacity: 0.8; font-weight: 500;">Отсканируйте, чтобы открыть визитку</p>
</div>
</div>
<div id="pdfModal" class="modal" style="align-items: center;">
<div class="modal-content" style="max-width: 800px; height: 90vh; display: flex; flex-direction: column; border-radius: 36px; padding-bottom: 0;">
<button class="close-btn" onclick="closeModal('pdfModal')" style="top: 20px; right: 20px;"><i class="fas fa-times"></i></button>
<div style="flex-grow: 1; padding: 20px; padding-top: 80px;">
<iframe id="pdfIframe" src="" width="100%" height="100%" style="border: none; border-radius: 20px; background: white;" type="application/pdf"></iframe>
</div>
</div>
</div>
<div id="cartModal" class="modal">
<div class="modal-content" style="padding: 30px 20px; display: flex; flex-direction: column; min-height: 85vh; max-height: 95vh;">
<button class="close-btn" onclick="closeModal('cartModal')"><i class="fas fa-times"></i></button>
<div class="cart-header">
<h2 style="color: var(--accent); margin:0; font-weight: 800; font-size: 1.8rem;">Корзина</h2>
<button onclick="clearCart()" class="clear-cart-btn" id="clearCartBtn"><i class="fas fa-trash-alt"></i> Очистить</button>
</div>
<div id="cartItems" style="flex-grow: 1; overflow-y: auto; padding-right: 5px; margin-bottom: 20px;"></div>
<div id="cartTotal" style="font-size: 1.6rem; font-weight: 800; text-align: right; color: var(--accent); margin-bottom: 25px; padding: 0 10px;"></div>
<div id="checkoutForm" style="background: rgba(0,0,0,0.2); padding: 25px; border-radius: 24px; border: 1px solid var(--border-color); backdrop-filter: blur(10px);">
<h3 style="margin-top: 0; margin-bottom: 20px; font-weight: 700; color: var(--text-light);">Оформление заказа</h3>
<input type="text" id="customerName" class="input-glass" placeholder="Ваше Имя" required>
<input type="tel" id="customerPhone" class="input-glass" placeholder="Ваш Телефон" required>
<button onclick="submitOrder()" class="save-contact-btn" style="width: 100%; margin-bottom: 0;">Подтвердить заказ</button>
</div>
</div>
</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 orgName = `{{ settings.organization_name }}`.replace(/`/g, '');
const enableCart = {{ 'true' if settings.get('enable_cart', False) else 'false' }};
const envId = '{{ env_id }}';
const itemsPerPage = 10;
let currentPage = 1;
let currentCategory = 'all';
let searchQuery = '';
let filterMin = null;
let filterMax = null;
let cart = {};
try {
let saved = localStorage.getItem('cart_' + envId);
if (saved) {
cart = JSON.parse(saved);
if (Array.isArray(cart)) cart = {};
}
} catch(e) { cart = {}; }
document.addEventListener('DOMContentLoaded', () => {
const chipsContainer = document.getElementById('category-chips');
if(chipsContainer && orderedCategories.length > 0) {
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;
}
window.addEventListener('click', function(event) { if (event.target.classList.contains('modal')) { closeModal(event.target.id); } });
updateCartBadge();
renderCatalog();
});
function parseVariant(v, basePrice) {
if (!v) return { name: '', price: parseFloat(basePrice) || 0 };
if (v.includes(':')) {
let parts = v.split(':');
let parsedPrice = parseFloat(parts[1].trim());
return {
name: parts[0].trim(),
price: isNaN(parsedPrice) ? (parseFloat(basePrice) || 0) : parsedPrice
};
}
return { name: v.trim(), price: parseFloat(basePrice) || 0 };
}
function saveCart() {
localStorage.setItem('cart_' + envId, JSON.stringify(cart));
}
function updateCartBadge() {
if(!enableCart) return;
const badge = document.getElementById('cart-badge');
const fab = document.getElementById('cart-fab');
let totalItems = 0;
for(let id in cart) { totalItems += cart[id].qty; }
badge.innerText = totalItems;
if(totalItems > 0) {
fab.style.display = 'flex';
} else {
fab.style.display = 'none';
closeModal('cartModal');
}
}
function addToCart(productId) {
let p = getProductById(productId);
if(!p) return;
let variantRaw = '';
let itemPrice = parseFloat(p.price) || 0;
const vSelect = document.getElementById('variant-selector');
if(vSelect) {
variantRaw = vSelect.value;
let pv = parseVariant(variantRaw, p.price);
itemPrice = pv.price;
}
let cartItemId = variantRaw ? productId + '|||' + variantRaw : productId;
if(!cart[cartItemId]) {
cart[cartItemId] = {
productId: productId,
variant: variantRaw,
qty: 1,
price: itemPrice,
photo: p.photos && p.photos.length > 0 ? p.photos[0] : null
};
} else {
cart[cartItemId].qty += 1;
}
saveCart();
updateCartBadge();
renderCatalog();
renderModalCartControls(productId);
}
function updateQty(cartItemId, delta, productId) {
if(cart[cartItemId]) {
cart[cartItemId].qty += delta;
if(cart[cartItemId].qty <= 0) delete cart[cartItemId];
saveCart();
updateCartBadge();
renderCatalog();
renderCartModal();
if(productId) renderModalCartControls(productId);
}
}
function setQty(cartItemId, inputElem, productId) {
let val = parseInt(inputElem.value);
if(isNaN(val) || val <= 0) {
delete cart[cartItemId];
} else {
cart[cartItemId].qty = val;
}
saveCart();
updateCartBadge();
renderCatalog();
renderCartModal();
if(productId) renderModalCartControls(productId);
}
function removeFromCart(cartItemId, productId) {
delete cart[cartItemId];
saveCart();
updateCartBadge();
renderCatalog();
renderCartModal();
if(productId) renderModalCartControls(productId);
}
function clearCart() {
if(confirm("Вы действительно хотите полностью очистить корзину?")) {
cart = {};
saveCart();
updateCartBadge();
renderCatalog();
renderCartModal();
}
}
function toggleCartZoom(imgElement) {
imgElement.classList.toggle('zoomed');
}
function setCategory(cat, btn) {
document.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
if(btn) btn.classList.add('active');
currentCategory = cat;
currentPage = 1;
renderCatalog();
}
function handleSearch() {
searchQuery = document.getElementById('searchInput').value.toLowerCase().trim();
currentPage = 1;
renderCatalog();
}
function openFilterModal() {
document.getElementById('minPriceInput').value = filterMin !== null ? filterMin : '';
document.getElementById('maxPriceInput').value = filterMax !== null ? filterMax : '';
const modal = document.getElementById('filterModal');
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function applyFilter() {
let minVal = parseFloat(document.getElementById('minPriceInput').value);
let maxVal = parseFloat(document.getElementById('maxPriceInput').value);
filterMin = isNaN(minVal) ? null : minVal;
filterMax = isNaN(maxVal) ? null : maxVal;
const btn = document.getElementById('filterBtnIcon');
if (filterMin !== null || filterMax !== null) {
btn.classList.add('active-filter');
} else {
btn.classList.remove('active-filter');
}
closeModal('filterModal');
currentPage = 1;
renderCatalog();
}
function clearFilter() {
filterMin = null;
filterMax = null;
document.getElementById('minPriceInput').value = '';
document.getElementById('maxPriceInput').value = '';
document.getElementById('filterBtnIcon').classList.remove('active-filter');
closeModal('filterModal');
currentPage = 1;
renderCatalog();
}
function buildProductCard(product) {
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=Нет+фото`;
let displayPrice = product.price;
let prices = [product.price];
if(product.variants && product.variants.length > 0) {
product.variants.forEach(v => {
let pv = parseVariant(v, product.price);
prices.push(pv.price);
});
let minP = Math.min(...prices);
let maxP = Math.max(...prices);
displayPrice = minP;
}
let priceText = displayPrice > 0 ? `<div class="product-price-tag">${parseFloat(displayPrice).toFixed(0)} ${currencyCode}</div>` : '';
if(product.variants && product.variants.length > 0 && Math.min(...prices) !== Math.max(...prices)) {
priceText = `<div class="product-price-tag">От ${parseFloat(Math.min(...prices)).toFixed(0)} ${currencyCode}</div>`;
}
let carouselBadge = product.photos && product.photos.length > 1 ? `<div style="position:absolute; top:12px; right:12px; background:rgba(0,0,0,0.7); color:#fff; padding:6px 10px; border-radius:12px; font-size:0.9rem; font-weight:bold; backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px); box-shadow:0 4px 10px rgba(0,0,0,0.3);"><i class="fas fa-images"></i> ${product.photos.length}</div>` : '';
return `
<div class="product-card" onclick="openModalById('${product.product_id}')">
<div class="product-image-container">
<img src="${photoUrl}" loading="lazy" decoding="async">
${carouselBadge}
${priceText}
</div>
</div>
`;
}
function renderCatalog() {
const container = document.getElementById('catalog-content');
if(!container) return;
let filtered = allProducts.filter(p => {
if (p.archived) return false;
if (currentCategory !== 'all' && p.category !== currentCategory) return false;
if (searchQuery) {
let searchStr = (p.description || "") + " " + (p.category || "") + " " + (p.search_keywords || "");
if (!searchStr.toLowerCase().includes(searchQuery)) return false;
}
let prices = [p.price];
if(p.variants && p.variants.length > 0) {
p.variants.forEach(v => {
prices.push(parseVariant(v, p.price).price);
});
}
let minP = Math.min(...prices);
let maxP = Math.max(...prices);
if (filterMin !== null && maxP < filterMin) return false;
if (filterMax !== null && minP > filterMax) return false;
return true;
});
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 = '<div class="no-results-message"><i class="fas fa-search" style="font-size:2rem; margin-bottom:15px; opacity:0.5;"></i><br>Ничего не найдено.</div>';
return;
}
let html = '<div class="product-grid">';
paginated.forEach(product => {
html += buildProductCard(product);
});
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 style="color:var(--text-light); font-weight:bold;">...</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();
const catalogSec = document.querySelector('.catalog-section');
if(catalogSec) catalogSec.scrollIntoView({behavior: 'smooth', block: 'start'});
}
function getProductById(productId) { return allProducts.find(p => p.product_id === productId); }
function renderModalCartControls(productId) {
const container = document.getElementById('modal-cart-controls-container');
if(!container) return;
const product = getProductById(productId);
if(!product || !enableCart) {
container.innerHTML = '';
return;
}
let variantRaw = '';
let currentPrice = parseFloat(product.price) || 0;
const vSelect = document.getElementById('variant-selector');
if(vSelect) {
variantRaw = vSelect.value;
let pv = parseVariant(variantRaw, product.price);
currentPrice = pv.price;
}
if(currentPrice <= 0) {
container.innerHTML = '';
return;
}
let cartItemId = variantRaw ? productId + '|||' + variantRaw : productId;
if(cart[cartItemId]) {
let qty = cart[cartItemId].qty;
container.innerHTML = `
<div class="cart-controls" style="width: 100%; max-width: 250px; height: 56px; border-radius: 28px; margin:0 auto; background: var(--bg-medium); box-shadow: 0 8px 20px rgba(0,0,0,0.2);">
<button onclick="updateQty('${cartItemId}', -1, '${productId}')" style="font-size: 1.8rem; width: 64px; background:var(--accent); border-radius: 28px 0 0 28px;">-</button>
<input type="number" value="${qty}" onchange="setQty('${cartItemId}', this, '${productId}')" style="flex-grow:1; text-align:center; font-size:1.3rem; border:none; background:transparent; color:white; font-weight:bold;">
<button onclick="updateQty('${cartItemId}', 1, '${productId}')" style="font-size: 1.8rem; width: 64px; background:var(--accent); border-radius: 0 28px 28px 0;">+</button>
</div>
`;
} else {
container.innerHTML = `<button class="add-to-cart-btn" style="width: 100%; max-width: 250px; height: 56px; margin:0 auto; display:block;" onclick="addToCart('${productId}')">В заказ</button>`;
}
}
function updateModalPrice(productId) {
const product = getProductById(productId);
const vSelect = document.getElementById('variant-selector');
let currentPrice = parseFloat(product.price) || 0;
if(vSelect) {
let pv = parseVariant(vSelect.value, product.price);
currentPrice = pv.price;
}
const priceDiv = document.getElementById('modal-price-display');
if(priceDiv) {
if(currentPrice > 0) {
priceDiv.innerHTML = `${currentPrice.toFixed(0)} ${currencyCode}`;
priceDiv.style.display = 'block';
} else {
priceDiv.style.display = 'none';
}
}
renderModalCartControls(productId);
}
function openModalById(productId) {
const product = getProductById(productId);
if (!product) return;
const modalContent = document.getElementById('modalContent');
let descHtml = product.description ? `<p style="margin:15px 0 20px 0; font-size:1.1rem; opacity:0.9; text-align:center; line-height: 1.6;">${product.description.replace(/\\n/g, '<br>')}</p>` : '';
let varsHtml = '';
if(product.variants && product.variants.length > 0) {
let opts = product.variants.map(v => {
let pv = parseVariant(v, product.price);
return `<option value="${v}">${pv.name} - ${pv.price.toFixed(0)} ${currencyCode}</option>`;
}).join('');
varsHtml = `<div style="margin:20px 0; text-align:center;"><select id="variant-selector" class="input-glass" style="max-width:300px; padding:15px; font-weight:600;" onchange="updateModalPrice('${productId}')">${opts}</select></div>`;
}
let initialPrice = product.price;
if(product.variants && product.variants.length > 0) {
initialPrice = parseVariant(product.variants[0], product.price).price;
}
let priceHtml = `<div id="modal-price-display" style="text-align:center; font-size:2.2rem; margin:10px 0; color:var(--accent); font-weight:800; ${initialPrice > 0 ? '' : 'display:none;'}">${parseFloat(initialPrice).toFixed(0)} ${currencyCode}</div>`;
let swiperSlides = '';
let photos = product.photos && product.photos.length > 0 ? product.photos : [];
if(photos.length === 0) {
swiperSlides = `<div class="swiper-slide"><div class="swiper-zoom-container"><img src="https://via.placeholder.com/600x600.png?text=Нет+фото" style="width:100%; height:100%; object-fit:contain;" loading="lazy" decoding="async"></div></div>`;
} else {
swiperSlides = photos.map(p =>
`<div class="swiper-slide">
<div class="swiper-zoom-container">
<img src="https://huggingface.co/datasets/${repoId}/resolve/main/photos/${p}" style="width:100%; height:100%; object-fit:contain;" loading="lazy" decoding="async">
</div>
</div>`
).join('');
}
let imagesHtml = `
<div class="swiper mySwiper" style="width:100%; height:100%; position:absolute; top:0; left:0;">
<div class="swiper-wrapper">${swiperSlides}</div>
${photos.length > 1 ? '<div class="swiper-pagination"></div>' : ''}
</div>
`;
modalContent.innerHTML = `
<div style="width:100%; aspect-ratio:1/1; position:relative; background:rgba(0,0,0,0.3); border-radius: 36px 36px 0 0; overflow:hidden;">
${imagesHtml}
</div>
<div class="modal-body" style="padding: 30px 24px;">
${priceHtml}
${descHtml}
${varsHtml}
<div id="modal-cart-controls-container" style="display:flex; justify-content:center; align-items:center; min-height: 70px; margin-top: 15px;"></div>
</div>
`;
const modal = document.getElementById('productModal');
modal.style.display = "flex";
document.body.style.overflow = 'hidden';
renderModalCartControls(productId);
new Swiper('.mySwiper', {
zoom: true,
pagination: photos.length > 1 ? { el: '.swiper-pagination', clickable: true } : false,
});
}
function openQrModal() {
const currentDomain = window.location.origin;
const targetUrl = currentDomain + "{{ url_for('catalog', env_id=env_id) }}";
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=500x500&data=${encodeURIComponent(targetUrl)}&margin=10`;
document.getElementById('qrImage').src = qrApiUrl;
document.getElementById('qrModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function openPdfModal(url) {
document.getElementById('pdfIframe').src = url;
const modal = document.getElementById('pdfModal');
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function openCartModal() {
renderCartModal();
const modal = document.getElementById('cartModal');
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function renderCartModal() {
const container = document.getElementById('cartItems');
const totalContainer = document.getElementById('cartTotal');
const clearBtn = document.getElementById('clearCartBtn');
const checkoutForm = document.getElementById('checkoutForm');
let html = '';
let total = 0;
for(let cartItemId in cart) {
let item = cart[cartItemId];
total += item.qty * item.price;
let photoUrl = item.photo ? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${item.photo}` : `https://via.placeholder.com/85x85.png?text=Фото`;
let variantName = '';
if(item.variant) {
let pv = parseVariant(item.variant, item.price);
variantName = pv.name;
}
let variantText = variantName ? `<br><small style="opacity:0.8; font-weight:500; color:var(--text-light);">Вариант: ${variantName}</small>` : '';
let productId = item.productId || cartItemId.split('|||')[0];
html += `
<div class="cart-item">
<img src="${photoUrl}" alt="Фото" onclick="toggleCartZoom(this)" loading="lazy" decoding="async">
<div class="cart-item-info">
<div class="cart-item-price">${item.price.toFixed(0)} ${currencyCode} ${variantText}</div>
<div class="cart-controls">
<button onclick="updateQty('${cartItemId}', -1, '${productId}')">-</button>
<input type="number" value="${item.qty}" onchange="setQty('${cartItemId}', this, '${productId}')">
<button onclick="updateQty('${cartItemId}', 1, '${productId}')">+</button>
</div>
</div>
<button onclick="removeFromCart('${cartItemId}', '${productId}')" class="cart-remove-btn"><i class="fas fa-trash"></i></button>
</div>
`;
}
if(html === '') {
html = '<p style="text-align:center; opacity:0.6; font-size:1.2rem; margin-top:40px; font-weight:600;"><i class="fas fa-shopping-basket" style="font-size:3rem; margin-bottom:15px; display:block;"></i>Ваша корзина пуста</p>';
totalContainer.innerHTML = '';
clearBtn.style.display = 'none';
checkoutForm.style.display = 'none';
} else {
totalContainer.innerHTML = `Итого: ${total.toFixed(0)} ${currencyCode}`;
clearBtn.style.display = 'block';
checkoutForm.style.display = 'block';
}
container.innerHTML = html;
}
async function submitOrder() {
if(Object.keys(cart).length === 0) return alert("Корзина пуста");
const cName = document.getElementById('customerName').value.trim();
const cPhone = document.getElementById('customerPhone').value.trim();
if(!cName || !cPhone) return alert("Пожалуйста, заполните имя и телефон");
const cleanCart = {};
for(let key in cart) {
let vName = cart[key].variant ? parseVariant(cart[key].variant, cart[key].price).name : '';
cleanCart[key] = {
...cart[key],
variant: vName
};
}
const payload = { items: cleanCart, customer_name: cName, customer_phone: cPhone };
const btn = document.querySelector('#cartModal .save-contact-btn');
const originalText = btn.innerText;
btn.innerText = "Оформление...";
btn.disabled = true;
try {
const res = await fetch(`/${envId}/checkout`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const data = await res.json();
if(data.success) {
cart = {};
saveCart();
window.location.href = data.url;
} else {
alert("Ошибка оформления заказа");
}
} catch(e) {
alert("Сетевая ошибка");
} finally {
btn.innerText = originalText;
btn.disabled = false;
}
}
function closeModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = "none";
document.body.style.overflow = 'auto';
if (modalId === 'pdfModal') {
document.getElementById('pdfIframe').src = '';
}
}
}
</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@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>
: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; font-size: 1.1rem; }
.container { max-width: 1200px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 24px; box-shadow: 0 10px 40px rgba(0,0,0,0.04); }
.header { padding-bottom: 20px; margin-bottom: 30px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: column; align-items: center; text-align: center; gap: 15px;}
.header .logo-title-container { display: flex; flex-direction: column; align-items: center; gap: 10px; }
.header .logo-title-container img { height: 70px; width: 70px; border-radius: 50%; object-fit: cover; border: 3px solid var(--bg-medium);}
.header-actions { display: flex; flex-direction: column; width: 100%; gap: 10px; margin-top: 10px; }
@media (min-width: 768px) {
.header { flex-direction: row; justify-content: space-between; text-align: left; }
.header .logo-title-container { flex-direction: row; }
.header-actions { flex-direction: row; width: auto; margin-top: 0; }
}
h1, h2, h3 { font-weight: 700; color: var(--bg-medium); margin-bottom: 15px; }
h1 { font-size: 1.8rem; margin: 0; }
h2 { font-size: 1.5rem; margin-top: 20px; display: flex; align-items: center; gap: 10px; }
h3 { font-size: 1.3rem; color: #004D40; margin-top: 20px; }
.section { margin-bottom: 30px; padding: 20px; background-color: #fff; border: 2px solid #f0f0f0; border-radius: 20px; box-shadow: 0 4px 15px rgba(0,0,0,0.02); }
label { font-weight: 700; margin-top: 15px; display: block; color: #222; font-size: 1.1rem;}
input[type="text"], input[type="number"], input[type="password"], input[type="tel"], textarea, select {
width: 100%; padding: 16px; margin-top: 8px; border: 2px solid #ccc; background: #fff;
border-radius: 16px; font-size: 1.15rem; box-sizing: border-box; transition: all 0.3s ease; min-height: 60px;
}
input:focus, textarea:focus, select:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 4px rgba(72, 209, 204, 0.15); }
textarea { min-height: 120px; resize: vertical; }
input[type="file"] { padding: 15px; background-color: #f8f9fa; cursor: pointer; border: 2px dashed #ccc; border-radius: 16px; }
input[type="checkbox"] { margin-right: 12px; vertical-align: middle; width: 28px; height: 28px; cursor: pointer; accent-color: var(--bg-medium);}
button, .button {
padding: 16px 20px; border: none; border-radius: 16px; background-color: var(--accent);
color: var(--text-on-accent); font-weight: 700; cursor: pointer; transition: 0.2s ease;
margin-top: 15px; font-size: 1.2rem; display: inline-flex; align-items: center; justify-content: center;
gap: 10px; text-decoration: none; min-height: 60px; width: 100%;
}
@media (min-width: 768px) { button, .button { width: auto; } }
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; }
.archive-button { background-color: #f39c12; color: white; }
.archive-button:hover { background-color: #e67e22; }
.item-list { display: grid; gap: 20px; }
.item { background: #fff; padding: 25px; border-radius: 20px; box-shadow: 0 4px 15px rgba(0,0,0,0.05); border: 2px solid #f0f0f0; }
.item p { margin: 8px 0; font-size: 1.1rem; color: #444; }
.item strong { color: var(--text-dark); }
.item-actions { margin-top: 20px; display: flex; flex-direction: column; gap: 10px; }
@media (min-width: 768px) { .item-actions { flex-direction: row; flex-wrap: wrap;} }
.edit-form-container { margin-top: 25px; padding: 25px; background: #f8f9fa; border: 2px solid #e9ecef; border-radius: 20px; display: none; }
details { background-color: #fff; border: 2px solid #f0f0f0; border-radius: 20px; margin-bottom: 25px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); }
details > summary { cursor: pointer; font-weight: 700; color: var(--bg-medium); display: block; padding: 25px 20px; list-style: none; position: relative; font-size: 1.3rem; }
details > summary:hover { background-color: #f8f9fa; border-radius: 20px;}
details > summary::after { content: '\\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; right: 24px; 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: 2px solid #f0f0f0; border-radius: 20px 20px 0 0; }
details .form-content { padding: 25px; }
.photo-preview { display: flex; gap: 10px; flex-wrap: wrap; }
.photo-preview img { width: 120px; height: 120px; border-radius: 16px; border: 2px solid #e0e0e0; object-fit: cover;}
.message { padding: 20px; border-radius: 16px; margin-bottom: 25px; font-size: 1.15rem; font-weight: 600;}
.message.success { background-color: #d4edda; color: #155724; border: 2px solid #c3e6cb;}
.message.error { background-color: #f8d7da; color: #721c24; border: 2px solid #f5c6cb;}
.current-avatar { max-width: 80px; max-height: 80px; border-radius: 50%; vertical-align: middle; margin-left: 15px; border: 3px solid var(--bg-medium);}
.block-item { display: flex; flex-direction: column; background: #fff; padding: 25px; border: 2px solid #f0f0f0; border-radius: 20px; margin-bottom: 15px; gap: 15px; box-shadow: 0 4px 15px rgba(0,0,0,0.03);}
@media (min-width: 768px) { .block-item { flex-direction: row; justify-content: space-between; align-items: center; } }
.block-controls { display: flex; flex-direction: column; gap: 10px; width: 100%; }
@media (min-width: 768px) { .block-controls { flex-direction: row; width: auto; } }
.btn-small { padding: 12px; font-size: 1.1rem; min-height: 50px;}
.pagination { display: flex; justify-content: center; gap: 10px; margin-top: 40px; flex-wrap: wrap; align-items: center; }
.pagination .button { min-width: 60px; text-align: center; padding: 15px; margin: 0; border-radius: 16px; width: auto; }
#loadingOverlay {
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.85); backdrop-filter: blur(5px); z-index: 10000; flex-direction: column;
justify-content: center; align-items: center; color: white; text-align: center;
}
.spinner {
width: 70px; height: 70px; border: 6px solid rgba(255,255,255,0.2);
border-top: 6px solid var(--accent); border-radius: 50%;
animation: spin 1s linear infinite; margin-bottom: 25px;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.toggle-btn { display: inline-block; padding: 8px 15px; margin: 5px 0; background: #e0e0e0; color: #333; border-radius: 8px; cursor: pointer; font-size: 1rem; border: none; font-weight: bold; }
.toggle-btn:hover { background: #d0d0d0; }
</style>
</head>
<body>
<div id="loadingOverlay">
<div class="spinner"></div>
<h2 style="color:white; margin:0;">Пожалуйста, подождите...<br>Идёт сохранение данных</h2>
</div>
<div class="container">
<div class="header">
<div class="logo-title-container">
<img src="{{ chat_avatar_url }}" alt="Logo" loading="lazy" decoding="async">
<h1 style="margin:0;"><i class="fas fa-id-badge"></i> Настройки сайта</h1>
</div>
<div class="header-actions">
<a href="{{ url_for('catalog', env_id=env_id) }}" class="button" style="background-color: var(--bg-medium); color: white;"><i class="fas fa-external-link-alt"></i> Открыть сайт</a>
<a href="{{ url_for('admin_orders', env_id=env_id) }}" class="button" style="background-color: {% if new_orders_count > 0 %}#E57373{% else %}var(--bg-medium){% endif %}; color: white; position: relative;">
<i class="fas fa-history"></i> Заказы
{% if new_orders_count > 0 %}
<span style="background-color: white; color: #E57373; border-radius: 50%; padding: 4px 10px; font-size: 1rem; font-weight: bold; margin-left: 8px; border: 2px solid #E57373;">{{ new_orders_count }}</span>
{% endif %}
</a>
<button onclick="downloadQR()" class="button" style="background-color: #8A2BE2; color: white;"><i class="fas fa-qrcode"></i> Сохранить QR</button>
{% 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">
<details>
<summary><i class="fas fa-user-edit"></i> Профиль и Внешний вид</summary>
<div class="form-content">
<form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, tab=request.args.get('tab', 'active')) }}" enctype="multipart/form-data" onsubmit="showLoadingOverlay()">
<input type="hidden" name="action" value="update_settings">
<div style="display:flex; gap:15px; flex-wrap:wrap;">
<div style="flex:1; min-width:200px;">
<label>Имя:</label>
<input type="text" name="vcard_firstname" value="{{ settings.vcard_firstname }}">
</div>
<div style="flex:1; min-width:200px;">
<label>Фамилия:</label>
<input type="text" name="vcard_lastname" value="{{ settings.vcard_lastname }}">
</div>
</div>
<label>Профессия / Должность:</label>
<input type="text" name="vcard_job" value="{{ settings.vcard_job }}">
<label>Название организации:</label>
<input type="text" name="organization_name" value="{{ settings.organization_name }}">
<label>Коротко о себе (описание):</label>
<textarea name="about_text" rows="3">{{ settings.about_text }}</textarea>
<label>Ваша фотография (Аватар):</label>
<input type="file" name="chat_avatar" accept="image/*">
{% if settings.chat_avatar %}
<p style="font-size: 1.1rem; margin-top: 15px; display: flex; align-items: center;">Текущая: <img src="{{ chat_avatar_url }}" class="current-avatar" loading="lazy" decoding="async"></p>
{% endif %}
<label>Дизайн (Цветовая схема):</label>
<select name="color_scheme">
{% for key, name in color_schemes.items() %}
<option value="{{ key }}" {% if settings.color_scheme == key %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
<label>Валюта цен на сайте:</label>
<select name="currency_code">
{% for code, name in currencies.items() %}
<option value="{{ code }}" {% if settings.currency_code == code %}selected{% endif %}>{{ name }} ({{ code }})</option>
{% endfor %}
</select>
<h4 style="margin-top: 40px; font-size:1.3rem; color:var(--bg-medium);"><i class="fas fa-shopping-cart"></i> Прием заказов с сайта</h4>
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; padding: 10px 0;">
<input type="checkbox" name="enable_cart" {% if settings.enable_cart %}checked{% endif %}>
Включить возможность заказа товаров
</label>
<label>Куда получать заказы:</label>
<select name="order_messenger">
<option value="whatsapp" {% if settings.order_messenger == 'whatsapp' %}selected{% endif %}>На WhatsApp</option>
<option value="telegram" {% if settings.order_messenger == 'telegram' %}selected{% endif %}>На Telegram</option>
<option value="none" {% if settings.order_messenger == 'none' %}selected{% endif %}>Не принимать заказы</option>
</select>
<label>Ваш номер (для WhatsApp) или логин (для Telegram):</label>
<input type="text" name="order_contact" value="{{ settings.order_contact }}" placeholder="Например: 996555123456 или my_login">
<div style="background: #f1f3f5; padding: 25px; border-radius: 20px; margin-top: 40px; border:2px solid #e9ecef;">
<h4 style="margin-top: 0; color: var(--bg-medium); font-size: 1.3rem;"><i class="fas fa-lock"></i> Пароль для входа сюда</h4>
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; padding: 10px 0;"><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>
<button type="submit" class="add-button" style="margin-top: 30px;"><i class="fas fa-save"></i> Сохранить настройки</button>
</form>
</div>
</details>
</div>
<div class="section">
<h2><i class="fas fa-link"></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, tab=request.args.get('tab', 'active')) }}" enctype="multipart/form-data" onsubmit="showLoadingOverlay()">
<input type="hidden" name="action" value="add_block">
<label>Тип кнопки:</label>
<select name="block_type" id="add_block_type" onchange="toggleBlockFields('add_')">
<option value="link">Обычная ссылка/номер</option>
<option value="text">Текст (описание)</option>
<option value="pdf">Открыть PDF файл</option>
</select>
<label>Надпись на кнопке (заголовок):</label>
<input type="text" name="block_title" required>
<div id="add_block_icon_div">
<label>Иконка на кнопке:</label>
<select name="block_icon">
<option value="">-- Без иконки --</option>
{% for icon_class, icon_name in icons.items() %}
<option value="{{ icon_class }}">{{ icon_name }}</option>
{% endfor %}
</select>
</div>
<div id="add_block_url_div">
<label>Ссылка или номер телефона (с +):</label>
<input type="text" name="block_url" placeholder="https://... или +996...">
</div>
<div id="add_block_pdf_div" style="display: none;">
<label>Выберите PDF файл с телефона:</label>
<input type="file" name="block_pdf" accept="application/pdf">
</div>
<div id="add_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: 20px;">
{% if blocks %}
{% for block in blocks %}
<div class="block-item">
<div style="flex-grow: 1;">
<strong style="font-size: 1.25rem;">
{% if block.icon %}
{% set prefix = 'fab' if block.icon in ['fa-whatsapp', 'fa-telegram', 'fa-instagram', 'fa-youtube', 'fa-tiktok'] else 'fas' %}
<i class="{{ prefix }} {{ block.icon }}" style="color:var(--accent);"></i>
{% endif %}
{{ block.title }}
</strong>
<span style="color: #666; font-size: 1rem; margin-left: 10px; display:inline-block; margin-top:5px;">
({% if block.type == 'link' %}Ссылка{% elif block.type == 'pdf' %}PDF{% else %}Текст{% endif %})
</span>
{% if block.type == 'link' %}<br><small style="font-size: 1.05rem; color: #444; margin-top: 8px; display: block; word-break:break-all;"><a href="{{ block.url }}" target="_blank" rel="noopener noreferrer">{{ block.url }}</a></small>{% endif %}
</div>
<div class="block-controls">
<button type="button" class="button btn-small" style="background: #1E90FF;" onclick="toggleEditForm('edit-block-{{ block.id }}'); toggleBlockFields('edit_{{ block.id }}_');"><i class="fas fa-edit"></i> Редактировать</button>
<form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, tab=request.args.get('tab', 'active')) }}" style="margin: 0; width:100%;">
<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, tab=request.args.get('tab', 'active')) }}" style="margin: 0; width:100%;">
<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, tab=request.args.get('tab', 'active')) }}" style="margin: 0; width:100%;" onsubmit="if(!confirm('Удалить эту кнопку?')) return false; showLoadingOverlay(); return true;">
<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>
<div id="edit-block-{{ block.id }}" class="edit-form-container" style="width: 100%;">
<form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, tab=request.args.get('tab', 'active')) }}" enctype="multipart/form-data" onsubmit="showLoadingOverlay()">
<input type="hidden" name="action" value="edit_block">
<input type="hidden" name="block_id" value="{{ block.id }}">
<input type="hidden" name="old_block_url" value="{{ block.url }}">
<label>Тип кнопки:</label>
<select name="block_type" id="edit_{{ block.id }}_block_type" onchange="toggleBlockFields('edit_{{ block.id }}_')">
<option value="link" {% if block.type == 'link' %}selected{% endif %}>Обычная ссылка/номер</option>
<option value="text" {% if block.type == 'text' %}selected{% endif %}>Текст (описание)</option>
<option value="pdf" {% if block.type == 'pdf' %}selected{% endif %}>Открыть PDF файл</option>
</select>
<label>Надпись на кнопке (заголовок):</label>
<input type="text" name="block_title" value="{{ block.title }}" required>
<div id="edit_{{ block.id }}_block_icon_div">
<label>Иконка:</label>
<select name="block_icon">
<option value="">-- Без иконки --</option>
{% for icon_class, icon_name in icons.items() %}
<option value="{{ icon_class }}" {% if block.icon == icon_class %}selected{% endif %}>{{ icon_name }}</option>
{% endfor %}
</select>
</div>
<div id="edit_{{ block.id }}_block_url_div">
<label>Ссылка или номер телефона:</label>
<input type="text" name="block_url" value="{{ block.url if block.type == 'link' else '' }}">
</div>
<div id="edit_{{ block.id }}_block_pdf_div" style="display: none;">
<label>Текущий PDF: {% if block.type == 'pdf' and block.url %}{{ block.url }}{% else %}Нет{% endif %}</label>
<label>Загрузить новый PDF (старый удалится):</label>
<input type="file" name="block_pdf" accept="application/pdf">
</div>
<div id="edit_{{ block.id }}_block_content_div" style="display: none;">
<label>Текст сообщения:</label>
<textarea name="block_content" rows="3">{{ block.content }}</textarea>
</div>
<button type="submit" class="add-button"><i class="fas fa-save"></i> Сохранить изменения</button>
</form>
</div>
{% endfor %}
{% else %}
<p style="font-size: 1.15rem; color:#888;">Кнопки еще не добавлены.</p>
{% endif %}
</div>
<script>
function toggleBlockFields(prefix = 'add_') {
const typeSelect = document.getElementById(prefix + 'block_type');
if (!typeSelect) return;
const type = typeSelect.value;
const urlDiv = document.getElementById(prefix + 'block_url_div');
const iconDiv = document.getElementById(prefix + 'block_icon_div');
const pdfDiv = document.getElementById(prefix + 'block_pdf_div');
const contentDiv = document.getElementById(prefix + 'block_content_div');
if (urlDiv) urlDiv.style.display = type === 'link' ? 'block' : 'none';
if (iconDiv) iconDiv.style.display = (type === 'link' || type === 'pdf') ? 'block' : 'none';
if (pdfDiv) pdfDiv.style.display = type === 'pdf' ? 'block' : 'none';
if (contentDiv) contentDiv.style.display = type === 'text' ? 'block' : 'none';
}
document.addEventListener("DOMContentLoaded", function() {
toggleBlockFields('add_');
let lastCat = localStorage.getItem('last_bulk_category_{{ env_id }}');
let catSelect = document.getElementById('bulk_category');
if(lastCat && catSelect) {
catSelect.value = lastCat;
}
});
</script>
</div>
<div class="section">
<h2><i class="fas fa-folder-open"></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, tab=request.args.get('tab', 'active')) }}">
<input type="hidden" name="action" value="add_category">
<label>Название новой папки:</label>
<input type="text" name="category_name" required>
<button type="submit" class="add-button"><i class="fas fa-plus"></i> Создать папку</button>
</form>
</div>
</details>
{% if categories %}
<div class="item-list" style="margin-top:25px;">
{% for category in categories %}
<div class="item" style="display: flex; justify-content: space-between; align-items: center; flex-wrap:wrap; gap:15px;">
<span style="font-size: 1.25rem; font-weight: 700; color: var(--bg-medium);">{{ category }}</span>
<form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, tab=request.args.get('tab', 'active')) }}" style="margin: 0; width:100%;" 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.15rem; margin-top:20px; color:#888;">Папок пока нет.</p>
{% endif %}
</div>
<div class="section">
<h2><i class="fas fa-camera"></i> Все фотографии и товары</h2>
<details open>
<summary><i class="fas fa-upload"></i> Загрузить много фото на сайт</summary>
<div class="form-content">
<label>В какую папку добавить фото:</label>
<select id="bulk_category">
<option value="Без категории">Без папки</option>
{% for category in categories %}
<option value="{{ category }}">{{ category }}</option>
{% endfor %}
</select>
<label style="margin-top: 20px;">Выберите фото с телефона (можно выбрать сразу несколько):</label>
<input type="file" id="bulk_photos" accept="image/*" multiple onchange="handleBulkSelect(event)">
<div id="bulk_preview" style="display: flex; flex-wrap: wrap; gap: 15px; margin-top: 25px;"></div>
<div id="progress_container" style="display:none; margin-top:30px;">
<div style="width:100%; background:#eee; border-radius:16px; overflow:hidden;">
<div id="progress_bar" style="width:0%; height:30px; background:var(--accent); transition:width 0.2s;"></div>
</div>
<p id="progress_text" style="text-align:center; font-weight:bold; margin-top:10px; font-size:1.2rem;">0%</p>
</div>
<button type="button" class="add-button" style="margin-top: 30px; width:100%; font-size:1.2rem; min-height:65px;" onclick="uploadBulk()"><i class="fas fa-cloud-upload-alt"></i> Опубликовать фото на сайте</button>
</div>
</details>
<div style="margin-top: 45px; margin-bottom: 25px; display:flex; gap:10px;">
<a href="{{ url_for('admin', env_id=env_id, p=1, tab='active') }}" class="button" style="padding:10px 20px; {% if not show_archive %}background:var(--bg-medium);{% else %}background:#e0e0e0; color:#333;{% endif %} margin:0;">Активные</a>
<a href="{{ url_for('admin', env_id=env_id, p=1, tab='archive') }}" class="button" style="padding:10px 20px; {% if show_archive %}background:var(--bg-medium);{% else %}background:#e0e0e0; color:#333;{% endif %} margin:0;">Архив</a>
</div>
{% if paginated_products %}
<div class="item-list" id="admin-products-list">
{% for product in paginated_products %}
<div class="item">
<div style="display: flex; gap: 20px; align-items: flex-start; flex-direction:column;">
<div style="display: flex; align-items: center; gap: 15px;">
<input type="checkbox" class="bulk-action-checkbox" value="{{ product.product_id }}" onchange="updateBulkFab()">
<span style="font-weight:700; color:#555;">Отметить</span>
</div>
<div class="photo-preview" style="display:flex; flex-wrap:wrap; width: 100%;">
{% if product.get('photos') %}
{% for p in product['photos'] %}
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ p }}" alt="Фото" loading="lazy" decoding="async">
{% endfor %}
{% else %}
<img src="https://via.placeholder.com/120x120.png?text=Нет+фото" alt="Нет фото" loading="lazy" decoding="async">
{% endif %}
</div>
<div style="flex-grow: 1; width: 100%;">
<p style="font-size: 1.15rem;"><strong>Папка:</strong> {{ product.get('category', 'Без папки') if product.get('category') != 'Без категории' else 'Без папки' }}</p>
<p style="font-size: 1.25rem; color: var(--bg-medium);"><strong>Цена:</strong> {% if product.get('price', 0) > 0 %}{{ "%.2f"|format(product.get('price', 0)) }} {{ currency_code }}{% else %}0{% endif %}</p>
{% if product.get('description') %}
<p style="font-size: 1rem; color: #555;"><strong>Описание:</strong> {{ product.get('description') }}</p>
{% endif %}
{% if product.get('variants') %}
<p style="font-size: 1rem; color: #555;"><strong>Варианты:</strong> {{ ', '.join(product.get('variants')) }}</p>
{% endif %}
{% if product.get('search_keywords') %}
<p style="font-size: 1rem; color: #555;"><strong>Ключевые слова:</strong> {{ product.get('search_keywords') }}</p>
{% endif %}
</div>
</div>
<div class="item-actions">
<button type="button" class="button" onclick="toggleEditForm('edit-form-{{ product.product_id }}')"><i class="fas fa-edit"></i> Изменить</button>
{% if show_archive %}
<form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, tab='archive') }}" style="margin:0; width:100%;" onsubmit="showLoadingOverlay(); return true;">
<input type="hidden" name="action" value="unarchive_product">
<input type="hidden" name="product_id" value="{{ product.get('product_id', '') }}">
<button type="submit" class="button archive-button" style="background:#2ecc71;"><i class="fas fa-box-open"></i> Восстановить</button>
</form>
{% else %}
<form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, tab='active') }}" style="margin:0; width:100%;" onsubmit="if(!confirm('Отправить товар в архив? Он скроется из каталога.')) return false; showLoadingOverlay(); return true;">
<input type="hidden" name="action" value="archive_product">
<input type="hidden" name="product_id" value="{{ product.get('product_id', '') }}">
<button type="submit" class="button archive-button"><i class="fas fa-archive"></i> В архив</button>
</form>
{% endif %}
<form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, tab=request.args.get('tab', 'active')) }}" style="margin:0; width:100%;" 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 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.25rem;"><i class="fas fa-edit"></i> Изменение товара</h4>
<form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, tab=request.args.get('tab', 'active')) }}" 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="number" name="price" step="0.01" value="{{ product.get('price', 0) }}">
<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>
<label>Описание (необязательно):</label>
<textarea name="description" rows="2">{{ product.get('description', '') }}</textarea>
<label>Слова для поиска (через пробел/запятую):</label>
<input type="text" name="search_keywords" value="{{ product.get('search_keywords', '') }}" placeholder="Например: скидка, новинка, лето">
<label>Варианты (Формат Название:Цена. Например: Красный:1500, Синий, 42:1600):</label>
<input type="text" name="variants" value="{{ ', '.join(product.get('variants', [])) }}">
<label>Загрузить другие фото вместо старых:</label>
<input type="file" name="photos" accept="image/*" multiple>
<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, tab=request.args.get('tab', 'active')) }}" class="button" style="padding:15px; min-width:auto;"><i class="fas fa-chevron-left"></i> Назад</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, tab=request.args.get('tab', 'active')) }}" class="button {% if p_num == page %}active{% endif %}" style="padding:15px; min-width:50px; {% 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: 15px; color: var(--bg-medium); font-weight: bold; font-size:1.2rem;">...</span>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="{{ url_for('admin', env_id=env_id, p=page+1, tab=request.args.get('tab', 'active')) }}" class="button" style="padding:15px; min-width:auto;">Вперед <i class="fas fa-chevron-right"></i></a>
{% endif %}
</div>
{% endif %}
{% else %}
<p style="font-size: 1.25rem; text-align: center; padding: 50px; color:#888;">{% if show_archive %}Архив пуст.{% else %}Фотографии пока не добавлены.{% endif %}</p>
{% endif %}
</div>
</div>
<form id="bulk-action-form" method="POST" action="{{ url_for('admin', env_id=env_id, p=page, tab=request.args.get('tab', 'active')) }}" style="display:none;" onsubmit="showLoadingOverlay()">
<input type="hidden" name="action" id="bulk-action-input" value="bulk_delete">
<div id="bulk-action-inputs"></div>
</form>
<div id="bulk-fab-container" style="display:none; position:fixed; bottom:24px; left:24px; z-index:999; display:flex; flex-direction:column; gap:10px;">
{% if show_archive %}
<button class="button archive-button" style="border-radius:24px; box-shadow:0 6px 20px rgba(0,0,0,0.4); font-size:1.1rem; padding:15px 25px; background:#2ecc71; display:none;" type="button" id="bulk-unarchive-btn" onclick="submitBulkAction('bulk_unarchive')">
<i class="fas fa-box-open"></i> Восстановить (<span class="bulk-count">0</span>)
</button>
{% else %}
<button class="button archive-button" style="border-radius:24px; box-shadow:0 6px 20px rgba(0,0,0,0.4); font-size:1.1rem; padding:15px 25px; display:none;" type="button" id="bulk-archive-btn" onclick="submitBulkAction('bulk_archive')">
<i class="fas fa-archive"></i> В архив (<span class="bulk-count">0</span>)
</button>
{% endif %}
<button class="button delete-button" style="border-radius:24px; box-shadow:0 6px 20px rgba(0,0,0,0.4); font-size:1.1rem; padding:15px 25px; display:none;" type="button" id="bulk-delete-btn" onclick="submitBulkAction('bulk_delete')">
<i class="fas fa-trash-alt"></i> Удалить (<span class="bulk-count">0</span>)
</button>
</div>
<button id="carousel-fab" class="button" style="display:none; position:fixed; bottom:24px; right:24px; z-index:999; border-radius:24px; box-shadow:0 6px 20px rgba(0,0,0,0.4); font-size:1.1rem; padding:15px 25px; background-color: var(--bg-medium);" type="button" onclick="mergeCarousel()">
<i class="fas fa-object-group"></i> Сгруппировать (<span id="carousel-count">0</span>)
</button>
<script>
let bulkFiles = [];
let bulkCounter = 0;
function toggleField(id) {
let el = document.getElementById(id);
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
async function handleBulkSelect(event) {
const files = event.target.files;
for(let i = 0; i < files.length; i++) {
let id = 'img_' + (bulkCounter++);
const url = URL.createObjectURL(files[i]);
bulkFiles.push({ id: id, files: [files[i]], previewUrl: url });
}
event.target.value = '';
renderBulkPreview();
updateCarouselFab();
}
function renderBulkPreview() {
const container = document.getElementById('bulk_preview');
container.innerHTML = '';
bulkFiles.forEach(item => {
const div = document.createElement('div');
div.id = 'preview_div_' + item.id;
div.style.cssText = 'border:2px solid #e0e0e0; border-radius:20px; padding:15px; text-align:center; position:relative; width:160px; background:#fff; box-shadow:0 4px 15px rgba(0,0,0,0.05); display:flex; flex-direction:column; gap:5px;';
let badge = item.files.length > 1 ? `<div style="position:absolute;top:-10px;right:-10px;background:var(--accent);color:var(--text-dark);border-radius:12px;padding:6px 12px;font-size:1rem;font-weight:bold;box-shadow:0 2px 8px rgba(0,0,0,0.3);"><i class="fas fa-images"></i> ${item.files.length}</div>` : '';
div.innerHTML = `
<input type="checkbox" class="carousel-group-checkbox" value="${item.id}" style="position:absolute; top:12px; left:12px; z-index:10; width:28px; height:28px; cursor:pointer;" onchange="updateCarouselFab()">
${badge}
<img src="${item.previewUrl}" style="width:100%; height:130px; object-fit:cover; border-radius:12px; margin-bottom:5px;" loading="lazy" decoding="async">
<input type="number" id="price_${item.id}" placeholder="Цена" value="0" style="width:100%; padding:10px; box-sizing:border-box; font-size:1rem; border:2px solid transparent; border-radius:12px; background:#f1f3f5; outline:none; transition:0.3s;" onfocus="this.style.background='#fff';this.style.borderColor='var(--accent)';" onblur="this.style.background='#f1f3f5';this.style.borderColor='transparent';">
<div style="display:flex; justify-content:space-between; margin-top:5px; flex-wrap: wrap; gap: 5px;">
<button type="button" class="toggle-btn" onclick="toggleField('desc_${item.id}')" style="font-size:0.8rem; padding:4px; flex-grow:1;" title="Добавить описание">+ Опис.</button>
<button type="button" class="toggle-btn" onclick="toggleField('vars_${item.id}')" style="font-size:0.8rem; padding:4px; flex-grow:1;" title="Добавить варианты">+ Вар.</button>
<button type="button" class="toggle-btn" onclick="toggleField('words_${item.id}')" style="font-size:0.8rem; padding:4px; flex-grow:1;" title="Слова для поиска">+ Слова</button>
</div>
<textarea id="desc_${item.id}" placeholder="Описание..." style="display:none; width:100%; padding:8px; border-radius:10px; border:1px solid #ccc; font-size:0.9rem; resize:vertical; min-height:50px;"></textarea>
<input type="text" id="vars_${item.id}" placeholder="Вар (Цвет:100, Размер)" style="display:none; width:100%; padding:8px; border-radius:10px; border:1px solid #ccc; font-size:0.9rem;">
<input type="text" id="words_${item.id}" placeholder="Ключевые слова" style="display:none; width:100%; padding:8px; border-radius:10px; border:1px solid #ccc; font-size:0.9rem;">
<button type="button" class="button delete-button" style="width:100%; padding:10px; margin-top:10px; border:none; border-radius:12px; cursor:pointer; font-size:1rem; min-height:auto;" onclick="removeBulk('${item.id}')"><i class="fas fa-times"></i> Убрать</button>
`;
container.appendChild(div);
});
}
function removeBulk(id) {
bulkFiles = bulkFiles.filter(item => item.id !== id);
renderBulkPreview();
updateCarouselFab();
}
function updateCarouselFab() {
const checked = document.querySelectorAll('.carousel-group-checkbox:checked');
const fab = document.getElementById('carousel-fab');
if(checked.length > 1) {
fab.style.display = 'flex';
document.getElementById('carousel-count').innerText = checked.length;
} else {
fab.style.display = 'none';
}
}
function mergeCarousel() {
const checked = Array.from(document.querySelectorAll('.carousel-group-checkbox:checked')).map(cb => cb.value);
if (checked.length < 2) return;
const targetId = checked[0];
const targetItem = bulkFiles.find(b => b.id === targetId);
for (let i = 1; i < checked.length; i++) {
const sourceItem = bulkFiles.find(b => b.id === checked[i]);
if (sourceItem) {
targetItem.files.push(...sourceItem.files);
bulkFiles = bulkFiles.filter(b => b.id !== checked[i]);
}
}
renderBulkPreview();
updateCarouselFab();
}
function compressImage(file, maxWidth=1000, quality=0.75) {
if(file.type === 'image/gif') return Promise.resolve(file);
return new Promise((resolve) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target.result;
img.onload = () => {
let width = img.width;
let height = img.height;
if (width > maxWidth) {
height = Math.round((height * maxWidth) / width);
width = maxWidth;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
resolve(new File([blob], file.name, { type: 'image/jpeg' }));
}, 'image/jpeg', quality);
};
img.onerror = () => resolve(file);
};
reader.onerror = () => resolve(file);
});
}
async function uploadBulk() {
if(bulkFiles.length === 0) return alert('Пожалуйста, сначала выберите фото!');
const category = document.getElementById('bulk_category').value;
localStorage.setItem('last_bulk_category_{{ env_id }}', category);
const formData = new FormData();
formData.append('action', 'bulk_add');
formData.append('category', category);
formData.append('product_count', bulkFiles.length);
document.getElementById('progress_container').style.display = 'block';
const btn = document.querySelector('button[onclick="uploadBulk()"]');
btn.disabled = true;
btn.innerText = "Подготовка файлов, подождите...";
for(let i=0; i<bulkFiles.length; i++) {
let item = bulkFiles[i];
let price = document.getElementById('price_'+item.id).value || 0;
let desc = document.getElementById('desc_'+item.id).value || '';
let vars = document.getElementById('vars_'+item.id).value || '';
let words = document.getElementById('words_'+item.id).value || '';
formData.append('price_' + i, price);
formData.append('description_' + i, desc);
formData.append('variants_' + i, vars);
formData.append('search_keywords_' + i, words);
for(let j=0; j<item.files.length; j++) {
let compressed = await compressImage(item.files[j]);
formData.append('photos_' + i, compressed, item.files[j].name);
}
}
btn.innerText = "Идет загрузка...";
const xhr = new XMLHttpRequest();
xhr.open('POST', '{{ url_for("admin", env_id=env_id, p=1, tab=request.args.get("tab", "active")) }}', true);
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
let p = (e.loaded / e.total) * 100;
document.getElementById('progress_bar').style.width = p + '%';
document.getElementById('progress_text').innerText = Math.round(p) + '%';
}
};
xhr.onload = function() {
if (xhr.status === 200) {
window.location.reload();
} else {
alert('Произошла ошибка при загрузке.');
document.getElementById('progress_container').style.display = 'none';
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-cloud-upload-alt"></i> Опубликовать фото на сайте';
}
};
xhr.onerror = function() {
alert('Сетевая ошибка. Проверьте интернет.');
document.getElementById('progress_container').style.display = 'none';
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-cloud-upload-alt"></i> Опубликовать фото на сайте';
};
xhr.send(formData);
}
function updateBulkFab() {
const checked = document.querySelectorAll('.bulk-action-checkbox:checked');
const container = document.getElementById('bulk-fab-container');
const btnArchive = document.getElementById('bulk-archive-btn');
const btnUnarchive = document.getElementById('bulk-unarchive-btn');
const btnDelete = document.getElementById('bulk-delete-btn');
if (checked.length > 0) {
document.querySelectorAll('.bulk-count').forEach(el => el.innerText = checked.length);
container.style.display = 'flex';
if (btnArchive) btnArchive.style.display = 'flex';
if (btnUnarchive) btnUnarchive.style.display = 'flex';
if (btnDelete) btnDelete.style.display = 'flex';
} else {
container.style.display = 'none';
if (btnArchive) btnArchive.style.display = 'none';
if (btnUnarchive) btnUnarchive.style.display = 'none';
if (btnDelete) btnDelete.style.display = 'none';
}
}
function submitBulkAction(actionName) {
let msg = '';
if(actionName === 'bulk_delete') msg = 'Вы точно уверены, что хотите удалить эти товары/фото навсегда?';
else if(actionName === 'bulk_archive') msg = 'Переместить выбранные товары в архив?';
else if(actionName === 'bulk_unarchive') msg = 'Восстановить выбранные товары из архива?';
if(confirm(msg)) {
document.getElementById('bulk-action-input').value = actionName;
const checked = document.querySelectorAll('.bulk-action-checkbox:checked');
const container = document.getElementById('bulk-action-inputs');
container.innerHTML = '';
checked.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'product_ids[]';
input.value = cb.value;
container.appendChild(input);
});
document.getElementById('bulk-action-form').submit();
}
}
function showLoadingOverlay() {
document.getElementById('loadingOverlay').style.display = 'flex';
}
function toggleEditForm(formId) {
const formContainer = document.getElementById(formId);
if (formContainer) {
formContainer.style.display = formContainer.style.display === 'none' || formContainer.style.display === '' ? 'block' : 'none';
}
}
function downloadQR() {
const currentDomain = window.location.origin;
const targetUrl = currentDomain + "{{ url_for('catalog', env_id=env_id) }}";
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=500x500&data=${encodeURIComponent(targetUrl)}`;
fetch(qrApiUrl)
.then(response => response.blob())
.then(blob => {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'QR_code_{{ env_id }}.png';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
})
.catch(err => alert('Произошла ошибка при сохранении QR-кода'));
}
</script>
</body>
</html>
'''
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'] }}</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;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">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.3/viewer.min.css">
<style>
body { font-family: 'Montserrat', sans-serif; background: #f4f6f9; margin: 0; padding: 20px; color: #333; }
.invoice-box { max-width: 600px; margin: 0 auto; background: #fff; padding: 35px; border-radius: 24px; box-shadow: 0 10px 40px rgba(0,0,0,0.05); }
.header { text-align: center; border-bottom: 2px solid #f0f0f0; padding-bottom: 25px; margin-bottom: 25px; }
.header h1 { margin: 0; color: #135D66; font-size: 1.9rem; }
.info { margin-bottom: 25px; font-size: 1rem; line-height: 1.7; }
.info strong { color: #135D66; }
.item { display: flex; align-items: center; border-bottom: 1px solid #f0f0f0; padding: 15px 0; gap: 15px; }
.item img { width: 85px; height: 85px; object-fit: cover; border-radius: 14px; border: 1px solid #f0f0f0; cursor: pointer; transition: transform 0.2s;}
.item img:hover { transform: scale(1.05); }
.item-details { flex-grow: 1; }
.item-meta { font-size: 1.05rem; color: #333; font-weight: bold; }
.item-variant { font-size: 0.95rem; color: #666; margin-top: 4px; }
.item-sum { font-weight: 700; font-size: 1.25rem; color: #135D66; text-align: right; }
.total { text-align: right; margin-top: 25px; font-size: 1.5rem; font-weight: 700; color: #135D66; }
.btn { display: flex; justify-content: center; align-items: center; gap: 10px; width: 100%; padding: 18px; margin-top: 35px; background: #25D366; color: white; text-decoration: none; border-radius: 16px; font-weight: 700; font-size: 1.15rem; transition: background 0.3s, transform 0.2s; box-sizing: border-box; border: none; cursor: pointer;}
.btn.telegram { background: #0088cc; }
.btn:hover { opacity: 0.9; transform: translateY(-2px); }
.back-btn { background: #6c757d; margin-top: 15px; }
</style>
</head>
<body>
<div class="invoice-box">
<div class="header">
<h1>Накладная заказа</h1>
<p style="margin-top: 5px; color: #888;">#{{ order['id'] }} от {{ order['date'] }}</p>
</div>
<div class="info">
<p><strong>Покупатель:</strong> {{ order['customer_name'] }}</p>
<p><strong>Телефон:</strong> {{ order['customer_phone'] }}</p>
<p><strong>Продавец:</strong> {{ settings['organization_name'] }}</p>
</div>
<div class="items">
{% for cid, item in order['items'].items() %}
<div class="item">
{% if item['photo'] %}
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ item['photo'] }}" alt="Фото" loading="lazy" decoding="async">
{% else %}
<img src="https://via.placeholder.com/85x85.png?text=Фото" alt="Нет фото" loading="lazy" decoding="async">
{% endif %}
<div class="item-details">
<div class="item-meta">{{ item['qty'] }} шт. × {{ "%.2f"|format(item['price']|float) }} {{ currency }}</div>
{% if item.get('variant') %}
<div class="item-variant">Вариант: {{ item['variant'] }}</div>
{% endif %}
</div>
<div class="item-sum">
{{ "%.2f"|format(item['qty']|int * item['price']|float) }} {{ currency }}
</div>
</div>
{% endfor %}
</div>
<div class="total">
Итого: {{ "%.2f"|format(order['total']|float) }} {{ currency }}
</div>
{% if send_link != "#" %}
<a href="{{ send_link }}" target="_blank" class="btn {{ 'telegram' if messenger_name == 'Telegram' else '' }}">
<i class="fab fa-{{ 'telegram-plane' if messenger_name == 'Telegram' else 'whatsapp' }}"></i> Отправить продавцу ({{ messenger_name }})
</a>
{% endif %}
<a href="{{ url_for('catalog', env_id=env_id) }}" class="btn back-btn">Вернуться в каталог</a>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.3/viewer.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const items = document.querySelector('.items');
if (items) {
new Viewer(items, {
navbar: false,
toolbar: false,
title: false,
tooltip: false
});
}
});
</script>
</body>
</html>
'''
ADMIN_ORDERS_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;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>
: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; font-size: 1.1rem; }
.container { max-width: 900px; margin: 0 auto; background-color: #fff; padding: 30px; border-radius: 24px; box-shadow: 0 10px 40px rgba(0,0,0,0.04); }
.header { padding-bottom: 20px; margin-bottom: 25px; border-bottom: 2px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
h1, h2 { font-weight: 700; color: var(--bg-medium); margin-bottom: 15px; }
h1 { font-size: 1.8rem; margin: 0; }
.button { padding: 16px 20px; border: none; border-radius: 16px; background-color: var(--accent); color: var(--text-on-accent); font-weight: 700; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; text-decoration: none; display: inline-flex; align-items: center; gap: 10px; justify-content: center; min-height: 56px; font-size: 1.15rem; }
.button:hover { background-color: var(--accent-hover); }
.button:active { transform: scale(0.98); }
.back-button { background-color: #6c757d; color: white; width:100%;}
@media (min-width: 768px) { .back-button { width:auto; } }
.back-button:hover { background-color: #5a6268; }
.delete-button { background-color: var(--danger); color: white; padding: 12px 16px; font-size: 1.1rem; min-height: 50px; }
.delete-button:hover { background-color: var(--danger-hover); }
.order-item { background: #fff; padding: 25px; border: 2px solid #f0f0f0; border-radius: 20px; margin-bottom: 25px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); transition: transform 0.2s; }
.order-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; border-bottom: 2px solid #f0f0f0; padding-bottom: 15px; margin-bottom: 15px; }
.order-id { font-weight: 700; color: var(--bg-medium); font-size: 1.25rem; }
.order-date { color: #888; font-size: 1.05rem; margin-left: 10px;}
.customer-info p { margin: 8px 0; font-size: 1.1rem; }
.customer-info strong { color: var(--bg-medium); }
.order-details { margin-top: 25px; background: #f8f9fa; border: 2px solid #f0f0f0; border-radius: 16px; padding: 20px; }
.product-row { display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px dashed #e0e0e0; font-size: 1.1rem; gap:15px; }
.product-row:last-child { border-bottom: none; }
.order-total { text-align: right; font-weight: 700; color: var(--bg-medium); font-size: 1.4rem; margin-top: 20px; }
.empty-state { text-align: center; padding: 60px 20px; color: #888; }
.empty-state i { font-size: 4rem; margin-bottom: 20px; color: #ddd; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fas fa-history"></i> История заказов</h1>
<a href="{{ url_for('admin', env_id=env_id) }}" class="button back-button"><i class="fas fa-arrow-left"></i> Вернуться к настройкам</a>
</div>
{% if orders %}
{% for order in orders %}
<div class="order-item">
<div class="order-header">
<div>
<span class="order-id">Заказ #{{ order['id'] }}</span>
<span class="order-date">{{ order['date'] }}</span>
</div>
<div style="display: flex; gap: 10px; align-items: center; width:100%; justify-content:space-between; margin-top:10px;">
<a href="{{ url_for('order_invoice', env_id=env_id, order_id=order['id']) }}" target="_blank" class="button" style="padding: 12px 20px; font-size: 1.1rem; min-height: 50px;"><i class="fas fa-file-invoice"></i> Накладная</a>
<form method="POST" action="{{ url_for('delete_order', env_id=env_id, order_id=order['id']) }}" style="margin: 0;" onsubmit="return confirm('Вы уверены, что хотите удалить этот заказ из истории?');">
<button type="submit" class="button delete-button"><i class="fas fa-trash-alt"></i></button>
</form>
</div>
</div>
<div class="customer-info">
<p><strong>Покупатель:</strong> {{ order['customer_name'] }}</p>
<p><strong>Телефон:</strong> <a href="tel:{{ order['customer_phone'] }}" style="color:var(--accent); font-weight:700; text-decoration:none;">{{ order['customer_phone'] }}</a></p>
</div>
<div class="order-details">
{% for cid, item in order['items'].items() %}
<div class="product-row">
{% if item['photo'] %}
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ item['photo'] }}" style="width:75px; height:75px; object-fit:cover; border-radius:12px;" loading="lazy" decoding="async">
{% else %}
<div style="width:75px; height:75px; background:#e0e0e0; display:flex; align-items:center; justify-content:center; border-radius:12px; font-size:1rem; color:#888;">Фото</div>
{% endif %}
<span style="flex-grow:1; font-weight:700;">
{{ item['qty'] }} шт.
{% if item.get('variant') %}<br><small style="color:#666; font-weight:normal; font-size:0.95rem;">Вариант: {{ item['variant'] }}</small>{% endif %}
</span>
<span style="font-weight:700;">{{ "%.2f"|format(item['price']|float * item['qty']|int) }} {{ currency }}</span>
</div>
{% endfor %}
<div class="order-total">
Итого: {{ "%.2f"|format(order['total']|float) }} {{ currency }}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<i class="fas fa-shopping-cart"></i>
<p style="font-size: 1.25rem;">История заказов пуста.</p>
</div>
{% endif %}
</div>
</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", f"Визитка {env_id}")
environments_data.append({
"id": env_id,
"org_name": org_name,
"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()
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':[], 'blocks':[], 'orders': [],
'settings': {
"vcard_firstname": "Имя",
"vcard_lastname": "Фамилия",
"vcard_job": "Специалист",
"organization_name": "Моя Компания",
"currency_code": "KGS",
"chat_avatar": None,
"color_scheme": "default",
"admin_password_enabled": False,
"admin_password": "",
"categories_as_lines": False,
"about_text": "Привет! Это моя онлайн-визитка.",
"enable_cart": False,
"order_messenger": "whatsapp",
"order_contact": ""
}
}
save_data(all_data)
flash(f'Новая визитка с ID {new_id} успешно создана.', 'success')
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))
@app.route('/<env_id>/vcard')
def download_vcard(env_id):
data = get_env_data(env_id)
settings = data.get('settings', {})
blocks = data.get('blocks', [])
first_name = settings.get("vcard_firstname", "")
last_name = settings.get("vcard_lastname", "")
org = settings.get("organization_name", "")
title = settings.get("vcard_job", "")
note = settings.get("about_text", "").replace('\n', '\\n')
card_url = url_for('catalog', env_id=env_id, _external=True)
vcard = [
"BEGIN:VCARD",
"VERSION:3.0",
f"N:{last_name};{first_name};;;",
f"FN:{first_name} {last_name}".strip(),
]
if org: vcard.append(f"ORG:{org}")
if title: vcard.append(f"TITLE:{title}")
if note: vcard.append(f"NOTE:{note}")
vcard.append(f"URL;type=pref:{card_url}")
for b in blocks:
if b.get('type') == 'link' and b.get('url'):
url = b.get('url', '').strip()
if url.startswith('+') or url.replace('-', '').replace(' ', '').isdigit():
vcard.append(f"TEL;TYPE=CELL:{url}")
elif '@' in url and not url.startswith('http'):
vcard.append(f"EMAIL;TYPE=WORK:{url.replace('mailto:', '')}")
else:
vcard.append(f"URL:{url}")
vcard.append("END:VCARD")
vcard_str = "\r\n".join(vcard)
response = make_response(vcard_str)
response.headers["Content-Disposition"] = f"attachment; filename=contact_{env_id}.vcf"
response.headers["Content-Type"] = "text/vcard; charset=utf-8"
return response
@app.route('/<env_id>/catalog')
def catalog(env_id):
data = get_env_data(env_id)
all_products = data.get('products',[])
settings = data.get('settings', {})
blocks = data.get('blocks',[])
active_products = [p for p in all_products if not p.get('archived', False)]
product_categories = set(p.get('category', 'Без категории') for p in active_products)
admin_categories = set(data.get('categories',[]))
all_cat_names = sorted(list(product_categories.union(admin_categories)))
products_sorted_for_js = list(reversed(active_products))
products_by_category = {cat:[] for cat in all_cat_names}
for product in active_products:
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>/checkout', methods=['POST'])
def checkout(env_id):
req_data = request.get_json()
env_data = get_env_data(env_id)
items = req_data.get('items', {})
if not items:
return jsonify({"success": False, "error": "Корзина пуста"})
total = sum(item.get('qty', 0) * item.get('price', 0) for item in items.values())
order = {
"id": ''.join(random.choices(string.digits, k=8)),
"date": datetime.now(ALMATY_TZ).strftime('%Y-%m-%d %H:%M:%S'),
"customer_name": req_data.get('customer_name', ''),
"customer_phone": req_data.get('customer_phone', ''),
"items": items,
"total": total,
"viewed": False
}
if 'orders' not in env_data:
env_data['orders'] = []
env_data['orders'].append(order)
save_env_data(env_id, env_data)
return jsonify({"success": True, "url": url_for('order_invoice', env_id=env_id, order_id=order['id'])})
@app.route('/<env_id>/order/<order_id>')
def order_invoice(env_id, order_id):
env_data = get_env_data(env_id)
settings = env_data.get('settings', {})
orders = env_data.get('orders', [])
order = next((o for o in orders if o.get('id') == order_id), None)
if not order:
return "Заказ не найден", 404
currency = settings.get('currency_code', 'KGS')
text = f"Здравствуйте! Я оформил заказ #{order['id']} на сумму {order['total']} {currency}.\nСсылка на накладную: {request.url}"
encoded_text = urllib.parse.quote(text)
messenger = settings.get('order_messenger', 'whatsapp')
contact = settings.get('order_contact', '')
if messenger == 'whatsapp':
num = re.sub(r'[^0-9+]', '', contact)
send_link = f"https://wa.me/{num}?text={encoded_text}"
messenger_name = "WhatsApp"
elif messenger == 'telegram':
username = contact.replace('@', '').strip()
send_link = f"tg://resolve?domain={username}&text={encoded_text}"
messenger_name = "Telegram"
else:
send_link = "#"
messenger_name = "Нет"
return render_template_string(
ORDER_TEMPLATE,
order=order,
currency=currency,
send_link=send_link,
messenger_name=messenger_name,
repo_id=REPO_ID,
settings=settings,
env_id=env_id
)
@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',[])
blocks = data.get('blocks',[])
page = request.args.get('p', 1, type=int)
show_archive = request.args.get('tab') == 'archive'
if request.method == 'POST':
action = request.form.get('action')
try:
if action == 'bulk_add':
category = request.form.get('category', 'Без категории')
product_count = int(request.form.get('product_count', 0))
if HF_TOKEN_WRITE and product_count > 0:
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
api = HfApi()
for i in range(product_count):
photos = request.files.getlist(f'photos_{i}')
price_str = request.form.get(f'price_{i}', '0')
desc_str = request.form.get(f'description_{i}', '').strip()
vars_str = request.form.get(f'variants_{i}', '')
words_str = request.form.get(f'search_keywords_{i}', '').strip()
try:
price_val = float(price_str)
except ValueError:
price_val = 0.0
variants_list = [v.strip() for v in vars_str.split(',') if v.strip()]
photo_filenames = []
for photo in photos:
if photo and photo.filename:
try:
ext = os.path.splitext(photo.filename)[1].lower()
if ext not in ['.jpg', '.jpeg', '.png', '.gif', '.webp']: ext = '.jpg'
photo_filename = f"photo_{uuid4().hex[:12]}{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)
os.remove(temp_path)
photo_filenames.append(photo_filename)
except Exception: pass
if photo_filenames:
products.append({
'product_id': uuid4().hex,
'price': price_val,
'category': category,
'photos': photo_filenames,
'views': 0,
'archived': False,
'description': desc_str,
'variants': variants_list,
'search_keywords': words_str
})
data['products'] = products
save_env_data(env_id, data)
return "OK", 200
return "No photos", 400
elif action == 'bulk_delete':
product_ids = request.form.getlist('product_ids[]')
if product_ids:
to_delete_photos = []
new_products = []
for p in products:
if p.get('product_id') in product_ids:
to_delete_photos.extend(p.get('photos', []))
else:
new_products.append(p)
if to_delete_photos and HF_TOKEN_WRITE:
try:
api = HfApi()
paths = [f"photos/{p}" for p in to_delete_photos]
for chunk in [paths[x:x+50] for x in range(0, len(paths), 50)]:
api.delete_files(repo_id=REPO_ID, paths_in_repo=chunk, repo_type="dataset", token=HF_TOKEN_WRITE)
except Exception: pass
data['products'] = new_products
save_env_data(env_id, data)
flash(f"Успешно удалено.", 'success')
elif action == 'bulk_archive':
product_ids = request.form.getlist('product_ids[]')
if product_ids:
for p in products:
if p.get('product_id') in product_ids:
p['archived'] = True
data['products'] = products
save_env_data(env_id, data)
flash(f"Отправлено в архив.", 'success')
elif action == 'bulk_unarchive':
product_ids = request.form.getlist('product_ids[]')
if product_ids:
for p in products:
if p.get('product_id') in product_ids:
p['archived'] = False
data['products'] = products
save_env_data(env_id, data)
flash(f"Восстановлено из архива.", 'success')
elif action == 'archive_product':
product_id = request.form.get('product_id')
for p in products:
if p.get('product_id') == product_id:
p['archived'] = True
break
data['products'] = products
save_env_data(env_id, data)
flash("Товар отправлен в архив.", 'success')
elif action == 'unarchive_product':
product_id = request.form.get('product_id')
for p in products:
if p.get('product_id') == product_id:
p['archived'] = False
break
data['products'] = products
save_env_data(env_id, data)
flash("Товар восстановлен из архива.", 'success')
elif action == 'add_block':
b_type = request.form.get('block_type')
b_title = request.form.get('block_title', '').strip()
b_icon = request.form.get('block_icon', '')
b_url = request.form.get('block_url', '').strip()
b_content = request.form.get('block_content', '').strip()
if b_type == 'link' and b_url:
if not b_url.startswith(('http://', 'https://', 'mailto:', 'tel:')) and not b_url.startswith('+'):
if '@' in b_url:
b_url = 'mailto:' + b_url
else:
b_url = 'https://' + b_url
elif b_url.startswith('+'):
b_url = 'tel:' + b_url.replace(' ', '').replace('-', '')
elif b_type == 'pdf':
b_url = ''
pdf_file = request.files.get('block_pdf')
if pdf_file and pdf_file.filename and HF_TOKEN_WRITE:
try:
ext = os.path.splitext(pdf_file.filename)[1].lower()
pdf_filename = f"pdf_{uuid4().hex[:8]}{ext}"
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
temp_path = os.path.join(uploads_dir, pdf_filename)
pdf_file.save(temp_path)
HfApi().upload_file(path_or_fileobj=temp_path, path_in_repo=f"pdfs/{pdf_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
os.remove(temp_path)
b_url = pdf_filename
except Exception as e:
flash(f"Ошибка загрузки PDF: {e}", 'error')
blocks.append({
'id': uuid4().hex[:8],
'type': b_type,
'title': b_title,
'icon': b_icon if b_type in ['link', 'pdf'] else '',
'url': b_url,
'content': b_content
})
data['blocks'] = blocks
save_env_data(env_id, data)
flash("Кнопка добавлена.", "success")
elif action == 'edit_block':
b_id = request.form.get('block_id')
b_type = request.form.get('block_type')
b_title = request.form.get('block_title', '').strip()
b_icon = request.form.get('block_icon', '')
b_url = request.form.get('block_url', '').strip()
b_content = request.form.get('block_content', '').strip()
old_url = request.form.get('old_block_url', '')
for b in blocks:
if b.get('id') == b_id:
b['type'] = b_type
b['title'] = b_title
b['icon'] = b_icon if b_type in ['link', 'pdf'] else ''
b['content'] = b_content
if b_type == 'link':
if b_url:
if not b_url.startswith(('http://', 'https://', 'mailto:', 'tel:')) and not b_url.startswith('+'):
if '@' in b_url:
b_url = 'mailto:' + b_url
else:
b_url = 'https://' + b_url
elif b_url.startswith('+'):
b_url = 'tel:' + b_url.replace(' ', '').replace('-', '')
b['url'] = b_url
elif b_type == 'pdf':
pdf_file = request.files.get('block_pdf')
if pdf_file and pdf_file.filename and HF_TOKEN_WRITE:
if old_url and old_url.startswith('pdf_'):
try:
HfApi().delete_files(repo_id=REPO_ID, paths_in_repo=[f"pdfs/{old_url}"], repo_type="dataset", token=HF_TOKEN_WRITE)
except Exception: pass
try:
ext = os.path.splitext(pdf_file.filename)[1].lower()
pdf_filename = f"pdf_{uuid4().hex[:8]}{ext}"
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
temp_path = os.path.join(uploads_dir, pdf_filename)
pdf_file.save(temp_path)
HfApi().upload_file(path_or_fileobj=temp_path, path_in_repo=f"pdfs/{pdf_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
os.remove(temp_path)
b['url'] = pdf_filename
except Exception as e:
flash(f"Ошибка загрузки PDF: {e}", 'error')
b['url'] = old_url
else:
b['url'] = old_url
else:
b['url'] = ''
break
data['blocks'] = blocks
save_env_data(env_id, data)
flash("Кнопка сохранена.", "success")
elif action == 'delete_block':
b_id = request.form.get('block_id')
block_to_del = next((b for b in blocks if b.get('id') == b_id), None)
if block_to_del:
if block_to_del.get('type') == 'pdf' and block_to_del.get('url') and block_to_del.get('url').startswith('pdf_') and HF_TOKEN_WRITE:
try:
HfApi().delete_files(repo_id=REPO_ID, paths_in_repo=[f"pdfs/{block_to_del.get('url')}"], repo_type="dataset", token=HF_TOKEN_WRITE)
except Exception: pass
blocks = [b for b in blocks if b.get('id') != b_id]
data['blocks'] = blocks
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_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"Папка успешно создана.", 'success')
elif not category_name:
flash("Название папки не может быть пустым.", 'error')
else:
flash(f"Такая папка уже существует.", '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)
for product in products:
if product.get('category') == category_to_delete:
product['category'] = 'Без категории'
data['categories'] = categories
data['products'] = products
save_env_data(env_id, data)
flash(f"Папка удалена.", '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['vcard_firstname'] = request.form.get('vcard_firstname', '').strip()
settings['vcard_lastname'] = request.form.get('vcard_lastname', '').strip()
settings['vcard_job'] = request.form.get('vcard_job', '').strip()
settings['organization_name'] = request.form.get('organization_name', '').strip()
settings['about_text'] = request.form.get('about_text', '').strip()
settings['currency_code'] = request.form.get('currency_code', 'KGS')
settings['color_scheme'] = request.form.get('color_scheme', 'default')
settings['enable_cart'] = 'enable_cart' in request.form
settings['order_messenger'] = request.form.get('order_messenger', 'whatsapp')
settings['order_contact'] = request.form.get('order_contact', '').strip()
avatar_file = request.files.get('chat_avatar')
if avatar_file and avatar_file.filename and 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)
except Exception: pass
data['settings'] = settings
save_env_data(env_id, data)
flash("Настройки успешно сохранены.", 'success')
elif action == 'edit_product':
product_id = request.form.get('product_id')
product_data = next((p for p in products if p.get('product_id') == product_id), None)
if product_data:
try:
product_data['price'] = float(request.form.get('price', 0))
except ValueError:
product_data['price'] = 0.0
category = request.form.get('category')
product_data['category'] = category if category in categories else 'Без категории'
product_data['description'] = request.form.get('description', '').strip()
product_data['search_keywords'] = request.form.get('search_keywords', '').strip()
vars_str = request.form.get('variants', '')
product_data['variants'] = [v.strip() for v in vars_str.split(',') if v.strip()]
photo_files = request.files.getlist('photos')
valid_photos = [f for f in photo_files if f and f.filename]
if valid_photos and HF_TOKEN_WRITE:
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
api = HfApi()
new_filenames = []
for f in valid_photos:
try:
ext = os.path.splitext(f.filename)[1].lower()
if ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
photo_filename = f"photo_{uuid4().hex[:12]}{ext}"
temp_path = os.path.join(uploads_dir, photo_filename)
f.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)
os.remove(temp_path)
new_filenames.append(photo_filename)
except Exception: pass
if new_filenames:
if 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
product_data['photos'] = new_filenames
data['products'] = products
save_env_data(env_id, data)
flash("Товар изменён.", 'success')
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:
deleted_product = products.pop(product_index)
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("Товар удалён.", 'success')
return redirect(url_for('admin', env_id=env_id, p=page, tab='archive' if show_archive else 'active'))
except Exception as e:
flash(f"Произошла ошибка: {e}", 'error')
return redirect(url_for('admin', env_id=env_id, p=page, tab='archive' if show_archive else 'active'))
if show_archive:
filtered_products = [p for p in reversed(products) if p.get('archived')]
else:
filtered_products = [p for p in reversed(products) if not p.get('archived')]
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_settings = settings
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"
new_orders_count = sum(1 for o in data.get('orders', []) if not o.get('viewed', False))
return render_template_string(
ADMIN_TEMPLATE, paginated_products=paginated_products, total_pages=total_pages, page=page, categories=display_categories,
settings=display_settings, 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, icons=ICONS, env_id=env_id, new_orders_count=new_orders_count, show_archive=show_archive
)
@app.route('/<env_id>/admin/orders')
def admin_orders(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 = data.get('orders', [])
changed = False
for o in orders:
if not o.get('viewed', False):
o['viewed'] = True
changed = True
if changed:
save_env_data(env_id, data)
orders_sorted = sorted(orders, key=lambda x: x.get('date', ''), reverse=True)
return render_template_string(ADMIN_ORDERS_TEMPLATE, orders=orders_sorted, env_id=env_id, settings=settings, currency=settings.get('currency_code', 'KGS'), repo_id=REPO_ID)
@app.route('/<env_id>/admin/orders/delete/<order_id>', methods=['POST'])
def delete_order(env_id, order_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 = data.get('orders', [])
data['orders'] = [o for o in orders if o.get('id') != order_id]
save_env_data(env_id, data)
flash("Заказ успешно удален из истории.", "success")
return redirect(url_for('admin_orders', env_id=env_id))
if __name__ == '__main__':
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)