| 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) |
|
|