Test / app.py
Kgshop's picture
Update app.py
c79f542 verified
raw
history blame
227 kB
import os
import io
import base64
import json
import logging
import threading
import time
from datetime import datetime, timedelta, timezone
from uuid import uuid4
import random
import string
import queue
from flask import Flask, render_template_string, request, redirect, url_for, flash, jsonify, Response
from PIL import Image
import google.generativeai as genai
import numpy as np
from huggingface_hub import HfApi, hf_hub_download
from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
from werkzeug.utils import secure_filename
from dotenv import load_dotenv
import requests
from selenium import webdriver
from selenium.webdriver.chrome.service import Service as ChromeService
from selenium.webdriver.chrome.options import Options as ChromeOptions
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.keys import Keys
from selenium.common.exceptions import TimeoutException, NoSuchElementException
from webdriver_manager.chrome import ChromeDriverManager
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/metastoretest"
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
DOWNLOAD_RETRIES = 3
DOWNLOAD_DELAY = 5
ALMATY_TZ = timezone(timedelta(hours=6))
CURRENCIES = {
'KGS': 'Кыргызский сом',
'KZT': 'Казахстанский тенге',
'UAH': 'Украинская гривна',
'RUB': 'Российский рубль',
'USD': 'Доллар США',
'EUR': 'Евро'
}
COLOR_SCHEMES = {
'default': 'Бирюзовый (по умолч.)',
'forest': 'Лесной зеленый',
'ocean': 'Глубокий синий',
'sunset': 'Закатный оранжевый',
'lavender': 'Лавандовый',
'vintage': 'Винтажный',
'dark': 'Полночь (тёмная)',
'cosmic': 'Космическая ночь',
'minty': 'Свежая мята',
'mocha': 'Кофейный мокко',
'crimson': 'Багровый рассвет',
'solar': 'Солнечная вспышка',
'cyberpunk': 'Киберпанк неон'
}
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
driver = None
whatsapp_thread = None
whatsapp_env_id = None
whatsapp_queues = {
'incoming': queue.Queue(),
'outgoing': queue.Queue()
}
whatsapp_active = threading.Event()
def whatsapp_bot_thread(env_id):
global driver, whatsapp_env_id
whatsapp_env_id = env_id
try:
chrome_options = ChromeOptions()
user_data_dir = os.path.join(os.getcwd(), "whatsapp_profile")
os.makedirs(user_data_dir, exist_ok=True)
chrome_options.add_argument(f"--user-data-dir={user_data_dir}")
chrome_options.add_argument("--headless=new")
chrome_options.add_argument("--no-sandbox")
chrome_options.add_argument("--disable-dev-shm-usage")
chrome_options.add_argument("--disable-gpu")
chrome_options.add_argument("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
try:
service = ChromeService(ChromeDriverManager().install())
driver = webdriver.Chrome(service=service, options=chrome_options)
except Exception as e:
logging.error(f"Could not start WebDriver with manager: {e}. Falling back to default.")
driver = webdriver.Chrome(options=chrome_options)
driver.get("https://web.whatsapp.com")
logging.info("WhatsApp Web page opened. Please scan the QR code if required.")
wait = WebDriverWait(driver, 60)
wait.until(EC.presence_of_element_located((By.XPATH, '//*[@id="side"]/div[1]/div/div[2]/div[2]/div/div[1]/p'))) # Wait for search bar
logging.info("WhatsApp Web is logged in and ready.")
whatsapp_active.set()
processor = threading.Thread(target=process_whatsapp_queues, daemon=True)
processor.start()
last_processed_messages = {}
while whatsapp_active.is_set():
try:
unread_chats = driver.find_elements(By.XPATH, '//span[@data-testid="icon-unread-count"]')
for chat in unread_chats:
try:
parent_element = chat.find_element(By.XPATH, './ancestor::div[contains(@class, "_aou8")]')
parent_element.click()
time.sleep(1)
chat_title_element = driver.find_element(By.XPATH, '//header//span[@dir="auto"]')
chat_name = chat_title_element.text
messages = driver.find_elements(By.XPATH, '//div[contains(@class, "message-in")]')
if messages:
last_message = messages[-1]
message_text_element = last_message.find_element(By.XPATH, './/span[contains(@class, "selectable-text")]/span')
message_text = message_text_element.text
if last_processed_messages.get(chat_name) != message_text:
logging.info(f"New message from '{chat_name}': {message_text}")
whatsapp_queues['incoming'].put({'sender': chat_name, 'message': message_text})
last_processed_messages[chat_name] = message_text
except (NoSuchElementException, StaleElementReferenceException):
continue
time.sleep(5)
except Exception as e:
logging.error(f"Error in WhatsApp listener loop: {e}")
time.sleep(10)
except TimeoutException:
logging.error("Timed out waiting for WhatsApp Web to load. Please scan the QR code faster or check your connection.")
except Exception as e:
logging.error(f"An error occurred in the WhatsApp bot thread: {e}")
finally:
if driver:
driver.quit()
driver = None
whatsapp_active.clear()
logging.info("WhatsApp bot thread has stopped.")
def process_whatsapp_queues():
while whatsapp_active.is_set():
try:
# Process incoming messages
if not whatsapp_queues['incoming'].empty():
data = whatsapp_queues['incoming'].get()
sender = data['sender']
message = data['message']
# Simple chat history management (in-memory for this example)
# For a real app, this should be persisted in data.json
if 'whatsapp_chats' not in app.config:
app.config['whatsapp_chats'] = {}
if sender not in app.config['whatsapp_chats']:
app.config['whatsapp_chats'][sender] = []
history = app.config['whatsapp_chats'][sender]
ai_response = generate_chat_response(message, history, whatsapp_env_id)
history.append({'role': 'user', 'text': message})
history.append({'role': 'ai', 'text': ai_response})
# Trim history to keep it from growing too large
app.config['whatsapp_chats'][sender] = history[-20:]
whatsapp_queues['outgoing'].put({'recipient': sender, 'message': ai_response})
# Process outgoing messages
if not whatsapp_queues['outgoing'].empty():
data = whatsapp_queues['outgoing'].get()
send_whatsapp_message(data['recipient'], data['message'])
time.sleep(1)
except Exception as e:
logging.error(f"Error processing WhatsApp queues: {e}")
def send_whatsapp_message(recipient, message):
if not driver or not whatsapp_active.is_set():
logging.warning("WhatsApp driver not ready, cannot send message.")
return
try:
search_box = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.XPATH, '//*[@id="side"]/div[1]/div/div[2]/div[2]/div/div[1]/p'))
)
search_box.clear()
search_box.send_keys(recipient)
time.sleep(2)
chat = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.XPATH, f'//span[@title="{recipient}"]'))
)
chat.click()
time.sleep(1)
message_box = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.XPATH, '//*[@id="main"]/footer/div[1]/div/span[2]/div/div[2]/div[1]/div/div[1]/p'))
)
for line in message.split('\n'):
message_box.send_keys(line)
message_box.send_keys(Keys.SHIFT, Keys.ENTER)
message_box.send_keys(Keys.ENTER)
logging.info(f"Message sent to '{recipient}'.")
except TimeoutException:
logging.error(f"Could not find chat or message box for '{recipient}'.")
except Exception as e:
logging.error(f"Failed to send WhatsApp message to '{recipient}': {e}")
def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
files_to_download = [specific_file] if specific_file else SYNC_FILES
all_successful = True
for file_name in files_to_download:
success = False
for attempt in range(retries + 1):
try:
local_path = hf_hub_download(
repo_id=REPO_ID,
filename=file_name,
repo_type="dataset",
token=token_to_use,
local_dir=".",
local_dir_use_symlinks=False,
force_download=True,
resume_download=False
)
success = True
break
except RepositoryNotFoundError:
return False
except HfHubHTTPError as e:
if e.response.status_code == 404:
if attempt == 0 and not os.path.exists(file_name):
try:
if file_name == DATA_FILE:
with open(file_name, 'w', encoding='utf-8') as f:
json.dump({}, f)
except Exception as create_e:
pass
success = False
break
else:
pass
except requests.exceptions.RequestException as e:
pass
except Exception as e:
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().strftime('%Y-%m-%d %H:%M:%S')}"
)
except Exception as e:
pass
else:
pass
except Exception as e:
pass
def periodic_backup():
backup_interval = 1800
while True:
time.sleep(backup_interval)
upload_db_to_hf()
def load_data():
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):
try:
with open(DATA_FILE, 'w', encoding='utf-8') as file:
json.dump(data, file, ensure_ascii=False, indent=4)
upload_db_to_hf(specific_file=DATA_FILE)
except Exception as e:
pass
def is_chat_active(env_id):
data = get_env_data(env_id)
settings = data.get('settings', {})
if not settings.get('chat_activated', False):
return False
expires_str = settings.get('chat_activation_expires')
if not expires_str:
return False
try:
expires_dt = datetime.fromisoformat(expires_str)
if expires_dt > datetime.now(ALMATY_TZ):
return True
except (ValueError, TypeError):
return False
return False
def get_env_data(env_id):
all_data = load_data()
default_organization_info = {
"about_us": "Мы — надежный партнер в мире уникальных товаров. Мы предлагаем широкий ассортимент продукции, от электроники до товаров для дома, всегда стремясь к качеству и доступности. Наша миссия — сделать ваш шопинг приятным и удобным, предлагая только лучшие товары, тщательно отобранные для вас.",
"shipping": "Доставка осуществляется по всему Кыргызстану. Стоимость и сроки доставки зависят от региона и веса товара. По Бишкеку доставка возможна в течение 1-2 рабочих дней, в регионы — от 3 до 7 дней. Для уточнения деталей свяжитесь с нами.",
"returns": "Возврат и обмен товара возможен в течение 14 дней с момента покупки, при условии сохранения товарного вида, упаковки и чека. Некоторые категории товаров могут иметь особые условия возврата. Пожалуйста, свяжитесь с нами для оформления возврата или обмена.",
"contact": f"Наш магазин находится по адресу: Рынок Кербен, 6 ряд , 43 контейнер. Связаться с нами можно по телефону или через WhatsApp. Мы работаем ежедневно с 9:00 до 18:00."
}
default_settings = {
"organization_name": "Gippo312",
"whatsapp_number": "+996701202013",
"currency_code": "KGS",
"chat_name": "EVA",
"chat_avatar": None,
"color_scheme": "default",
"chat_activated": False,
"chat_activation_expires": None,
"business_type": "combined"
}
env_data = all_data.get(env_id, {})
if not env_data:
env_data = {
'products': [], 'categories': [], 'orders': {}, 'chats': {},
'organization_info': default_organization_info,
'settings': default_settings
}
if 'products' not in env_data: env_data['products'] = []
if 'categories' not in env_data: env_data['categories'] = []
if 'orders' not in env_data: env_data['orders'] = {}
if 'organization_info' not in env_data: env_data['organization_info'] = default_organization_info
if 'chats' not in env_data: env_data['chats'] = {}
if 'settings' not in env_data: env_data['settings'] = default_settings
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 'sizes' not in product:
product['sizes'] = []
products_changed = True
if 'variant_prices' not in product:
product['variant_prices'] = {}
products_changed = True
if 'wholesale_price' not in product:
product['wholesale_price'] = None
products_changed = True
if 'min_wholesale_quantity' not in product:
product['min_wholesale_quantity'] = None
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):
all_data = load_data()
all_data[env_id] = env_data
save_data(all_data)
def configure_gemini():
if not GOOGLE_API_KEY:
return False
try:
genai.configure(api_key=GOOGLE_API_KEY)
return True
except Exception as e:
return False
def generate_ai_description_from_image(image_data, language):
if not configure_gemini():
raise ValueError("Google AI API не настроен.")
try:
if not image_data:
raise ValueError("Файл изображения не найден.")
image_stream = io.BytesIO(image_data)
image = Image.open(image_stream).convert('RGB')
except Exception as e:
raise ValueError(f"Не удалось обработать изображение. Убедитесь, что это действительный файл изображения.")
base_prompt = "Напиши большой и красивый, содержательный рекламный пост минимум на 1000 символов со смайликами и 25 тематических хэштегов с ключевыми словами разных вариантов, чтобы мои клиенты могли найти меня в поиске Instagram, Google и т.д. по ключевым словам. Пост пиши исключительно под товар, который на фото, без адресов и номеров телефона."
lang_suffix = ""
if language == "Русский":
lang_suffix = " Пиши на русском языке."
elif language == "Кыргызский":
lang_suffix = " Пиши на кыргызском языке."
elif language == "Казахский":
lang_suffix = " Пиши на казахском языке."
elif language == "Узбекский":
lang_suffix = " Пиши на узбекском языке."
final_prompt = f"{base_prompt}{lang_suffix}"
try:
model = genai.GenerativeModel('gemma-3-27b-it')
response = model.generate_content([final_prompt, image])
if hasattr(response, 'text'):
return response.text
else:
if response.parts:
return "".join(part.text for part in response.parts if hasattr(part, 'text'))
else:
response.resolve()
return response.text
except Exception as e:
if "API key not valid" in str(e):
raise ValueError("Внутренняя ошибка конфигурации API.")
elif " Billing account not found" in str(e):
raise ValueError("Проблема с биллингом аккаунта Google Cloud. Проверьте ваш аккаунт.")
elif "Could not find model" in str(e):
raise ValueError(f"Модель 'learnlm-2.0-flash-experimental' не найдена или недоступна.")
elif "resource has been exhausted" in str(e).lower():
raise ValueError("Квота запросов исчерпана. Попробуйте позже.")
elif "content has been blocked" in str(e).lower():
reason = "неизвестна"
if hasattr(e, 'response') and hasattr(e.response, 'prompt_feedback') and e.response.prompt_feedback.block_reason:
reason = e.response.prompt_feedback.block_reason
raise ValueError(f"Генерация контента заблокирована из-за политики безопасности (причина: {reason}). Попробуйте другое изображение или запрос.)")
else:
raise ValueError(f"Ошибка при генерации контента: {e}")
def generate_chat_response(message, chat_history_from_client, env_id):
if not is_chat_active(env_id):
return "Извините, чат в данный момент неактивен. Пожалуйста, свяжитесь с нами другим способом."
if not configure_gemini():
return "Извините, сервис чата временно недоступен. Пожалуйста, попробуйте позже."
data = get_env_data(env_id)
products = data.get('products', [])
categories = data.get('categories', [])
organization_info = data.get('organization_info', {})
settings = data.get('settings', {})
currency_code = settings.get('currency_code', 'KGS')
chat_name = settings.get('chat_name', 'EVA')
org_name = settings.get('organization_name', 'Gippo312')
business_type = settings.get('business_type', 'combined')
product_info_list = []
for p in products:
if p.get('in_stock', True):
price_display = f"{p.get('price', 0):.2f}".replace('.00', '')
wholesale_info = ""
if business_type in ['wholesale', 'combined'] and p.get('wholesale_price') and p.get('min_wholesale_quantity'):
wholesale_price_display = f"{p['wholesale_price']:.2f}".replace('.00', '')
wholesale_info = (f", Оптовая цена: {wholesale_price_display} {currency_code} "
f"(от {p['min_wholesale_quantity']} шт.)")
colors_str = f"Цвета: {', '.join(p.get('colors', []))}" if p.get('colors') else ""
sizes_str = f"Размеры: {', '.join(p.get('sizes', []))}" if p.get('sizes') else ""
options_str = ""
if colors_str or sizes_str:
options_str = f", Варианты: ({' '.join(filter(None, [colors_str, sizes_str]))})"
variant_prices_str = ""
if p.get('variant_prices'):
variant_prices_list = [f"{k.replace('-', ' ')} - {v} {currency_code}" for k, v in p['variant_prices'].items()]
if variant_prices_list:
variant_prices_str = f", Особые цены: [{'; '.join(variant_prices_list)}]"
product_info_list.append(
f"- [ID_ТОВАРА: {p.get('product_id', 'N/A')} Название: {p.get('name', 'Без названия')}], "
f"Категория: {p.get('category', 'Без категории')}, "
f"Розничная цена: {price_display} {currency_code}"
f"{wholesale_info}"
f"{options_str}{variant_prices_str}, "
f"Описание: {p.get('description', '')[:100]}..."
)
product_list_str = "\n".join(product_info_list) if product_info_list else "В данный момент нет товаров в наличии."
category_list_str = ", ".join(categories) if categories else "Категорий пока нет."
org_info_str = ""
if organization_info:
org_info_str += "\n\nИнформация о магазине:\n"
if organization_info.get("about_us"):
org_info_str += f"О нас: {organization_info['about_us']}\n"
if organization_info.get("shipping"):
org_info_str += f"Доставка: {organization_info['shipping']}\n"
if organization_info.get("returns"):
org_info_str += f"Возврат и обмен: {organization_info['returns']}\n"
if organization_info.get("contact"):
org_info_str += f"Контактная информация: {organization_info['contact']}\n"
base_prompt = (
f"Ты — первоклассный виртуальный консультант-продажник по имени {chat_name} для магазина {org_name}. "
"Говори на любом языке, на котором к тебе обращается клиент. Будь энергичным, убедительным и проактивным. "
"Твоя речь должна быть живой, с использованием эмодзи, чтобы располагать к себе клиента. "
"Никогда не выдумывай товары, категории или характеристики, которых нет в предоставленных списках. "
"Когда ты предлагаешь товар, всегда указывай его название и ID, используя *точный формат*: [ID_ТОВАРА: <product_id> Название: <product_name>]. Это *критически важно* для клиента. "
"Если пользователь спрашивает цену на конкретный вариант (цвет или размер), найди ее в 'Особые цены'. Если там нет, используй базовую цену. "
"Создавай ощущение срочности: 'Эта модель сейчас очень популярна, осталось всего несколько штук!'. "
"Работай с возражениями: если клиент говорит 'дорого', расскажи о качестве, гарантии и уникальных особенностях товара."
)
if business_type == 'retail':
business_specific_prompt = (
"Твоя главная цель — продавать розничным клиентам. Помогай пользователям находить товары, мастерски отвечай на вопросы о них, предлагай лучшие варианты, создавай ценность и закрывай сделку. "
"Активно предлагай сопутствующие товары или более дорогие аналоги (апсейл). Например: 'Отличный выбор! К этому телефону идеально подойдут наши новые беспроводные наушники. Хотите взглянуть?'. "
)
elif business_type == 'wholesale':
business_specific_prompt = (
"Твоя главная цель — продавать товары оптом для бизнес-клиентов. "
"Всегда уточняй, какой объем интересует клиента. Если клиент спрашивает цену, всегда предлагай оптовую цену и условия (минимальное количество). "
"Подчеркивай выгоду больших партий и возможность долгосрочного сотрудничества. Например: 'При покупке от 50 единиц цена будет значительно ниже. Какой объем вас интересует для вашего бизнеса?'. "
"Твоя задача — находить крупных клиентов и заключать большие сделки."
)
else:
business_specific_prompt = (
"Твоя главная цель — продавать товары как в розницу, так и оптом. В начале диалога вежливо уточни у клиента, интересует ли его розничная или оптовая покупка, чтобы сделать наилучшее предложение. "
"Для розничных клиентов: предлагай сопутствующие товары, рассказывай о преимуществах. "
"Для оптовых клиентов: сообщай об оптовых ценах и минимальном количестве для заказа. Подчеркивай выгоду крупных партий. "
"Четко разделяй предложения для разных типов клиентов. Например: 'Вас интересует покупка для себя или для бизнеса? У нас есть отличные условия для оптовиков!'. "
)
system_instruction_content = (
f"{base_prompt}\n{business_specific_prompt}\n\n"
f"Список доступных категорий: {category_list_str}.\n\n"
f"Список доступных товаров в магазине (используй эту информацию для ответов):\n"
f"{product_list_str}"
f"{org_info_str}\n\n"
"Если пользователь спрашивает про товары, которых нет, вежливо сообщи об этом и немедленно предложи лучшую альтернативу из имеющихся. "
"Если вопрос касается общей информации о магазине (доставка, возврат), используй данные из блока 'Информация о магазине' и сразу после ответа возвращай разговор к покупкам: 'Кстати, я могу помочь вам подобрать что-нибудь еще?'. "
"Твоя конечная цель — довольный клиент, который совершил покупку. Действуй!"
)
generated_text = ""
response = None
try:
model = genai.GenerativeModel('gemma-3-27b-it')
model_chat_history_for_gemini = [
{'role': 'user', 'parts': [{'text': system_instruction_content}]}
]
for entry in chat_history_from_client:
gemini_role = 'model' if entry['role'] == 'ai' else 'user'
model_chat_history_for_gemini.append({
'role': gemini_role,
'parts': [{'text': entry['text']}]
})
chat = model.start_chat(history=model_chat_history_for_gemini)
response = chat.send_message(message, generation_config={'max_output_tokens': 1000})
if hasattr(response, 'text'):
generated_text = response.text
elif response.parts:
generated_text = "".join(part.text for part in response.parts if hasattr(part, 'text'))
else:
response.resolve()
if hasattr(response, 'text'):
generated_text = response.text
else:
raise ValueError("AI did not return a valid text response.")
return generated_text
except Exception as e:
if "API key not valid" in str(e):
return "Внутренняя ошибка конфигурации API."
elif " Billing account not found" in str(e):
return "Проблема с биллингом аккаунта Google Cloud."
elif "Could not find model" in str(e):
return "Модель AI не найдена или недоступна."
elif "resource has been exhausted" in str(e).lower():
return "Квота запросов исчерпана. Попробуйте позже."
elif "content has been blocked" in str(e).lower() or (response is not None and hasattr(response, 'prompt_feedback') and response.prompt_feedback.block_reason):
reason = response.prompt_feedback.block_reason if (response and hasattr(response, 'prompt_feedback')) else "неизвестна"
return f"Извините, Ваш запрос был заблокирован из-за политики безопасности (причина: {reason}). Пожалуйста, переформулируйте его."
else:
return f"Извините, произошла ошибка: {e}"
LANDING_PAGE_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title> MetaStore - AI система для Вашего Бизнеса</title>
<style>
body, html {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
iframe {
border: none;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<iframe src="https://v0-ai-agent-landing-page-smoky-six.vercel.app/"></iframe>
</body>
</html>
'''
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;
--warning: #ffcc80;
--warning-text: #856404;
}
body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-light); color: var(--text-dark); padding: 20px; }
.container { max-width: 900px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); }
h1 { font-weight: 600; color: var(--bg-medium); margin-bottom: 25px; text-align: center; }
.section { margin-bottom: 30px; }
.add-env-form { margin-bottom: 20px; text-align: center; }
#search-env {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
box-sizing: border-box;
font-size: 1rem;
font-family: 'Montserrat', sans-serif;
}
.button { padding: 10px 18px; border: none; border-radius: 6px; 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; gap: 5px; }
.button:hover { background-color: var(--accent-hover); }
.button:disabled { background-color: #ccc; cursor: not-allowed; }
.env-list { list-style: none; padding: 0; }
.env-item { background: #fdfdff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 15px; margin-bottom: 10px; display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 15px; }
.env-details { display: flex; flex-direction: column; }
.env-id { font-weight: 600; color: var(--bg-medium); font-size: 1.2rem; }
.env-status { font-size: 0.85rem; color: #666; }
.env-status .expires-soon { color: var(--danger); font-weight: bold; }
.env-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end;}
.delete-button { background-color: var(--danger); color: white; }
.activate-button { background-color: #66BB6A; color: white; }
.message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; text-align: center; }
.message.success { background-color: #d4edda; color: #155724; }
.message.error { background-color: #f8d7da; color: #721c24; }
@media (max-width: 768px) {
.env-item {
grid-template-columns: 1fr;
}
.env-actions {
justify-content: flex-start;
}
}
</style>
</head>
<body>
<div class="container">
<h1><i class="fas fa-server"></i> Управление Средами</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="message {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="section">
<form method="POST" action="{{ url_for('create_environment') }}" class="add-env-form">
<button type="submit" class="button"><i class="fas fa-plus-circle"></i> Создать новую среду</button>
</form>
</div>
<div class="section">
<input type="text" id="search-env" placeholder="Поиск по ID среды...">
</div>
<div class="section">
<h2><i class="fas fa-list-ul"></i> Существующие среды</h2>
{% if environments %}
<ul class="env-list">
{% for env in environments %}
<li class="env-item">
<div class="env-details">
<span class="env-id">{{ env.id }}</span>
<div class="env-status">
{% if env.chat_active %}
Активирован до: <span class="{{ 'expires-soon' if env.expires_soon else '' }}">{{ env.expires_date }}</span>
{% else %}
Чат не активирован
{% endif %}
</div>
</div>
<div class="env-actions">
<a href="{{ url_for('admin', env_id=env.id) }}" class="button" target="_blank"><i class="fas fa-tools"></i> Админ</a>
<a href="{{ url_for('catalog', env_id=env.id) }}" class="button" target="_blank"><i class="fas fa-store"></i> Каталог</a>
<form method="POST" action="{{ url_for('activate_chat', env_id=env.id) }}" style="display:inline-flex; gap: 5px; align-items: center;">
<select name="period" style="padding: 10px; border-radius: 4px; border: 1px solid #ccc; height: 100%;">
<option value="month">1 месяц</option>
<option value="half_year">6 месяцев</option>
<option value="year">1 год</option>
</select>
<button type="submit" class="button activate-button">
<i class="fas fa-check-circle"></i> {{ 'Продлить' if env.chat_active else 'Активировать' }}
</button>
</form>
<form method="POST" action="{{ url_for('delete_environment', env_id=env.id) }}" style="display:inline;" onsubmit="return confirm('Вы уверены, что хотите удалить среду {{ env.id }}? Это действие необратимо.');">
<button type="submit" class="button delete-button"><i class="fas fa-trash-alt"></i></button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p>Пока не создано ни одной среды.</p>
{% endif %}
</div>
</div>
<script>
document.getElementById('search-env').addEventListener('input', function() {
const searchTerm = this.value.toLowerCase().trim();
const envItems = document.querySelectorAll('.env-item');
envItems.forEach(item => {
const envId = item.querySelector('.env-id').textContent.toLowerCase();
if (envId.includes(searchTerm)) {
item.style.display = 'grid';
} else {
item.style.display = 'none';
}
});
});
</script>
</body>
</html>
'''
CATALOG_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>{{ settings.organization_name }} - Каталог</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&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: #FFFFFF; --danger: #CD5C5C; --danger-hover: #F08080; }
{% elif settings.color_scheme == 'ocean' %}
:root { --bg-dark: #000080; --bg-medium: #1E90FF; --accent: #87CEEB; --accent-hover: #ADD8E6; --text-light: #F0F8FF; --text-dark: #000000; --danger: #FF6347; --danger-hover: #FF4500; }
{% elif settings.color_scheme == 'sunset' %}
:root { --bg-dark: #8B4513; --bg-medium: #D2691E; --accent: #FFA500; --accent-hover: #FFB733; --text-light: #FFF8DC; --text-dark: #000000; --danger: #DC143C; --danger-hover: #FF0000; }
{% elif settings.color_scheme == 'lavender' %}
:root { --bg-dark: #483D8B; --bg-medium: #8A2BE2; --accent: #D8BFD8; --accent-hover: #E6E6FA; --text-light: #F0F8FF; --text-dark: #000000; --danger: #DB7093; --danger-hover: #FFC0CB; }
{% elif settings.color_scheme == 'vintage' %}
:root { --bg-dark: #5D4037; --bg-medium: #8D6E63; --accent: #D7CCC8; --accent-hover: #EFEBE9; --text-light: #F5F5F5; --text-dark: #3E2723; --danger: #BF360C; --danger-hover: #F4511E; }
{% elif settings.color_scheme == 'dark' %}
:root { --bg-dark: #121212; --bg-medium: #1E1E1E; --accent: #BB86FC; --accent-hover: #A764FC; --text-light: #E1E1E1; --text-dark: #121212; --danger: #CF6679; --danger-hover: #D98899; }
{% elif settings.color_scheme == 'cosmic' %}
:root { --bg-dark: #1A237E; --bg-medium: #303F9F; --accent: #536DFE; --accent-hover: #7986CB; --text-light: #FFFFFF; --text-dark: #FFFFFF; --danger: #F50057; --danger-hover: #FF4081; }
{% elif settings.color_scheme == 'minty' %}
:root { --bg-dark: #004D40; --bg-medium: #00796B; --accent: #4DB6AC; --accent-hover: #80CBC4; --text-light: #E0F2F1; --text-dark: #FFFFFF; --danger: #ef5350; --danger-hover: #e57373; }
{% elif settings.color_scheme == 'mocha' %}
:root { --bg-dark: #3e2723; --bg-medium: #5d4037; --accent: #a1887f; --accent-hover: #bcaaa4; --text-light: #efebe9; --text-dark: #3e2723; --danger: #d32f2f; --danger-hover: #e57373; }
{% elif settings.color_scheme == 'crimson' %}
:root { --bg-dark: #6A1B9A; --bg-medium: #9C27B0; --accent: #CE93D8; --accent-hover: #E1BEE7; --text-light: #FFFFFF; --text-dark: #FFFFFF; --danger: #E91E63; --danger-hover: #F06292; }
{% elif settings.color_scheme == 'solar' %}
:root { --bg-dark: #E65100; --bg-medium: #FB8C00; --accent: #FFCA28; --accent-hover: #FFD54F; --text-light: #212121; --text-dark: #212121; --danger: #d32f2f; --danger-hover: #e57373; }
{% elif settings.color_scheme == 'cyberpunk' %}
:root { --bg-dark: #000000; --bg-medium: #0d0221; --accent: #00f0ff; --accent-hover: #81f5ff; --text-light: #ffffff; --text-dark: #000000; --danger: #f50057; --danger-hover: #ff4081; }
{% else %}
:root { --bg-dark: #003C43; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-light: #E3FEF7; --text-dark: #003C43; --danger: #E57373; --danger-hover: #EF5350; }
{% endif %}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { -webkit-tap-highlight-color: transparent; }
body {
font-family: 'Montserrat', sans-serif;
background-color: var(--bg-dark);
color: var(--text-light);
line-height: 1.6;
}
.container {
max-width: 1300px;
margin: 0 auto;
padding: 0 0 100px 0;
}
.top-bar {
display: flex;
align-items: center;
padding: 15px 20px;
gap: 15px;
position: sticky;
top: 0;
background-color: var(--bg-dark);
z-index: 999;
border-bottom: 1px solid var(--bg-medium);
}
.logo {
flex-shrink: 0;
}
.logo img {
width: 45px;
height: 45px;
border-radius: 50%;
border: 2px solid var(--accent);
}
.search-wrapper {
flex-grow: 1;
position: relative;
}
#search-input {
width: 100%;
padding: 12px 20px 12px 45px;
font-size: 1rem;
border: none;
border-radius: 25px;
outline: none;
background-color: var(--bg-medium);
color: var(--text-light);
transition: all 0.3s ease;
}
#search-input::placeholder { color: rgba(227, 254, 247, 0.6); }
.search-wrapper .fa-search {
position: absolute;
top: 50%;
left: 18px;
transform: translateY(-50%);
color: rgba(227, 254, 247, 0.6);
font-size: 1rem;
}
.category-section {
margin-top: 20px;
}
.category-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
margin-bottom: 15px;
}
.category-header h2 {
font-size: 1.5rem;
font-weight: 600;
}
.category-header .view-all-arrow {
font-size: 1.8rem;
color: var(--accent);
text-decoration: none;
font-weight: 300;
}
.product-carousel {
display: flex;
overflow-x: auto;
gap: 16px;
padding: 10px 20px;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.product-carousel::-webkit-scrollbar { display: none; }
.product-card {
width: 170px;
height: 170px;
background: white;
border-radius: 16px;
flex-shrink: 0;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
position: relative;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
}
.product-card:active { transform: scale(0.96); }
.product-image-container {
width: 100%;
height: 100%;
}
.product-image-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
.product-info-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, transparent 100%);
padding: 20px 12px 10px;
color: #fff;
pointer-events: none;
}
.product-price {
font-size: 1rem;
font-weight: 600;
}
.top-product-indicator {
position: absolute;
top: 8px;
right: 8px;
background-color: var(--accent);
color: var(--text-dark);
padding: 2px 8px;
font-size: 0.7rem;
border-radius: 10px;
font-weight: bold;
z-index: 10;
}
.no-results-message { padding: 40px 20px; text-align: center; font-size: 1.1rem; color: #ccc; }
.floating-buttons-container {
position: fixed;
bottom: 25px;
right: 25px;
display: flex;
flex-direction: column;
gap: 15px;
z-index: 1000;
}
.floating-button {
background-color: var(--accent);
color: var(--text-dark);
border: none;
border-radius: 50%;
width: 55px;
height: 55px;
font-size: 1.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 15px rgba(72, 209, 204, 0.4);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
text-decoration: none;
}
.floating-button:hover {
background-color: var(--accent-hover);
transform: translateY(-3px);
}
#cart-button { position: relative; }
#cart-count {
position: absolute;
top: -2px;
right: -2px;
background-color: var(--danger);
color: white;
border-radius: 50%;
padding: 2px 6px;
font-size: 0.7rem;
font-weight: bold;
border: 2px solid var(--accent);
}
.modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.6); backdrop-filter: blur(5px); overflow-y: auto; }
.modal-content { background: #ffffff; color: var(--text-dark); margin: 5% auto; padding: 25px; border-radius: 15px; width: 90%; max-width: 700px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); animation: slideIn 0.3s ease-out; position: relative; }
.dark-theme .modal-content { background: #2a2a2a; color: var(--text-light); }
@keyframes slideIn { from { transform: translateY(-30px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.close { position: absolute; top: 15px; right: 15px; font-size: 1.8rem; color: #aaa; cursor: pointer; transition: color 0.3s; line-height: 1; }
.close:hover { color: #666; }
.modal-content h2 { margin-top: 0; margin-bottom: 20px; color: var(--bg-medium); display: flex; align-items: center; gap: 10px;}
.cart-item { display: grid; grid-template-columns: 60px 1fr auto auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #e0e0e0; }
.dark-theme .cart-item { border-bottom-color: #444; }
.cart-item:last-child { border-bottom: none; }
.cart-item img { width: 60px; height: 60px; object-fit: cover; border-radius: 8px; }
.cart-item-details { grid-column: 2 / span 2; }
.cart-item-details strong { display: block; margin-bottom: 5px; font-size: 1rem; }
.dark-theme .cart-item-details strong { color: var(--text-light); }
.cart-item-details .variant-info { font-size: 0.85rem; color: #666; }
.dark-theme .cart-item-details .variant-info { color: #ccc; }
.cart-item-price { font-size: 0.9rem; color: #666; }
.dark-theme .cart-item-price { color: #ccc; }
.cart-item-price .wholesale-label { color: var(--bg-medium); font-weight: bold; }
.cart-item-quantity { display: flex; align-items: center; gap: 8px; grid-column: 1; grid-row: 2;}
.quantity-btn { background-color: #eee; border: 1px solid #ddd; border-radius: 50%; width: 28px; height: 28px; cursor: pointer; font-size: 1.1rem; line-height: 1; display: flex; align-items: center; justify-content: center; }
.dark-theme .quantity-btn { background-color: #444; border-color: #555; color: #fff; }
.cart-item-total { font-weight: bold; text-align: right; grid-column: 3 / span 2; grid-row: 2; font-size: 1rem; color: var(--bg-medium);}
.cart-item-remove { grid-column: 4; grid-row: 1; justify-self: end; background:none; border:none; color: var(--danger); cursor:pointer; font-size: 1.3em; padding: 5px; line-height: 1; }
.cart-item-remove:hover { color: var(--danger-hover); }
.quantity-input, .options-select { width: 100%; max-width: 180px; padding: 10px; border: 1px solid #e0e0e0; border-radius: 8px; font-size: 1rem; margin: 10px 0; box-sizing: border-box; }
.dark-theme .quantity-input, .dark-theme .options-select { background-color: #333; color: #fff; border-color: #555; }
.quantity-input:focus, .options-select:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 2px rgba(72, 209, 204, 0.2); }
.cart-summary { margin-top: 20px; text-align: right; border-top: 1px solid #e0e0e0; padding-top: 15px; }
.dark-theme .cart-summary { border-top-color: #444; }
.cart-summary strong { font-size: 1.2rem; color: var(--bg-medium);}
.cart-actions { margin-top: 25px; display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
.product-button { display: block; width: auto; flex-grow: 1; padding: 10px; border: none; border-radius: 8px; color: var(--text-dark); font-size: 0.9rem; font-weight: 500; cursor: pointer; transition: all 0.3s ease; text-align: center; text-decoration: none; }
.product-button i { margin-right: 5px; }
.clear-cart { background-color: #6c757d; color: white;}
.clear-cart:hover { background-color: #5a6268; }
.formulate-order-button { background-color: var(--accent); color: var(--text-dark); }
.formulate-order-button:hover { background-color: var(--accent-hover); }
.notification { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); background-color: var(--accent); color: var(--text-dark); padding: 10px 20px; border-radius: 20px; box-shadow: 0 4px 10px rgba(0,0,0,0.2); z-index: 1002; opacity: 0; transition: opacity 0.5s ease; font-size: 0.9rem;}
.notification.show { opacity: 1;}
.chat-product-card { background-color: #f0f2f5; border-radius: 12px; padding: 10px; margin-top: 8px; display: flex; align-items: center; gap: 12px; border: 1px solid #e0e0e0; }
.chat-product-card img { width: 50px; height: 50px; object-fit: cover; border-radius: 8px; flex-shrink: 0; }
.chat-product-card-info { flex-grow: 1; }
.chat-product-card-info strong { display: block; font-size: 0.9rem; color: var(--text-dark); margin-bottom: 2px; }
.chat-product-card-info span { font-size: 0.85rem; color: var(--bg-medium); font-weight: 500; }
.chat-product-card-actions { display: flex; flex-direction: column; gap: 5px; }
.chat-product-link, .chat-add-to-cart { display: inline-block; background-color: #E0F2F1; color: var(--bg-medium); padding: 5px 10px; border-radius: 15px; cursor: pointer; font-size: 0.85rem; text-decoration: none; transition: background-color 0.2s; font-weight: 500; text-align: center; width: 100%; }
.chat-product-link:hover, .chat-add-to-cart:hover { background-color: #B2DFDB; }
@media (max-width: 480px) {
.cart-item { grid-template-columns: 60px 1fr auto; }
.cart-item-details { grid-column: 2; }
.cart-item-remove { grid-column: 3; }
.cart-item-quantity { grid-column: 2; grid-row: 2; justify-self: start; }
.cart-item-total { grid-column: 3; grid-row: 2; }
}
</style>
</head>
<body class="{{ 'dark-theme' if settings.color_scheme == 'dark' else '' }}">
<div class="container">
<div class="top-bar">
<a href="{{ url_for('catalog', env_id=env_id) }}" class="logo">
<img src="{{ chat_avatar_url }}" alt="Logo">
</a>
<div class="search-wrapper">
<i class="fas fa-search"></i>
<input type="text" id="search-input" placeholder="Искать товары">
</div>
</div>
<div id="catalog-content">
{% set has_products = False %}
{% for category_name in ordered_categories %}
{% if products_by_category[category_name] %}
{% set has_products = True %}
<div class="category-section" data-category-name="{{ category_name }}">
<div class="category-header">
<h2>{{ category_name }}</h2>
<a class="view-all-arrow" href="#">&gt;</a>
</div>
<div class="product-carousel">
{% for product in products_by_category[category_name] %}
<div class="product-card"
data-product-id="{{ product.get('product_id', '') }}"
data-name="{{ product.name|lower }}"
data-description="{{ product.get('description', '')|lower }}"
onclick="openModalById('{{ product.get('product_id', '') }}')">
{% if product.get('is_top', False) %}
<span class="top-product-indicator"><i class="fas fa-star"></i></span>
{% endif %}
<div class="product-image-container">
{% if product.get('photos') and product['photos']|length > 0 %}
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}"
alt="{{ product.name }}"
loading="lazy">
{% else %}
<img src="https://via.placeholder.com/170x170.png?text={{ settings.organization_name }}" alt="No Image" loading="lazy">
{% endif %}
</div>
<div class="product-info-overlay">
<span class="product-price">
{% if settings.business_type == 'wholesale' and product.wholesale_price is not none %}
{{ "%.0f"|format(product.wholesale_price) }} {{ currency_code }}
{% else %}
{{ "%.0f"|format(product.price) }} {{ currency_code }}
{% endif %}
</span>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
{% if not has_products %}
<p class="no-results-message">Товары пока не добавлены.</p>
{% endif %}
<p id="no-results-message" class="no-results-message" style="display: none;">По вашему запросу ничего не найдено.</p>
</div>
</div>
<div id="productModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('productModal')" aria-label="Закрыть">×</span>
<div id="modalContent">Загрузка...</div>
</div>
</div>
<div id="quantityModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('quantityModal')" aria-label="Закрыть">×</span>
<h2>Укажите опции и количество</h2>
<div id="quantityModalOptions"></div>
<label for="quantityInput">Количество:</label>
<input type="number" id="quantityInput" class="quantity-input" min="1" value="1" oninput="updatePriceOnQuantityChange()">
<p id="wholesale-info" style="font-size: 0.9em; color: var(--bg-medium); margin-bottom: 10px;"></p>
<button class="product-button formulate-order-button" style="width:100%;" onclick="confirmAddToCart()"><i class="fas fa-check"></i> Добавить в корзину</button>
</div>
</div>
<div id="cartModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('cartModal')" aria-label="Закрыть">×</span>
<h2><i class="fas fa-shopping-cart"></i> Ваша корзина</h2>
<div id="cartContent"><p style="text-align: center; padding: 20px;">Ваша корзина пуста.</p></div>
<div class="cart-summary">
<strong>Итого: <span id="cartTotal">0.00</span> {{ currency_code }}</strong>
</div>
<div class="cart-actions">
<button class="product-button clear-cart" onclick="clearCart()">
<i class="fas fa-trash"></i> Очистить корзину
</button>
<button class="product-button formulate-order-button" onclick="formulateOrder()">
<i class="fas fa-file-alt"></i> Сформировать заказ
</button>
</div>
</div>
</div>
<div class="floating-buttons-container">
{% if chat_is_active %}
<a href="{{ url_for('chat_page', env_id=env_id) }}" id="chat-open-button" class="floating-button" aria-label="Открыть чат">
<i class="fas fa-comment-dots"></i>
</a>
{% endif %}
<button id="cart-button" class="floating-button" onclick="openCartModal()" aria-label="Открыть корзину">
<i class="fas fa-shopping-cart"></i>
<span id="cart-count">0</span>
</button>
</div>
<div id="notification-placeholder"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
<script>
const allProducts = {{ products_json|safe }};
const repoId = '{{ repo_id }}';
const currencyCode = '{{ currency_code }}';
const envId = '{{ env_id }}';
const businessType = '{{ settings.business_type }}';
let selectedProductId = null;
let cart = JSON.parse(localStorage.getItem(`mekaCart_${envId}`) || '[]');
function getProductById(productId) {
return allProducts.find(p => p.product_id === productId);
}
function getProductIndexById(productId) {
return allProducts.findIndex(p => p.product_id === productId);
}
function openModalById(productId) {
const productIndex = getProductIndexById(productId);
if (productIndex === -1) {
alert("Ошибка: товар не найден или отсутствует в наличии.");
return;
}
loadProductDetails(productIndex);
const modal = document.getElementById('productModal');
if (modal) {
modal.style.display = "block";
document.body.style.overflow = 'hidden';
}
}
function closeModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = "none";
}
const anyModalOpen = document.querySelector('.modal[style*="display: block"]');
if (!anyModalOpen) {
document.body.style.overflow = 'auto';
}
}
function loadProductDetails(index) {
const modalContent = document.getElementById('modalContent');
if (!modalContent) return;
modalContent.innerHTML = '<p style="text-align:center; padding: 40px;">Загрузка...</p>';
fetch(`/${envId}/product/${index}`)
.then(response => {
if (!response.ok) throw new Error(`Ошибка ${response.status}: ${response.statusText}`);
return response.text();
})
.then(data => {
modalContent.innerHTML = data;
initializeSwiper();
attachOptionListeners();
})
.catch(error => {
console.error('Ошибка загрузки деталей продукта:', error);
modalContent.innerHTML = `<p style="color: var(--danger); text-align:center; padding: 40px;">Не удалось загрузить информацию о товаре. ${error.message}</p>`;
});
}
function attachOptionListeners() {
const product = getProductById(selectedProductId);
if (!product) return;
const priceEl = document.getElementById('variantPrice');
const colorSelect = document.getElementById('colorSelect');
const sizeSelect = document.getElementById('sizeSelect');
const updatePrice = () => {
if (!priceEl || businessType === 'wholesale') return;
let color = colorSelect ? colorSelect.value : null;
let size = sizeSelect ? sizeSelect.value : null;
let key = [color, size].filter(Boolean).join('-');
let currentPrice = product.price;
if (key && product.variant_prices && product.variant_prices[key]) {
currentPrice = product.variant_prices[key];
}
priceEl.textContent = `${parseFloat(currentPrice).toFixed(0)} ${currencyCode}`;
};
if (colorSelect) colorSelect.addEventListener('change', updatePrice);
if (sizeSelect) sizeSelect.addEventListener('change', updatePrice);
}
function initializeSwiper() {
const swiperContainer = document.querySelector('#productModal .swiper-container');
if (swiperContainer) {
new Swiper(swiperContainer, {
slidesPerView: 1,
spaceBetween: 20,
loop: true,
grabCursor: true,
pagination: { el: '.swiper-pagination', clickable: true },
navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' },
zoom: { maxRatio: 3, containerClass: 'swiper-zoom-container' },
autoplay: { delay: 5000, disableOnInteraction: true, },
});
}
}
function updatePriceOnQuantityChange() {
const product = getProductById(selectedProductId);
const quantity = parseInt(document.getElementById('quantityInput').value || 1);
const wholesaleInfoEl = document.getElementById('wholesale-info');
if (!product || !wholesaleInfoEl) return;
wholesaleInfoEl.textContent = '';
if (businessType === 'wholesale') {
if (product.wholesale_price && product.min_wholesale_quantity) {
wholesaleInfoEl.textContent = `Минимальный заказ: ${product.min_wholesale_quantity} шт.`;
}
} else if (businessType === 'combined') {
if (product.wholesale_price && product.min_wholesale_quantity) {
if (quantity >= product.min_wholesale_quantity) {
wholesaleInfoEl.textContent = `Активирована оптовая цена: ${product.wholesale_price.toFixed(2)} ${currencyCode}`;
} else {
const needed = product.min_wholesale_quantity - quantity;
wholesaleInfoEl.textContent = `Добавьте еще ${needed} шт. для оптовой цены (${product.wholesale_price.toFixed(2)} ${currencyCode})`;
}
}
}
}
function openQuantityModalById(productId) {
selectedProductId = productId;
const product = getProductById(productId);
if (!product) {
alert("Ошибка: товар не найден.");
return;
}
const optionsContainer = document.getElementById('quantityModalOptions');
optionsContainer.innerHTML = '';
if (product.colors && product.colors.length > 0) {
let colorHtml = '<label for="qColorSelect">Цвет/Вариант:</label><select id="qColorSelect" class="options-select">';
product.colors.forEach(c => colorHtml += `<option value="${c}">${c}</option>`);
colorHtml += '</select>';
optionsContainer.innerHTML += colorHtml;
}
if (product.sizes && product.sizes.length > 0) {
let sizeHtml = '<label for="qSizeSelect">Размер/Объем:</label><select id="qSizeSelect" class="options-select">';
product.sizes.forEach(s => sizeHtml += `<option value="${s}">${s}</option>`);
sizeHtml += '</select>';
optionsContainer.innerHTML += sizeHtml;
}
let minQty = 1;
if(businessType === 'wholesale' && product.min_wholesale_quantity) {
minQty = product.min_wholesale_quantity;
}
document.getElementById('quantityInput').value = minQty;
document.getElementById('quantityInput').min = minQty;
updatePriceOnQuantityChange();
const modal = document.getElementById('quantityModal');
if(modal) {
modal.style.display = "block";
document.body.style.overflow = 'hidden';
}
}
function confirmAddToCart() {
if (selectedProductId === null) return;
const quantityInput = document.getElementById('quantityInput');
const quantity = parseInt(quantityInput.value);
const colorSelect = document.getElementById('qColorSelect');
const sizeSelect = document.getElementById('qSizeSelect');
const color = colorSelect ? colorSelect.value : 'N/A';
const size = sizeSelect ? sizeSelect.value : 'N/A';
if (isNaN(quantity) || quantity <= 0) {
alert("Пожалуйста, укажите корректное количество (больше 0).");
quantityInput.focus();
return;
}
const product = getProductById(selectedProductId);
if (!product) {
alert("Ошибка добавления: товар не найден.");
return;
}
if (businessType === 'wholesale') {
if (!product.min_wholesale_quantity || quantity < product.min_wholesale_quantity) {
const minQty = product.min_wholesale_quantity || 1;
const needed = minQty - quantity;
if (needed > 0) {
showNotification(`Минимальный заказ ${minQty} шт. Добавьте еще ${needed} шт.`, 5000);
} else {
showNotification(`Минимальный заказ для этого товара - ${minQty} шт.`, 5000);
}
return;
}
}
let price;
if (businessType === 'wholesale') {
price = product.wholesale_price;
} else {
price = product.price;
let variantKey = [color, size].filter(v => v !== 'N/A').join('-');
if (variantKey && product.variant_prices && product.variant_prices[variantKey]) {
price = product.variant_prices[variantKey];
}
if (businessType === 'combined' && product.wholesale_price && product.min_wholesale_quantity && quantity >= product.min_wholesale_quantity) {
price = product.wholesale_price;
}
}
const cartItemId = `${product.product_id}-${color}-${size}`;
const existingItemIndex = cart.findIndex(item => item.id === cartItemId);
if (existingItemIndex > -1) {
cart[existingItemIndex].quantity += quantity;
const newQuantity = cart[existingItemIndex].quantity;
if (businessType === 'combined' && product.wholesale_price && product.min_wholesale_quantity) {
cart[existingItemIndex].price = newQuantity >= product.min_wholesale_quantity ? product.wholesale_price : product.price;
}
} else {
cart.push({
id: cartItemId,
product_id: product.product_id,
name: product.name,
price: price,
photo: product.photos && product.photos.length > 0 ? product.photos[0] : null,
quantity: quantity,
color: color,
size: size
});
}
localStorage.setItem(`mekaCart_${envId}`, JSON.stringify(cart));
closeModal('quantityModal');
updateCartButton();
showNotification(`${product.name} добавлен в корзину!`);
}
function updateCartButton() {
const cartCountElement = document.getElementById('cart-count');
const cartButton = document.getElementById('cart-button');
if (!cartCountElement || !cartButton) return;
let totalItems = 0;
cart.forEach(item => { totalItems += item.quantity; });
if (totalItems > 0) {
cartCountElement.textContent = totalItems;
cartCountElement.style.display = 'flex';
cartButton.style.display = 'flex';
} else {
cartCountElement.style.display = 'none';
}
}
function openCartModal() {
const cartContent = document.getElementById('cartContent');
const cartTotalElement = document.getElementById('cartTotal');
if (!cartContent || !cartTotalElement) return;
let total = 0;
if (cart.length === 0) {
cartContent.innerHTML = '<p style="text-align: center; padding: 20px;">Ваша корзина пуста.</p>';
cartTotalElement.textContent = '0.00';
} else {
cartContent.innerHTML = cart.map(item => {
const product = getProductById(item.product_id);
let isWholesale = false;
if (businessType === 'wholesale') {
isWholesale = true;
} else if (product && product.wholesale_price && product.min_wholesale_quantity && item.quantity >= product.min_wholesale_quantity) {
isWholesale = true;
item.price = product.wholesale_price;
} else if (product) {
item.price = product.price;
}
const itemTotal = item.price * item.quantity;
total += itemTotal;
const photoUrl = item.photo
? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${item.photo}`
: 'https://via.placeholder.com/60x60.png?text=N/A';
let variantInfo = [];
if (item.color && item.color !== 'N/A') variantInfo.push(`Цвет: ${item.color}`);
if (item.size && item.size !== 'N/A') variantInfo.push(`Размер: ${item.size}`);
return `
<div class="cart-item">
<img src="${photoUrl}" alt="${item.name}">
<div class="cart-item-details">
<strong>${item.name}</strong>
<p class="variant-info">${variantInfo.join(', ')}</p>
<p class="cart-item-price">${isWholesale ? '<span class="wholesale-label">Опт: </span>' : ''}${item.price.toFixed(2)} ${currencyCode}</p>
</div>
<div class="cart-item-quantity">
<button class="quantity-btn" onclick="decrementCartItem('${item.id}')">-</button>
<span>${item.quantity}</span>
<button class="quantity-btn" onclick="incrementCartItem('${item.id}')">+</button>
</div>
<span class="cart-item-total">${itemTotal.toFixed(2)}</span>
<button class="cart-item-remove" onclick="removeFromCart('${item.id}')" title="Удалить товар"><i class="fas fa-trash-alt"></i></button>
</div>
`;
}).join('');
cartTotalElement.textContent = total.toFixed(2);
}
const modal = document.getElementById('cartModal');
if (modal) {
modal.style.display = "block";
document.body.style.overflow = 'hidden';
}
}
function incrementCartItem(itemId) {
const itemIndex = cart.findIndex(item => item.id === itemId);
if (itemIndex > -1) {
cart[itemIndex].quantity++;
localStorage.setItem(`mekaCart_${envId}`, JSON.stringify(cart));
openCartModal();
updateCartButton();
}
}
function decrementCartItem(itemId) {
const itemIndex = cart.findIndex(item => item.id === itemId);
if (itemIndex > -1) {
const product = getProductById(cart[itemIndex].product_id);
let minQty = 1;
if(businessType === 'wholesale' && product && product.min_wholesale_quantity) {
minQty = product.min_wholesale_quantity;
}
if (cart[itemIndex].quantity - 1 < minQty) {
if (confirm("Количество меньше минимального. Удалить товар из корзины?")) {
cart.splice(itemIndex, 1);
} else {
return;
}
} else {
cart[itemIndex].quantity--;
}
if (cart[itemIndex] && cart[itemIndex].quantity <= 0) {
cart.splice(itemIndex, 1);
}
localStorage.setItem(`mekaCart_${envId}`, JSON.stringify(cart));
openCartModal();
updateCartButton();
}
}
function removeFromCart(itemId) {
cart = cart.filter(item => item.id !== itemId);
localStorage.setItem(`mekaCart_${envId}`, JSON.stringify(cart));
openCartModal();
updateCartButton();
}
function clearCart() {
if (confirm("Вы уверены, что хотите очистить корзину?")) {
cart = [];
localStorage.removeItem(`mekaCart_${envId}`);
openCartModal();
updateCartButton();
}
}
function formulateOrder() {
if (cart.length === 0) {
alert("Корзина пуста! Добавьте товары перед формированием заказа.");
return;
}
const orderData = { cart: cart };
const formulateButton = document.querySelector('.formulate-order-button');
if (formulateButton) formulateButton.disabled = true;
showNotification("Формируем заказ...", 5000);
fetch(`/${envId}/create_order`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(orderData)
})
.then(response => {
if (!response.ok) {
return response.json().then(err => { throw new Error(err.error || 'Не удалось создать заказ'); });
}
return response.json();
})
.then(data => {
if (data.order_id) {
localStorage.removeItem(`mekaCart_${envId}`);
cart = [];
updateCartButton();
closeModal('cartModal');
window.location.href = `/${envId}/order/${data.order_id}`;
} else {
throw new Error('Не получен ID заказа от сервера.');
}
})
.catch(error => {
console.error('Ошибка при формировании заказа:', error);
alert(`Ошибка: ${error.message}`);
if (formulateButton) formulateButton.disabled = false;
});
}
function filterProducts() {
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
const allCategorySections = document.querySelectorAll('.category-section');
const noResultsEl = document.getElementById('no-results-message');
let totalResults = 0;
allCategorySections.forEach(section => {
const productCards = section.querySelectorAll('.product-card');
let categoryHasVisibleProducts = false;
productCards.forEach(card => {
const name = card.dataset.name || '';
const description = card.dataset.description || '';
if (searchTerm === '' || name.includes(searchTerm) || description.includes(searchTerm)) {
card.style.display = 'inline-block';
categoryHasVisibleProducts = true;
} else {
card.style.display = 'none';
}
});
if (categoryHasVisibleProducts) {
section.style.display = 'block';
totalResults++;
} else {
section.style.display = 'none';
}
});
if (totalResults === 0 && searchTerm !== '') {
if (noResultsEl) noResultsEl.style.display = 'block';
} else {
if (noResultsEl) noResultsEl.style.display = 'none';
}
}
function showNotification(message, duration = 3000) {
const placeholder = document.getElementById('notification-placeholder');
if (!placeholder) return;
const notification = document.createElement('div');
notification.className = 'notification';
notification.textContent = message;
placeholder.appendChild(notification);
void notification.offsetWidth;
notification.classList.add('show');
setTimeout(() => {
notification.classList.remove('show');
notification.addEventListener('transitionend', () => notification.remove());
}, duration);
}
document.addEventListener('DOMContentLoaded', () => {
updateCartButton();
document.getElementById('search-input').addEventListener('input', filterProducts);
window.addEventListener('click', function(event) {
if (event.target.classList.contains('modal')) { closeModal(event.target.id); }
});
window.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
document.querySelectorAll('.modal[style*="display: block"]').forEach(modal => {
closeModal(modal.id);
});
}
});
});
</script>
</body>
</html>
'''
CHAT_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>{{ settings.organization_name }} - Чат с {{ settings.chat_name }}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&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: #FFFFFF; --danger: #CD5C5C; --danger-hover: #F08080; --chat-bg: #F5F5DC; }
{% elif settings.color_scheme == 'ocean' %}
:root { --bg-dark: #000080; --bg-medium: #1E90FF; --accent: #87CEEB; --accent-hover: #ADD8E6; --text-light: #F0F8FF; --text-dark: #000000; --danger: #FF6347; --danger-hover: #FF4500; --chat-bg: #F0F8FF; }
{% elif settings.color_scheme == 'sunset' %}
:root { --bg-dark: #8B4513; --bg-medium: #D2691E; --accent: #FFA500; --accent-hover: #FFB733; --text-light: #FFF8DC; --text-dark: #000000; --danger: #DC143C; --danger-hover: #FF0000; --chat-bg: #FFF8DC; }
{% elif settings.color_scheme == 'lavender' %}
:root { --bg-dark: #483D8B; --bg-medium: #8A2BE2; --accent: #D8BFD8; --accent-hover: #E6E6FA; --text-light: #F0F8FF; --text-dark: #000000; --danger: #DB7093; --danger-hover: #FFC0CB; --chat-bg: #F8F8FF; }
{% elif settings.color_scheme == 'vintage' %}
:root { --bg-dark: #5D4037; --bg-medium: #8D6E63; --accent: #D7CCC8; --accent-hover: #EFEBE9; --text-light: #F5F5F5; --text-dark: #3E2723; --danger: #BF360C; --danger-hover: #F4511E; --chat-bg: #EFEBE9; }
{% elif settings.color_scheme == 'dark' %}
:root { --bg-dark: #1F1F1F; --bg-medium: #333333; --accent: #BB86FC; --accent-hover: #A764FC; --text-light: #E1E1E1; --text-dark: #FFFFFF; --danger: #CF6679; --danger-hover: #D98899; --chat-bg: #121212; }
{% elif settings.color_scheme == 'cosmic' %}
:root { --bg-dark: #1A237E; --bg-medium: #303F9F; --accent: #536DFE; --accent-hover: #7986CB; --text-light: #FFFFFF; --text-dark: #FFFFFF; --danger: #F50057; --danger-hover: #FF4081; --chat-bg: #E8EAF6; }
{% elif settings.color_scheme == 'minty' %}
:root { --bg-dark: #004D40; --bg-medium: #00796B; --accent: #4DB6AC; --accent-hover: #80CBC4; --text-light: #E0F2F1; --text-dark: #FFFFFF; --danger: #ef5350; --danger-hover: #e57373; --chat-bg: #f1f8e9; }
{% elif settings.color_scheme == 'mocha' %}
:root { --bg-dark: #3e2723; --bg-medium: #5d4037; --accent: #a1887f; --accent-hover: #bcaaa4; --text-light: #efebe9; --text-dark: #3e2723; --danger: #d32f2f; --danger-hover: #e57373; --chat-bg: #d7ccc8; }
{% elif settings.color_scheme == 'crimson' %}
:root { --bg-dark: #6A1B9A; --bg-medium: #9C27B0; --accent: #CE93D8; --accent-hover: #E1BEE7; --text-light: #FFFFFF; --text-dark: #FFFFFF; --danger: #E91E63; --danger-hover: #F06292; --chat-bg: #F3E5F5; }
{% elif settings.color_scheme == 'solar' %}
:root { --bg-dark: #E65100; --bg-medium: #FB8C00; --accent: #FFCA28; --accent-hover: #FFD54F; --text-light: #212121; --text-dark: #212121; --danger: #d32f2f; --danger-hover: #e57373; --chat-bg: #fff8e1; }
{% elif settings.color_scheme == 'cyberpunk' %}
:root { --bg-dark: #000000; --bg-medium: #0d0221; --accent: #00f0ff; --accent-hover: #81f5ff; --text-light: #ffffff; --text-dark: #000000; --danger: #f50057; --danger-hover: #ff4081; --chat-bg: #0a0116; }
{% else %}
:root { --bg-dark: #003C43; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-light: #E3FEF7; --text-dark: #003C43; --danger: #E57373; --danger-hover: #EF5350; --chat-bg: #f0f2f5; }
{% endif %}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { -webkit-tap-highlight-color: transparent; height: 100%; }
body {
font-family: 'Montserrat', sans-serif;
background-color: var(--chat-bg);
color: var(--text-dark);
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.chat-container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
max-width: 800px;
margin: 0 auto;
background: #fff;
box-shadow: 0 0 20px rgba(0,0,0,0.05);
}
body.dark-theme .chat-container { background: var(--chat-bg); color: var(--text-light); }
.chat-header {
display: flex;
align-items: center;
padding: 10px 15px;
background: var(--bg-dark);
color: var(--text-light);
flex-shrink: 0;
}
.chat-header a { color: var(--text-light); font-size: 1.2rem; text-decoration: none; }
.chat-header .logo { width: 40px; height: 40px; border-radius: 50%; margin: 0 15px; border: 2px solid var(--accent); }
.chat-header h1 { font-size: 1.2rem; font-weight: 600; }
#chat-messages {
flex-grow: 1;
overflow-y: auto;
padding: 20px 15px;
display: flex;
flex-direction: column;
gap: 12px;
}
.chat-message {
display: flex;
flex-direction: column;
max-width: 85%;
animation: message-appear 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
@keyframes message-appear {
from { opacity: 0; transform: translateY(15px); }
to { opacity: 1; transform: translateY(0); }
}
.message-bubble {
padding: 10px 15px;
border-radius: 18px;
line-height: 1.5;
word-wrap: break-word;
}
.chat-message.user { align-self: flex-end; }
.chat-message.user .message-bubble {
background-color: var(--bg-medium);
color: var(--text-light);
border-bottom-right-radius: 4px;
}
.chat-message.ai { align-self: flex-start; }
.chat-message.ai .message-bubble {
background-color: #e6e6e6;
color: #333;
border-bottom-left-radius: 4px;
}
body.dark-theme .chat-message.ai .message-bubble { background-color: #333; color: var(--text-light); }
.chat-input-container {
padding: 15px;
background: #fff;
border-top: 1px solid #ddd;
display: flex;
gap: 10px;
align-items: center;
flex-shrink: 0;
}
body.dark-theme .chat-input-container { background: var(--bg-dark); border-top: 1px solid #444;}
#chat-input {
flex-grow: 1;
padding: 12px 18px;
border: 1px solid #e0e0e0;
border-radius: 24px;
font-size: 1rem;
outline: none;
transition: border-color 0.3s, box-shadow 0.3s;
}
body.dark-theme #chat-input { background-color: #333; color: var(--text-light); border-color: #555; }
#chat-input:focus {
border-color: var(--bg-medium);
box-shadow: 0 0 0 3px rgba(19, 93, 102, 0.15);
}
#chat-send-button {
background-color: var(--bg-medium);
color: var(--text-light);
border: none;
border-radius: 50%;
width: 48px;
height: 48px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;
flex-shrink: 0;
font-size: 1.2rem;
}
#chat-send-button:hover { background-color: var(--bg-dark); }
#chat-send-button:active { transform: scale(0.9); }
#chat-send-button:disabled { background-color: #cccccc; cursor: not-allowed; }
.floating-buttons-container { position: fixed; bottom: 25px; right: 25px; z-index: 1000; }
.floating-button { background-color: var(--accent); color: var(--text-dark); border: none; border-radius: 50%; width: 55px; height: 55px; font-size: 1.5rem; cursor: pointer; display: none; align-items: center; justify-content: center; box-shadow: 0 4px 15px rgba(72, 209, 204, 0.4); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.floating-button:hover { background-color: var(--accent-hover); transform: translateY(-3px); }
#cart-button { position: relative; }
#cart-count { position: absolute; top: -2px; right: -2px; background-color: var(--danger); color: white; border-radius: 50%; padding: 2px 6px; font-size: 0.7rem; font-weight: bold; border: 2px solid var(--accent); }
.modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.6); backdrop-filter: blur(5px); overflow-y: auto; }
.modal-content { background: #ffffff; color: var(--text-dark); margin: 5% auto; padding: 25px; border-radius: 15px; width: 90%; max-width: 700px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); animation: slideIn 0.3s ease-out; position: relative; }
body.dark-theme .modal-content { background-color: #2a2a2a; color: var(--text-light); }
@keyframes slideIn { from { transform: translateY(-30px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.close { position: absolute; top: 15px; right: 15px; font-size: 1.8rem; color: #aaa; cursor: pointer; transition: color 0.3s; line-height: 1; }
.close:hover { color: #666; }
.modal-content h2 { margin-top: 0; margin-bottom: 20px; color: var(--bg-medium); display: flex; align-items: center; gap: 10px;}
.cart-item { display: grid; grid-template-columns: 60px 1fr auto auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #e0e0e0; }
body.dark-theme .cart-item { border-bottom-color: #444; }
.cart-item:last-child { border-bottom: none; }
.cart-item img { width: 60px; height: 60px; object-fit: cover; border-radius: 8px; }
.cart-item-details strong { display: block; margin-bottom: 5px; font-size: 1rem; color: var(--text-dark);}
body.dark-theme .cart-item-details strong { color: var(--text-light); }
.cart-item-quantity { display: flex; align-items: center; gap: 8px; }
.quantity-btn { background-color: #eee; border: 1px solid #ddd; border-radius: 50%; width: 28px; height: 28px; cursor: pointer; }
body.dark-theme .quantity-btn { background-color: #444; border-color: #555; color: #fff; }
.cart-item-total { font-weight: bold; text-align: right; font-size: 1rem; color: var(--bg-medium);}
.cart-item-remove { background:none; border:none; color: var(--danger); cursor:pointer; font-size: 1.3em; }
.quantity-input, .options-select { width: 100%; max-width: 180px; padding: 10px; border: 1px solid #e0e0e0; border-radius: 8px; font-size: 1rem; margin: 10px 0; }
body.dark-theme .quantity-input, body.dark-theme .options-select { background-color: #333; color: #fff; border-color: #555; }
.cart-summary { margin-top: 20px; text-align: right; border-top: 1px solid #e0e0e0; padding-top: 15px; }
body.dark-theme .cart-summary { border-top-color: #444; }
.cart-actions { margin-top: 25px; display: flex; justify-content: space-between; }
.product-button { display: block; width: auto; flex-grow: 1; padding: 10px; border: none; border-radius: 8px; cursor: pointer; text-align: center; text-decoration: none; }
.clear-cart { background-color: #6c757d; color: white;}
.formulate-order-button { background-color: var(--accent); color: var(--text-dark); }
.notification { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); background-color: var(--accent); color: var(--text-dark); padding: 10px 20px; border-radius: 20px; box-shadow: 0 4px 10px rgba(0,0,0,0.2); z-index: 1002; opacity: 0; transition: opacity 0.5s ease; font-size: 0.9rem;}
.notification.show { opacity: 1;}
.chat-product-card { background-color: #f0f2f5; border-radius: 12px; padding: 10px; margin-top: 8px; display: flex; align-items: center; gap: 12px; border: 1px solid #e0e0e0; }
body.dark-theme .chat-product-card { background-color: #333; border-color: #555; }
.chat-product-card img { width: 50px; height: 50px; object-fit: cover; border-radius: 8px; flex-shrink: 0; }
.chat-product-card-info { flex-grow: 1; }
.chat-product-card-info strong { display: block; font-size: 0.9rem; margin-bottom: 2px; }
body.dark-theme .chat-product-card-info strong { color: var(--text-light); }
.chat-product-card-info span { font-size: 0.85rem; color: var(--bg-medium); font-weight: 500; }
.chat-product-card-actions { display: flex; flex-direction: column; gap: 5px; }
.chat-product-link, .chat-add-to-cart { display: inline-block; background-color: #E0F2F1; color: var(--bg-medium); padding: 5px 10px; border-radius: 15px; cursor: pointer; font-size: 0.85rem; text-decoration: none; transition: background-color 0.2s; font-weight: 500; text-align: center; width: 100%; }
.chat-product-link:hover, .chat-add-to-cart:hover { background-color: #B2DFDB; }
body.dark-theme .chat-product-link, body.dark-theme .chat-add-to-cart { background-color: #444; color: var(--accent); }
</style>
</head>
<body class="{{ 'dark-theme' if settings.color_scheme == 'dark' else '' }}">
<div class="chat-container">
<div class="chat-header">
<a href="{{ url_for('catalog', env_id=env_id) }}"><i class="fas fa-arrow-left"></i></a>
<img src="{{ chat_avatar_url }}" alt="Logo" class="logo">
<h1>Чат с {{ settings.chat_name }}</h1>
</div>
<div id="chat-messages"></div>
<div class="chat-input-container">
<input type="text" id="chat-input" placeholder="Напишите сообщение..." autocomplete="off">
<button id="chat-send-button"><i class="fas fa-paper-plane"></i></button>
</div>
</div>
<div class="floating-buttons-container">
<button id="cart-button" class="floating-button" onclick="openCartModal()" aria-label="Открыть корзину">
<i class="fas fa-shopping-cart"></i>
<span id="cart-count">0</span>
</button>
</div>
<div id="productModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('productModal')">×</span>
<div id="modalContent"></div>
</div>
</div>
<div id="quantityModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('quantityModal')">×</span>
<h2>Укажите опции и количество</h2>
<div id="quantityModalOptions"></div>
<label for="quantityInput">Количество:</label>
<input type="number" id="quantityInput" class="quantity-input" min="1" value="1">
<button class="product-button formulate-order-button" onclick="confirmAddToCart()">Добавить в корзину</button>
</div>
</div>
<div id="cartModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('cartModal')">×</span>
<h2><i class="fas fa-shopping-cart"></i> Ваша корзина</h2>
<div id="cartContent"></div>
<div class="cart-summary">
<strong>Итого: <span id="cartTotal">0.00</span> {{ currency_code }}</strong>
</div>
<div class="cart-actions">
<button class="product-button clear-cart" onclick="clearCart()">Очистить</button>
<button class="product-button formulate-order-button" onclick="formulateOrder()">Сформировать</button>
</div>
</div>
</div>
<div id="notification-placeholder"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
<script>
const allProducts = {{ products_json|safe }};
const repoId = '{{ repo_id }}';
const currencyCode = '{{ currency_code }}';
const envId = '{{ env_id }}';
const businessType = '{{ settings.business_type }}';
let selectedProductId = null;
let cart = JSON.parse(localStorage.getItem(`mekaCart_${envId}`) || '[]');
let chatHistory = [];
let chatId = sessionStorage.getItem(`gippoChatId_${envId}`);
if (!chatId) {
chatId = 'chat_' + new Date().getTime() + '_' + Math.random().toString(36).substr(2, 9);
sessionStorage.setItem(`gippoChatId_${envId}`, chatId);
}
const chatMessagesDiv = document.getElementById('chat-messages');
const chatInput = document.getElementById('chat-input');
const chatSendButton = document.getElementById('chat-send-button');
function getProductById(productId) { return allProducts.find(p => p.product_id === productId); }
function getProductIndexById(productId) { return allProducts.findIndex(p => p.product_id === productId); }
function openModalById(productId) {
const productIndex = getProductIndexById(productId);
if (productIndex === -1) return;
loadProductDetails(productIndex);
document.getElementById('productModal').style.display = "block";
document.body.style.overflow = 'hidden';
}
function closeModal(modalId) {
document.getElementById(modalId).style.display = "none";
if (!document.querySelector('.modal[style*="display: block"]')) {
document.body.style.overflow = 'auto';
}
}
function loadProductDetails(index) {
const modalContent = document.getElementById('modalContent');
modalContent.innerHTML = '<p>Загрузка...</p>';
fetch(`/${envId}/product/${index}`)
.then(response => response.text())
.then(data => {
modalContent.innerHTML = data;
initializeSwiper();
}).catch(error => modalContent.innerHTML = `<p>Не удалось загрузить информацию о товаре.</p>`);
}
function initializeSwiper() {
if (document.querySelector('#productModal .swiper-container')) {
new Swiper('#productModal .swiper-container', { loop: true, pagination: { el: '.swiper-pagination' }, navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' } });
}
}
function openQuantityModalById(productId) {
selectedProductId = productId;
const product = getProductById(productId);
if (!product) return;
const optionsContainer = document.getElementById('quantityModalOptions');
optionsContainer.innerHTML = '';
if (product.colors && product.colors.length > 0) {
let colorHtml = '<label for="qColorSelect">Цвет/Вариант:</label><select id="qColorSelect" class="options-select">';
product.colors.forEach(c => colorHtml += `<option value="${c}">${c}</option>`);
colorHtml += '</select>';
optionsContainer.innerHTML += colorHtml;
}
if (product.sizes && product.sizes.length > 0) {
let sizeHtml = '<label for="qSizeSelect">Размер/Объем:</label><select id="qSizeSelect" class="options-select">';
product.sizes.forEach(s => sizeHtml += `<option value="${s}">${s}</option>`);
sizeHtml += '</select>';
optionsContainer.innerHTML += sizeHtml;
}
document.getElementById('quantityInput').value = 1;
document.getElementById('quantityModal').style.display = "block";
}
function confirmAddToCart() {
const quantity = parseInt(document.getElementById('quantityInput').value);
const colorSelect = document.getElementById('qColorSelect');
const sizeSelect = document.getElementById('qSizeSelect');
const color = colorSelect ? colorSelect.value : 'N/A';
const size = sizeSelect ? sizeSelect.value : 'N/A';
if (isNaN(quantity) || quantity <= 0) {
alert("Укажите корректное количество.");
return;
}
const product = getProductById(selectedProductId);
if (businessType === 'wholesale') {
if (!product.min_wholesale_quantity || quantity < product.min_wholesale_quantity) {
const minQty = product.min_wholesale_quantity || 1;
const needed = minQty - quantity;
if (needed > 0) {
showNotification(`Минимальный заказ ${minQty} шт. Добавьте еще ${needed} шт.`, 5000);
} else {
showNotification(`Минимальный заказ для этого товара - ${minQty} шт.`, 5000);
}
return;
}
}
let price;
if (businessType === 'wholesale') {
price = product.wholesale_price;
} else {
price = product.price;
let variantKey = [color, size].filter(v => v !== 'N/A').join('-');
if (variantKey && product.variant_prices && product.variant_prices[variantKey]) {
price = product.variant_prices[variantKey];
}
if (businessType === 'combined' && product.wholesale_price && product.min_wholesale_quantity && quantity >= product.min_wholesale_quantity) {
price = product.wholesale_price;
}
}
const cartItemId = `${product.product_id}-${color}-${size}`;
const existingItem = cart.find(item => item.id === cartItemId);
if (existingItem) {
existingItem.quantity += quantity;
} else {
cart.push({ id: cartItemId, product_id: product.product_id, name: product.name, price: price, photo: product.photos ? product.photos[0] : null, quantity, color, size });
}
localStorage.setItem(`mekaCart_${envId}`, JSON.stringify(cart));
closeModal('quantityModal');
updateCartButton();
showNotification(`${product.name} добавлен в корзину!`);
}
function updateCartButton() {
const cartCountEl = document.getElementById('cart-count');
const cartButton = document.getElementById('cart-button');
const totalItems = cart.reduce((sum, item) => sum + item.quantity, 0);
if (totalItems > 0) {
cartCountEl.textContent = totalItems;
cartCountEl.style.display = 'flex';
cartButton.style.display = 'flex';
} else {
cartCountEl.style.display = 'none';
cartButton.style.display = 'none';
}
}
function openCartModal() {
const cartContent = document.getElementById('cartContent');
const cartTotalEl = document.getElementById('cartTotal');
let total = 0;
if (cart.length === 0) {
cartContent.innerHTML = '<p>Ваша корзина пуста.</p>';
cartTotalEl.textContent = '0.00';
} else {
cartContent.innerHTML = cart.map(item => {
const itemTotal = item.price * item.quantity;
total += itemTotal;
let variantInfo = [];
if (item.color && item.color !== 'N/A') variantInfo.push(`Цвет: ${item.color}`);
if (item.size && item.size !== 'N/A') variantInfo.push(`Размер: ${item.size}`);
return `<div class="cart-item">
<img src="${item.photo ? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${item.photo}` : ''}" alt="${item.name}">
<div><strong>${item.name}</strong><p>${variantInfo.join(', ')}</p><p>${item.price.toFixed(2)} ${currencyCode}</p></div>
<div class="cart-item-quantity"><button class="quantity-btn" onclick="decrementCartItem('${item.id}')">-</button><span>${item.quantity}</span><button class="quantity-btn" onclick="incrementCartItem('${item.id}')">+</button></div>
<span class="cart-item-total">${itemTotal.toFixed(2)}</span>
<button class="cart-item-remove" onclick="removeFromCart('${item.id}')"><i class="fas fa-trash-alt"></i></button>
</div>`;
}).join('');
cartTotalEl.textContent = total.toFixed(2);
}
document.getElementById('cartModal').style.display = 'block';
}
function incrementCartItem(itemId) {
const item = cart.find(i => i.id === itemId);
if (item) item.quantity++;
localStorage.setItem(`mekaCart_${envId}`, JSON.stringify(cart));
openCartModal();
updateCartButton();
}
function decrementCartItem(itemId) {
const itemIndex = cart.findIndex(i => i.id === itemId);
if (itemIndex > -1) {
cart[itemIndex].quantity--;
if (cart[itemIndex].quantity <= 0) cart.splice(itemIndex, 1);
localStorage.setItem(`mekaCart_${envId}`, JSON.stringify(cart));
openCartModal();
updateCartButton();
}
}
function removeFromCart(itemId) {
cart = cart.filter(item => item.id !== itemId);
localStorage.setItem(`mekaCart_${envId}`, JSON.stringify(cart));
openCartModal();
updateCartButton();
}
function clearCart() {
if (confirm("Очистить корзину?")) {
cart = [];
localStorage.removeItem(`mekaCart_${envId}`);
openCartModal();
updateCartButton();
}
}
function formulateOrder() {
if (cart.length === 0) return;
fetch(`/${envId}/create_order`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cart }) })
.then(res => res.json())
.then(data => {
if (data.order_id) {
localStorage.removeItem(`mekaCart_${envId}`);
cart = [];
updateCartButton();
closeModal('cartModal');
window.location.href = `/${envId}/order/${data.order_id}`;
}
});
}
function showNotification(message, duration = 3000) {
const el = document.createElement('div');
el.className = 'notification';
el.textContent = message;
document.getElementById('notification-placeholder').appendChild(el);
setTimeout(() => {
el.classList.add('show');
setTimeout(() => {
el.classList.remove('show');
el.addEventListener('transitionend', () => el.remove());
}, duration);
}, 10);
}
function addMessageToChatUI(text, role) {
const messageElement = document.createElement('div');
messageElement.className = `chat-message ${role}`;
const productRegex = /\[ID_ТОВАРА:\\s*([a-fA-F0-9]+)\\s*Название:\\s*([^\]]+)\]/g;
let lastIndex = 0;
const contentFragment = document.createDocumentFragment();
let match;
const sanitizedText = text.replace(/</g, "&lt;").replace(/>/g, "&gt;");
while ((match = productRegex.exec(sanitizedText)) !== null) {
if (match.index > lastIndex) {
const textNode = document.createElement('div');
textNode.className = 'message-bubble';
textNode.innerHTML = sanitizedText.substring(lastIndex, match.index).replace(/\\n/g, '<br>');
messageElement.appendChild(textNode);
}
const productId = match[1];
const product = getProductById(productId);
if (product) {
const photoUrl = product.photos && product.photos.length > 0 ? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${product.photos[0]}` : '';
const card = document.createElement('div');
card.className = 'chat-product-card';
card.innerHTML = `<img src="${photoUrl}" alt="${product.name}">
<div class="chat-product-card-info">
<strong>${product.name}</strong>
<span>${product.price.toFixed(0)} ${currencyCode}</span>
</div>
<div class="chat-product-card-actions">
<a href="#" class="chat-product-link" data-id="${productId}">Обзор</a>
<a href="#" class="chat-add-to-cart" data-id="${productId}"><i class="fas fa-cart-plus"></i></a>
</div>`;
messageElement.appendChild(card);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < sanitizedText.length) {
const textNode = document.createElement('div');
textNode.className = 'message-bubble';
textNode.innerHTML = sanitizedText.substring(lastIndex).replace(/\\n/g, '<br>');
messageElement.appendChild(textNode);
}
chatMessagesDiv.appendChild(messageElement);
chatMessagesDiv.scrollTop = chatMessagesDiv.scrollHeight;
}
async function sendMessage() {
const message = chatInput.value.trim();
if (!message) return;
addMessageToChatUI(message, 'user');
chatHistory.push({ role: 'user', text: message });
chatInput.value = '';
chatSendButton.disabled = true;
try {
const response = await fetch(`/${envId}/chat_with_ai`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: message, history: chatHistory.slice(0, -1), chat_id: chatId })
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
addMessageToChatUI(result.text, 'ai');
chatHistory.push({ role: 'ai', text: result.text });
} catch (error) {
addMessageToChatUI(`Ошибка: ${error.message}`, 'ai');
} finally {
chatSendButton.disabled = false;
chatInput.focus();
}
}
chatSendButton.addEventListener('click', sendMessage);
chatInput.addEventListener('keypress', e => { if (e.key === 'Enter') sendMessage(); });
chatMessagesDiv.addEventListener('click', e => {
if(e.target.closest('.chat-product-link')) {
e.preventDefault();
openModalById(e.target.closest('.chat-product-link').dataset.id);
}
if(e.target.closest('.chat-add-to-cart')) {
e.preventDefault();
openQuantityModalById(e.target.closest('.chat-add-to-cart').dataset.id);
}
});
document.addEventListener('DOMContentLoaded', () => {
updateCartButton();
window.addEventListener('click', e => { if (e.target.classList.contains('modal')) closeModal(e.target.id); });
window.addEventListener('keydown', e => { if (e.key === 'Escape') document.querySelectorAll('.modal').forEach(m => closeModal(m.id)); });
addMessageToChatUI('👋 Здравствуйте! Я ваш виртуальный помощник {{ settings.chat_name }}. Чем могу помочь?', 'ai');
chatHistory.push({ role: 'ai', text: 'Здравствуйте! Я ваш виртуальный помощник {{ settings.chat_name }}. Чем могу помочь?' });
});
</script>
</body>
</html>
'''
PRODUCT_DETAIL_TEMPLATE = '''
<div style="padding: 10px;" data-product-id="{{ product.get('product_id') }}">
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; text-align: center; color: #135D66;">{{ product['name'] }}</h2>
<div class="swiper-container" style="max-width: 450px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden; background-color: #fff; border: 1px solid #e0e0e0;">
<div class="swiper-wrapper">
{% if product.get('photos') and product['photos']|length > 0 %}
{% for photo in product['photos'] %}
<div class="swiper-slide" style="display: flex; justify-content: center; align-items: center; padding: 10px;">
<div class="swiper-zoom-container">
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}"
alt="{{ product['name'] }} - фото {{ loop.index }}"
style="max-width: 100%; max-height: 400px; object-fit: contain; display: block; margin: auto; cursor: grab;">
</div>
</div>
{% endfor %}
{% else %}
<div class="swiper-slide" style="display: flex; justify-content: center; align-items: center;">
<img src="https://via.placeholder.com/400x400.png?text=No+Image" alt="Изображение отсутствует" style="max-width: 100%; max-height: 400px; object-fit: contain;">
</div>
{% endif %}
</div>
{% if product.get('photos') and product['photos']|length > 1 %}
<div class="swiper-pagination" style="position: relative; bottom: 5px;"></div>
<div class="swiper-button-next" style="color: #135D66;"></div>
<div class="swiper-button-prev" style="color: #135D66;"></div>
{% endif %}
</div>
<div style="margin-top: 20px; padding: 0 10px;">
{% if product.get('colors') and product.colors|select('ne', '')|list|length > 0 %}
<div style="margin-bottom: 15px;">
<label for="colorSelect" style="display: block; margin-bottom: 5px; font-weight: 500;">Цвет/Вариант:</label>
<select id="colorSelect" class="options-select">
{% for color in product.colors|select('ne', '')|list %}
<option value="{{ color }}">{{ color }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% if product.get('sizes') and product.sizes|select('ne', '')|list|length > 0 %}
<div style="margin-bottom: 15px;">
<label for="sizeSelect" style="display: block; margin-bottom: 5px; font-weight: 500;">Размер/Объем:</label>
<select id="sizeSelect" class="options-select">
{% for size in product.sizes|select('ne', '')|list %}
<option value="{{ size }}">{{ size }}</option>
{% endfor %}
</select>
</div>
{% endif %}
</div>
<div style="text-align:center; margin-top:20px; padding: 0 10px;">
{% if settings.business_type == 'wholesale' %}
<p style="font-size: 1.5rem; font-weight: bold; color: #135D66; margin-bottom: 5px;">
<strong>Цена:</strong> <span id="variantPrice">{{ "%.0f"|format(product.wholesale_price) }} {{ currency_code }}</span>
</p>
<p style="font-size: 1rem; color: #555; margin-top: 0; margin-bottom: 15px;">
Минимальный заказ: {{ product.min_wholesale_quantity }} шт.
</p>
{% else %}
<p style="font-size: 1.5rem; font-weight: bold; color: #135D66; margin-bottom: 15px;">
<strong>Цена:</strong> <span id="variantPrice">{{ "%.0f"|format(product.price) }} {{ currency_code }}</span>
</p>
{% if product.wholesale_price and product.min_wholesale_quantity and settings.business_type == 'combined' %}
<p style="font-size: 1rem; color: #555; margin-top: -10px; margin-bottom: 15px;">
Опт: {{ "%.0f"|format(product.wholesale_price) }} {{ currency_code }} от {{ product.min_wholesale_quantity }} шт.
</p>
{% endif %}
{% endif %}
<button class="product-button formulate-order-button" style="padding: 12px 30px; width: 100%; max-width: 300px;" onclick="closeModal('productModal'); openQuantityModalById('{{ product.get('product_id', '') }}')">
<i class="fas fa-cart-plus"></i> В корзину
</button>
</div>
<div style="margin-top: 20px; font-size: 1rem; line-height: 1.7; color: #333; padding: 0 10px;">
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
<p><strong>Описание:</strong><br> {{ product.get('description', 'Описание отсутствует.')|replace('\\n', '<br>')|safe }}</p>
</div>
</div>
<script>
selectedProductId = '{{ product.get("product_id") }}';
attachOptionListeners();
</script>
'''
ORDER_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Заказ №{{ order.id }} - {{ settings.organization_name }}</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root {
--bg-light: #f4f6f9;
--bg-medium: #135D66;
--accent: #48D1CC;
--text-dark: #333;
--text-light: #E3FEF7;
}
body { font-family: 'Montserrat', sans-serif; background: var(--bg-light); color: var(--text-dark); line-height: 1.6; padding: 20px; }
.container { max-width: 800px; margin: 20px auto; padding: 30px; background: #fff; border-radius: 15px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); border: 1px solid #e0e0e0; }
h1 { text-align: center; color: var(--bg-medium); margin-bottom: 25px; font-size: 1.8rem; font-weight: 600; }
h2 { color: var(--bg-medium); margin-top: 30px; margin-bottom: 15px; font-size: 1.4rem; border-bottom: 1px solid #e0e0e0; padding-bottom: 8px;}
.order-meta { font-size: 0.9rem; color: #999; margin-bottom: 20px; text-align: center; }
.order-item { display: grid; grid-template-columns: 60px 1fr auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #f0f0f0; }
.order-item:last-child { border-bottom: none; }
.order-item img { width: 60px; height: 60px; object-fit: cover; border-radius: 8px; }
.item-details { grid-column: 2; }
.item-details strong { display: block; margin-bottom: 5px; font-size: 1.05rem; color: var(--text-dark);}
.item-details span { font-size: 0.9rem; color: #666; display: block;}
.item-quantity-total { grid-column: 3; text-align: right; }
.item-quantity { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-bottom: 5px;}
.quantity-btn { background-color: #eee; border: 1px solid #ddd; border-radius: 50%; width: 28px; height: 28px; cursor: pointer; font-size: 1.1rem; line-height: 1; display: flex; align-items: center; justify-content: center; }
.quantity-btn:hover { background-color: #e0e0e0; }
.item-total { font-weight: bold; font-size: 1rem; color: var(--bg-medium);}
.order-summary { margin-top: 30px; padding-top: 20px; border-top: 2px solid var(--accent); text-align: right; }
.order-summary p { margin-bottom: 10px; font-size: 1.1rem; }
.order-summary strong { font-size: 1.3rem; color: var(--bg-medium); }
.customer-info { margin-top: 30px; background-color: #f9f9f9; padding: 20px; border-radius: 8px; border: 1px solid #e0e0e0;}
.customer-info p { margin-bottom: 8px; font-size: 0.95rem; }
.customer-info strong { color: var(--bg-medium); }
.actions { margin-top: 30px; text-align: center; }
.button { padding: 12px 25px; border: none; border-radius: 8px; background-color: var(--accent); color: var(--text-dark); font-weight: 600; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; font-size: 1rem; display: inline-flex; align-items: center; gap: 8px; text-decoration: none; }
.button:hover { background-color: #77E4D8; }
.button:active { transform: scale(0.98); }
.button i { font-size: 1.2rem; }
.catalog-link { display: block; text-align: center; margin-top: 25px; color: var(--bg-medium); text-decoration: none; font-size: 0.9rem; }
.catalog-link:hover { text-decoration: underline; }
.not-found { text-align: center; color: #dc3545; font-size: 1.2rem; padding: 40px 0;}
@media (max-width: 600px) {
.order-item { grid-template-columns: 60px 1fr; }
.item-quantity-total { grid-column: 1 / -1; grid-row: 2; text-align: left; margin-top: 10px; display: flex; justify-content: space-between; align-items: center; }
}
</style>
</head>
<body>
<div class="container">
{% if order %}
<h1><i class="fas fa-receipt"></i> Ваш Заказ №<span id="orderId">{{ order.id }}</span></h1>
<p class="order-meta">Дата создания: {{ order.created_at }}</p>
<h2><i class="fas fa-shopping-bag"></i> Товары в заказе</h2>
<div id="orderItems"></div>
<div class="order-summary">
<p><strong>ИТОГО К ОПЛАТЕ: <span id="orderTotal">{{ "%.2f"|format(order.total_price) }}</span> {{ currency_code }}</strong></p>
</div>
<div class="customer-info">
<h2><i class="fas fa-info-circle"></i> Статус заказа</h2>
<p>Этот заказ был оформлен без входа в систему. Вы можете скорректировать его и отправить в WhatsApp.</p>
<p>Пожалуйста, свяжитесь с нами по WhatsApp для подтверждения и уточнения деталей.</p>
</div>
<div class="actions">
<button class="button" onclick="sendOrderViaWhatsApp()"><i class="fab fa-whatsapp"></i> Отправить заказ</button>
</div>
<a href="{{ url_for('catalog', env_id=env_id) }}" class="catalog-link">← Вернуться в каталог</a>
<script>
let order = {{ order|tojson|safe }};
function renderOrderItems() {
const container = document.getElementById('orderItems');
if (!container) return;
if (order.cart.length === 0) {
container.innerHTML = '<p style="text-align:center; padding: 20px;">Заказ пуст.</p>';
document.querySelector('.actions .button').disabled = true;
} else {
container.innerHTML = order.cart.map((item, index) => {
let variantInfo = [];
if (item.color && item.color !== 'N/A') variantInfo.push(`Цвет: ${item.color}`);
if (item.size && item.size !== 'N/A') variantInfo.push(`Размер: ${item.size}`);
return `
<div class="order-item">
<img src="${item.photo_url}" alt="${item.name}">
<div class="item-details">
<strong>${item.name}</strong>
<span>${variantInfo.join(', ')}</span>
<span>${item.price.toFixed(2)} {{ currency_code }}</span>
</div>
<div class="item-quantity-total">
<div class="item-quantity">
<button class="quantity-btn" onclick="decrementItem(${index})">-</button>
<span>${item.quantity}</span>
<button class="quantity-btn" onclick="incrementItem(${index})">+</button>
</div>
<div class="item-total">
${(item.price * item.quantity).toFixed(2)} {{ currency_code }}
</div>
</div>
</div>
`}).join('');
document.querySelector('.actions .button').disabled = false;
}
updateOrderTotal();
}
function updateOrderTotal() {
const totalElement = document.getElementById('orderTotal');
if (!totalElement) return;
const total = order.cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
order.total_price = total;
totalElement.textContent = total.toFixed(2);
}
function incrementItem(index) {
if (order.cart[index]) {
order.cart[index].quantity++;
renderOrderItems();
}
}
function decrementItem(index) {
if (order.cart[index]) {
order.cart[index].quantity--;
if (order.cart[index].quantity <= 0) {
if (confirm(`Удалить "${order.cart[index].name}" из заказа?`)) {
order.cart.splice(index, 1);
} else {
order.cart[index].quantity = 1;
}
}
renderOrderItems();
}
}
function sendOrderViaWhatsApp() {
if (order.cart.length === 0) {
alert("Нельзя отправить пустой заказ.");
return;
}
const orderId = document.getElementById('orderId').textContent;
const whatsappNumber = "{{ whatsapp_number }}".replace(/[^0-9]/g, '');
let message = `Здравствуйте! Хочу подтвердить или изменить свой заказ в магазине {{ settings.organization_name }}:%0A%0A`;
message += `*Номер заказа:* ${orderId}%0A%0A`;
order.cart.forEach(item => {
let variantInfo = [];
if (item.color && item.color !== 'N/A') variantInfo.push(item.color);
if (item.size && item.size !== 'N/A') variantInfo.push(item.size);
let variantText = variantInfo.length > 0 ? ` (${variantInfo.join(', ')})` : '';
message += `*${item.name}*${variantText}%0A`;
message += ` - Количество: ${item.quantity}%0A`;
message += ` - Цена: ${item.price.toFixed(2)} {{ currency_code }}%0A`;
});
message += `%0A*Итоговая сумма:* ${order.total_price.toFixed(2)} {{ currency_code }}%0A%0A`;
message += `Пожалуйста, свяжитесь со мной для уточнения деталей.`;
const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${message}`;
window.open(whatsappUrl, '_blank');
}
document.addEventListener('DOMContentLoaded', renderOrderItems);
</script>
{% else %}
<h1 style="color: #dc3545;"><i class="fas fa-exclamation-triangle"></i> Ошибка</h1>
<p class="not-found">Заказ с таким ID не найден.</p>
<a href="{{ url_for('catalog', env_id=env_id) }}" class="catalog-link">← Вернуться в каталог</a>
{% endif %}
</div>
</body>
</html>
'''
ADMIN_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Админ-панель - {{ settings.organization_name }}</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root {
--bg-light: #f4f6f9;
--bg-medium: #135D66;
--accent: #48D1CC;
--accent-hover: #77E4D8;
--text-dark: #333;
--text-on-accent: #003C43;
--danger: #E57373;
--danger-hover: #EF5350;
}
body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-light); color: var(--text-dark); padding: 20px; line-height: 1.6; }
.container { max-width: 1200px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); }
.header { padding-bottom: 15px; margin-bottom: 25px; border-bottom: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;}
.header .logo-title-container img { height: 50px; width: 50px; border-radius: 50%; object-fit: cover; border: 2px solid var(--bg-medium);}
h1, h2, h3 { font-weight: 600; color: var(--bg-medium); margin-bottom: 15px; }
h1 { font-size: 1.8rem; }
h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; }
h3 { font-size: 1.2rem; color: #004D40; margin-top: 20px; }
.section { margin-bottom: 30px; padding: 20px; background-color: #fdfdff; border: 1px solid #e0e0e0; border-radius: 8px; }
form { margin-bottom: 20px; }
label { font-weight: 500; margin-top: 10px; display: block; color: #666; font-size: 0.9rem;}
input[type="text"], input[type="number"], input[type="password"], input[type="tel"], textarea, select { width: 100%; padding: 10px 12px; margin-top: 5px; border: 1px solid #e0e0e0; border-radius: 6px; font-size: 0.95rem; box-sizing: border-box; transition: border-color 0.3s ease; background-color: #fff; }
input:focus, textarea:focus, select:focus { border-color: var(--bg-medium); outline: none; box-shadow: 0 0 0 2px rgba(19, 93, 102, 0.1); }
textarea { min-height: 80px; resize: vertical; }
input[type="file"] { padding: 8px; background-color: #ffffff; cursor: pointer; border: 1px solid #e0e0e0;}
input[type="file"]::file-selector-button { padding: 5px 10px; border-radius: 4px; background-color: #f0f0f0; border: 1px solid #e0e0e0; cursor: pointer; margin-right: 10px;}
input[type="checkbox"] { margin-right: 5px; vertical-align: middle; }
label.inline-label { display: inline-block; margin-top: 10px; font-weight: normal; }
button, .button { padding: 10px 18px; border: none; border-radius: 6px; background-color: var(--accent); color: var(--text-on-accent); font-weight: 600; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; margin-top: 15px; font-size: 0.95rem; display: inline-flex; align-items: center; gap: 5px; text-decoration: none; line-height: 1.2;}
button:hover, .button:hover { background-color: var(--accent-hover); }
button:active, .button:active { transform: scale(0.98); }
button[type="submit"] { min-width: 120px; justify-content: center; }
.delete-button { background-color: var(--danger); color: white; }
.delete-button:hover { background-color: var(--danger-hover); }
.add-button { background-color: var(--bg-medium); color: white; }
.add-button:hover { background-color: #003C43; }
.item-list { display: grid; gap: 20px; }
.item { background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.03); border: 1px solid #f0f0f0; }
.item p { margin: 5px 0; font-size: 0.9rem; color: #666; }
.item strong { color: var(--text-dark); }
.item .description { font-size: 0.85rem; color: #999; max-height: 60px; overflow: hidden; text-overflow: ellipsis; }
.item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.edit-form-container { margin-top: 15px; padding: 20px; background: #E0F2F1; border: 1px dashed #B2DFDB; border-radius: 6px; display: none; }
details { background-color: #fdfdff; border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 20px; }
details > summary { cursor: pointer; font-weight: 600; color: var(--bg-medium); display: block; padding: 15px; list-style: none; position: relative; font-size: 1.2rem; }
details > summary:hover { background-color: #fafafa; }
details > summary::after { content: '\\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; right: 20px; top: 50%; transform: translateY(-50%); transition: transform 0.2s ease; color: var(--bg-medium); }
details[open] > summary::after { transform: translateY(-50%) rotate(180deg); }
details[open] > summary { border-bottom: 1px solid #e0e0e0; }
details .form-content { padding: 20px; }
.option-input-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.option-input-group input { flex-grow: 1; margin: 0; }
.remove-option-btn { background-color: var(--danger); color: white; padding: 6px 10px; font-size: 0.8rem; margin-top: 0; line-height: 1; }
.remove-option-btn:hover { background-color: var(--danger-hover); }
.add-option-btn { background-color: #B2DFDB; color: var(--bg-medium); border: 1px solid #e0e0e0; }
.add-option-btn:hover { background-color: var(--bg-medium); color: white; border-color: var(--bg-medium); }
.photo-preview img { max-width: 70px; max-height: 70px; border-radius: 5px; margin: 5px 5px 0 0; border: 1px solid #e0e0e0; object-fit: cover;}
.sync-buttons { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
.download-hf-button { background-color: #6c757d; color: white; }
.download-hf-button:hover { background-color: #5a6268; }
.flex-container { display: flex; flex-wrap: wrap; gap: 20px; }
.flex-item { flex: 1; min-width: 350px; }
.message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; font-size: 0.9rem;}
.message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;}
.message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;}
.message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
.status-indicator { display: inline-block; padding: 3px 8px; border-radius: 12px; font-size: 0.8rem; font-weight: 500; margin-left: 10px; vertical-align: middle; }
.status-indicator.in-stock { background-color: #d4edda; color: #155724; }
.status-indicator.out-of-stock { background-color: #f8d7da; color: #721c24; }
.status-indicator.top-product { background-color: #FFF9C4; color: #F57F17; margin-left: 5px;}
.status-indicator.expires-soon { background-color: var(--danger); color: white; }
.ai-generate-button { background-color: #8D6EC8; color: white; margin-top: 5px; margin-bottom: 10px; }
.ai-generate-button:hover { background-color: #7B4DB5; }
.chat-log-item { padding: 10px; border: 1px solid #eee; border-radius: 5px; cursor: pointer; transition: background-color 0.2s; }
.chat-log-item:hover { background-color: #f7f7f7; }
.modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.6); }
.modal-content { background: #fff; margin: 10% auto; padding: 20px; border-radius: 8px; width: 90%; max-width: 600px; }
.chat-message-viewer { max-height: 60vh; overflow-y: auto; }
.chat-message.user { text-align: right; margin: 5px 0; }
.chat-message.ai { text-align: left; margin: 5px 0; }
.chat-message .bubble { display: inline-block; padding: 8px 12px; border-radius: 15px; max-width: 80%; }
.chat-message.user .bubble { background-color: #dcf8c6; }
.chat-message.ai .bubble { background-color: #f1f1f1; }
.current-avatar { max-width: 60px; max-height: 60px; border-radius: 50%; vertical-align: middle; margin-left: 10px; border: 2px solid var(--bg-medium);}
.variant-price-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; background-color: #f7f9fa; padding: 10px; border-radius: 5px; margin-top: 10px; border: 1px solid #e0e0e0; }
.variant-price-item { display: flex; align-items: center; gap: 8px; font-size: 0.9em; }
.variant-price-item label { margin-top: 0; white-space: nowrap; }
.variant-price-item input { margin-top: 0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo-title-container" style="display: flex; align-items: center; gap: 15px;">
<img src="{{ chat_avatar_url }}" alt="Logo">
<h1><i class="fas fa-tools"></i> Админ-панель {{ settings.organization_name }} (Среда: {{ env_id }})</h1>
</div>
<a href="{{ url_for('catalog', env_id=env_id) }}" class="button" style="background-color: var(--bg-medium); color: white;"><i class="fas fa-store"></i> Перейти в каталог</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="message {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="section">
<h2><i class="fas fa-robot"></i> Статус активации чата</h2>
{% if chat_status.active %}
<p>Чат <strong>активен</strong>. Срок действия истекает: <strong class="{{ 'status-indicator expires-soon' if chat_status.expires_soon else '' }}">{{ chat_status.expires_date }}</strong></p>
{% else %}
<p>Чат <strong>неактивен</strong>. {% if chat_status.expires_date %}Срок действия истек: {{ chat_status.expires_date }}{% endif %}</p>
<p style="color: var(--danger);">Для активации чат-бота, пожалуйста, свяжитесь с администратором.</p>
<a href="https://wa.me/77472479197" target="_blank" class="button add-button" style="margin-top: 10px;"><i class="fab fa-whatsapp"></i> Написать администратору</a>
{% endif %}
</div>
<div class="section">
<h2><i class="fab fa-whatsapp"></i> Интеграция с WhatsApp</h2>
{% if whatsapp_is_active %}
<p style="color: green;">WhatsApp бот активен для среды <strong>{{ whatsapp_env_id }}</strong>.</p>
<a href="{{ url_for('stop_whatsapp_bot', env_id=env_id) }}" class="button delete-button">Остановить WhatsApp Бота</a>
{% else %}
<p>Запустите ИИ-консультанта в WhatsApp. После запуска, откроется браузер. Если потребуется, отсканируйте QR-код.</p>
<a href="{{ url_for('start_whatsapp_bot', env_id=env_id) }}" class="button add-button" style="background-color: #25D366; color: white;"><i class="fab fa-whatsapp"></i> Запустить WhatsApp Бота</a>
{% endif %}
</div>
<div class="section">
<h2><i class="fas fa-sync-alt"></i> Синхронизация с Датацентром</h2>
<div class="sync-buttons">
<form method="POST" action="{{ url_for('force_upload', env_id=env_id) }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно загрузить локальные данные на сервер? Это перезапишет данные на сервере.');">
<button type="submit" class="button add-button" title="Загрузить локальные файлы на Hugging Face"><i class="fas fa-upload"></i> Загрузить БД</button>
</form>
<form method="POST" action="{{ url_for('force_download', env_id=env_id) }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно скачать данные с сервера? Это перезапишет ваши локальные файлы.');">
<button type="submit" class="button download-hf-button" title="Скачать файлы (перезапишет локальные)"><i class="fas fa-download"></i> Скачать БД</button>
</form>
</div>
<p style="font-size: 0.85rem; color: #999;">Резервное копирование происходит автоматически каждые 30 минут, а также после каждого сохранения данных. Используйте эти кнопки для немедленной синхронизации.</p>
</div>
<div class="section">
<details>
<summary><i class="fas fa-cog"></i> Настройки магазина и чата</summary>
<div class="form-content">
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="action" value="update_settings">
<label for="organization_name">Название организации:</label>
<input type="text" id="organization_name" name="organization_name" value="{{ settings.organization_name }}">
<label for="whatsapp_number">Номер WhatsApp для заказов:</label>
<input type="tel" id="whatsapp_number" name="whatsapp_number" value="{{ settings.whatsapp_number }}" placeholder="+996XXXXXXXXX">
<label for="currency_code">Валюта магазина:</label>
<select id="currency_code" name="currency_code">
{% for code, name in currencies.items() %}
<option value="{{ code }}" {% if settings.currency_code == code %}selected{% endif %}>{{ name }} ({{ code }})</option>
{% endfor %}
</select>
<label for="business_type">Тип бизнеса:</label>
<select id="business_type" name="business_type">
<option value="retail" {% if settings.business_type == 'retail' %}selected{% endif %}>Розница</option>
<option value="wholesale" {% if settings.business_type == 'wholesale' %}selected{% endif %}>Опт</option>
<option value="combined" {% if settings.get('business_type', 'combined') == 'combined' %}selected{% endif %}>Опт и Розница</option>
</select>
<label for="chat_name">Имя чат-ассистента:</label>
<input type="text" id="chat_name" name="chat_name" value="{{ settings.chat_name }}">
<label for="chat_avatar">Аватар чата (загрузите новый, чтобы изменить):</label>
<input type="file" id="chat_avatar" name="chat_avatar" accept="image/png, image/jpeg, image/gif, image/webp">
{% if settings.chat_avatar %}
<p style="font-size: 0.85rem; margin-top: 5px;">Текущий аватар: <img src="{{ chat_avatar_url }}" class="current-avatar"></p>
{% endif %}
<label for="color_scheme">Цветовая схема каталога и чата:</label>
<select id="color_scheme" name="color_scheme">
{% for key, name in color_schemes.items() %}
<option value="{{ key }}" {% if settings.color_scheme == key %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
<button type="submit" class="add-button"><i class="fas fa-save"></i> Сохранить настройки</button>
</form>
</div>
</details>
</div>
<div class="section">
<details>
<summary><i class="fas fa-comments"></i> Диалоги с {{ settings.chat_name }}</summary>
<div class="form-content">
<div class="item-list">
{% if chats %}
{% for chat_id, chat_data in chats.items()|sort(reverse=True) %}
<div class="chat-log-item" onclick="viewChat('{{ chat_id }}')">
<strong>ID Диалога:</strong> {{ chat_id }}
<br>
<small>Сообщений: {{ chat_data|length }} | Последнее сообщение: {{ chat_data[-1].timestamp if chat_data and 'timestamp' in chat_data[-1] else 'N/A' }}</small>
</div>
{% endfor %}
{% else %}
<p>Пока не было ни одного диалога.</p>
{% endif %}
</div>
</div>
</details>
</div>
<div class="flex-container">
<div class="flex-item">
<div class="section">
<h2><i class="fas fa-tags"></i> Управление категориями</h2>
<details>
<summary><i class="fas fa-plus-circle"></i> Добавить новую категорию</summary>
<div class="form-content">
<form method="POST">
<input type="hidden" name="action" value="add_category">
<label for="add_category_name">Название новой категории:</label>
<input type="text" id="add_category_name" name="category_name" required>
<button type="submit" class="add-button"><i class="fas fa-plus"></i> Добавить</button>
</form>
</div>
</details>
<h3>Существующие категории:</h3>
{% if categories %}
<div class="item-list">
{% for category in categories %}
<div class="item" style="display: flex; justify-content: space-between; align-items: center;">
<span>{{ category }}</span>
<form method="POST" style="margin: 0;" onsubmit="return confirm('Вы уверены, что хотите удалить категорию \'{{ category }}\'? Товары этой категории будут помечены как \'Без категории\'.');">
<input type="hidden" name="action" value="delete_category">
<input type="hidden" name="category_name" value="{{ category }}">
<button type="submit" class="delete-button" style="padding: 5px 10px; font-size: 0.8rem; margin: 0;"><i class="fas fa-trash-alt"></i></button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<p>Категорий пока нет.</p>
{% endif %}
</div>
</div>
<div class="flex-item">
<div class="section">
<h2><i class="fas fa-info-circle"></i> Информация о магазине</h2>
<details>
<summary><i class="fas fa-chevron-down"></i> Развернуть/Свернуть</summary>
<div class="form-content">
<form method="POST">
<input type="hidden" name="action" value="update_org_info">
<label for="about_us">О нас (для ИИ-ассистента):</label>
<textarea id="about_us" name="about_us" rows="4">{{ organization_info.get('about_us', '') }}</textarea>
<label for="shipping">Информация о доставке (для ИИ-ассистента):</label>
<textarea id="shipping" name="shipping" rows="4">{{ organization_info.get('shipping', '') }}</textarea>
<label for="returns">Информация о возврате/обмене (для ИИ-ассистента):</label>
<textarea id="returns" name="returns" rows="4">{{ organization_info.get('returns', '') }}</textarea>
<label for="contact">Контактная информация (для ИИ-ассистента):</label>
<textarea id="contact" name="contact" rows="4">{{ organization_info.get('contact', '') }}</textarea>
<button type="submit" class="add-button"><i class="fas fa-save"></i> Сохранить</button>
</form>
<p style="font-size: 0.85rem; color: #999;">Эта информация будет использоваться ИИ-ассистентом для ответов на вопросы о вашем магазине.</p>
</div>
</details>
</div>
</div>
</div>
<div class="section">
<h2><i class="fas fa-box-open"></i> Управление товарами</h2>
<details>
<summary><i class="fas fa-plus-circle"></i> Добавить новый товар</summary>
<div class="form-content">
<form id="add-product-form" method="POST" enctype="multipart/form-data">
<input type="hidden" name="action" value="add_product">
<label for="add_name">Название товара *:</label>
<input type="text" id="add_name" name="name" required>
{% if settings.get('business_type') != 'wholesale' %}
<label for="add_price">Розничная Цена ({{ currency_code }}) *:</label>
<input type="number" id="add_price" name="price" step="0.01" min="0" required>
{% endif %}
{% if settings.get('business_type') != 'retail' %}
<label for="add_wholesale_price">Оптовая Цена ({{ currency_code }}) {% if settings.get('business_type') == 'wholesale' %}*{% endif %}:</label>
<input type="number" id="add_wholesale_price" name="wholesale_price" step="0.01" min="0" {% if settings.get('business_type') == 'wholesale' %}required{% endif %}>
<label for="add_min_wholesale_quantity">Мин. кол-во для опта (шт.) {% if settings.get('business_type') == 'wholesale' %}*{% endif %}:</label>
<input type="number" id="add_min_wholesale_quantity" name="min_wholesale_quantity" min="1" {% if settings.get('business_type') == 'wholesale' %}required{% endif %}>
{% endif %}
<label for="add_photos">Фотографии (до 10 шт.):</label>
<input type="file" id="add_photos" name="photos" accept="image/*" multiple>
<label for="add_description">Описание:</label>
<textarea id="add_description" name="description" rows="4"></textarea>
<button type="button" class="button ai-generate-button" onclick="generateDescription('add_photos', 'add_description', 'add_gen_lang')"><i class="fas fa-magic"></i> Сгенерировать описание</button>
<label for="add_gen_lang">Язык генерации:</label>
<select id="add_gen_lang" name="gen_lang" style="width: auto; display: inline-block; margin-left: 10px;">
<option value="Русский">Русский</option>
<option value="Кыргызский">Кыргызский</option>
<option value="Казахский">Казахский</option>
<option value="Узбекский">Узбекский</option>
</select>
<label for="add_category">Категория:</label>
<select id="add_category" name="category">
<option value="Без категории">Без категории</option>
{% for category in categories %}
<option value="{{ category }}">{{ category }}</option>
{% endfor %}
</select>
<label>Цвета/Варианты (оставьте пустым, если нет):</label>
<div id="add-color-inputs" class="option-inputs" data-type="color">
<div class="option-input-group">
<input type="text" name="colors" placeholder="Например: Розовый">
<button type="button" class="remove-option-btn" onclick="removeOptionInput(this)"><i class="fas fa-times"></i></button>
</div>
</div>
<button type="button" class="button add-option-btn" style="margin-top: 5px;" onclick="addOptionInput('add-color-inputs', 'colors')"><i class="fas fa-palette"></i> Добавить поле</button>
<label style="margin-top: 15px;">Размеры/Объем (оставьте пустым, если нет):</label>
<div id="add-size-inputs" class="option-inputs" data-type="size">
<div class="option-input-group">
<input type="text" name="sizes" placeholder="Например: 42 или 50ml">
<button type="button" class="remove-option-btn" onclick="removeOptionInput(this)"><i class="fas fa-times"></i></button>
</div>
</div>
<button type="button" class="button add-option-btn" style="margin-top: 5px;" onclick="addOptionInput('add-size-inputs', 'sizes')"><i class="fas fa-ruler-combined"></i> Добавить поле</button>
<h3 style="margin-top: 20px;">Особые цены для вариантов</h3>
<p style="font-size:0.85em; color:#666;">Здесь можно указать цену для конкретного сочетания цвета и размера. Если цена не указана, будет использоваться базовая.</p>
<div id="add-variant-prices-container" class="variant-price-grid"></div>
<br>
<div style="margin-top: 15px;">
<input type="checkbox" id="add_in_stock" name="in_stock" checked>
<label for="add_in_stock" class="inline-label">В наличии</label>
</div>
<div style="margin-top: 5px;">
<input type="checkbox" id="add_is_top" name="is_top">
<label for="add_is_top" class="inline-label">Топ товар (показывать наверху)</label>
</div>
<br>
<button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Добавить товар</button>
</form>
</div>
</details>
<h3>Список товаров:</h3>
{% if products %}
<div class="item-list">
{% for product in products %}
<div class="item">
<div style="display: flex; gap: 15px; align-items: flex-start;">
<div class="photo-preview" style="flex-shrink: 0;">
{% if product.get('photos') %}
<a href="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" target="_blank" title="Посмотреть первое фото">
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" alt="Фото">
</a>
{% else %}
<img src="https://via.placeholder.com/70x70.png?text=N/A" alt="Нет фото">
{% endif %}
</div>
<div style="flex-grow: 1;">
<h3 style="margin-top: 0; margin-bottom: 5px; color: var(--text-dark);">
{{ product['name'] }}
{% if product.get('in_stock', True) %}
<span class="status-indicator in-stock">В наличии</span>
{% else %}
<span class="status-indicator out-of-stock">Нет в наличии</span>
{% endif %}
{% if product.get('is_top', False) %}
<span class="status-indicator top-product"><i class="fas fa-star"></i> Топ</span>
{% endif %}
</h3>
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
{% if settings.get('business_type') != 'wholesale' %}
<p><strong>Цена:</strong> {{ "%.2f"|format(product.price) }} {{ currency_code }}</p>
{% endif %}
{% if settings.get('business_type') != 'retail' and product.get('wholesale_price') and product.get('min_wholesale_quantity') %}
<p><strong>Опт. цена:</strong> {{ "%.2f"|format(product.wholesale_price) }} {{ currency_code }} (от {{ product.min_wholesale_quantity }} шт.)</p>
{% endif %}
<p class="description" title="{{ product.get('description', '') }}"><strong>Описание:</strong> {{ product.get('description', 'N/A')[:150] }}{% if product.get('description', '')|length > 150 %}...{% endif %}</p>
{% set colors = product.get('colors', []) %}
{% set sizes = product.get('sizes', []) %}
<p><strong>Цвета/Вар-ты:</strong> {{ colors|select('ne', '')|join(', ') if colors|select('ne', '')|list|length > 0 else 'Нет' }}</p>
<p><strong>Размеры/Объем:</strong> {{ sizes|select('ne', '')|join(', ') if sizes|select('ne', '')|list|length > 0 else 'Нет' }}</p>
{% if product.get('photos') and product['photos']|length > 1 %}
<p style="font-size: 0.8rem; color: #999;">(Всего фото: {{ product['photos']|length }})</p>
{% endif %}
</div>
</div>
<div class="item-actions">
<button type="button" class="button" onclick="toggleEditForm('edit-form-{{ loop.index0 }}')"><i class="fas fa-edit"></i> Редактировать</button>
<form method="POST" style="margin:0;" onsubmit="return confirm('Вы уверены, что хотите удалить товар \'{{ product['name'] }}\'?');">
<input type="hidden" name="action" value="delete_product">
<input type="hidden" name="product_id" value="{{ product.get('product_id', '') }}">
<button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button>
</form>
</div>
<div id="edit-form-{{ loop.index0 }}" class="edit-form-container">
<h4><i class="fas fa-edit"></i> Редактирование: {{ product['name'] }}</h4>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="action" value="edit_product">
<input type="hidden" name="product_id" value="{{ product.get('product_id', '') }}">
<label>Название *:</label>
<input type="text" name="name" value="{{ product['name'] }}" required>
{% if settings.get('business_type') != 'wholesale' %}
<label>Розничная Цена ({{ currency_code }}) *:</label>
<input type="number" name="price" step="0.01" min="0" value="{{ product['price'] }}" required>
{% endif %}
{% if settings.get('business_type') != 'retail' %}
<label>Оптовая Цена ({{ currency_code }}) {% if settings.get('business_type') == 'wholesale' %}*{% endif %}:</label>
<input type="number" name="wholesale_price" step="0.01" min="0" value="{{ product.get('wholesale_price', '') }}" {% if settings.get('business_type') == 'wholesale' %}required{% endif %}>
<label>Мин. кол-во для опта (шт.) {% if settings.get('business_type') == 'wholesale' %}*{% endif %}:</label>
<input type="number" name="min_wholesale_quantity" min="1" value="{{ product.get('min_wholesale_quantity', '') }}" {% if settings.get('business_type') == 'wholesale' %}required{% endif %}>
{% endif %}
<label>Заменить фотографии (выберите новые файлы, до 10 шт.):</label>
<input type="file" id="edit_photos_{{ loop.index0 }}" name="photos" accept="image/*" multiple>
{% if product.get('photos') %}
<p style="font-size: 0.85rem; margin-top: 5px;">Текущие фото:</p>
<div class="photo-preview">
{% for photo in product['photos'] %}
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}" alt="Фото {{ loop.index }}">
{% endfor %}
</div>
{% endif %}
<label>Описание:</label>
<textarea id="edit_description_{{ loop.index0 }}" name="description" rows="4">{{ product.get('description', '') }}</textarea>
<button type="button" class="button ai-generate-button" onclick="generateDescription('edit_photos_{{ loop.index0 }}', 'edit_description_{{ loop.index0 }}', 'edit_gen_lang_{{ loop.index0 }}')"><i class="fas fa-magic"></i> Сгенерировать</button>
<label for="edit_gen_lang_{{ loop.index0 }}">Язык генерации:</label>
<select id="edit_gen_lang_{{ loop.index0 }}" name="gen_lang" style="width: auto; display: inline-block; margin-left: 10px;">
<option value="Русский">Русский</option>
<option value="Кыргызский">Кыргызский</option>
<option value="Казахский">Казахский</option>
<option value="Узбекский">Узбекский</option>
</select>
<label>Категория:</label>
<select name="category">
<option value="Без категории" {% if product.get('category', 'Без категории') == 'Без категории' %}selected{% endif %}>Без категории</option>
{% for category in categories %}
<option value="{{ category }}" {% if product.get('category') == category %}selected{% endif %}>{{ category }}</option>
{% endfor %}
</select>
<label>Цвета/Варианты:</label>
<div id="edit-color-inputs-{{ loop.index0 }}" class="option-inputs" data-type="color">
{% set current_colors = product.get('colors', []) %}
{% if current_colors and current_colors|select('ne', '')|list|length > 0 %}
{% for color in current_colors %}
{% if color.strip() %}
<div class="option-input-group">
<input type="text" name="colors" value="{{ color }}">
<button type="button" class="remove-option-btn" onclick="removeOptionInput(this)"><i class="fas fa-times"></i></button>
</div>
{% endif %}
{% endfor %}
{% else %}
<div class="option-input-group">
<input type="text" name="colors" placeholder="Например: Цвет">
<button type="button" class="remove-option-btn" onclick="removeOptionInput(this)"><i class="fas fa-times"></i></button>
</div>
{% endif %}
</div>
<button type="button" class="button add-option-btn" style="margin-top: 5px;" onclick="addOptionInput('edit-color-inputs-{{ loop.index0 }}', 'colors')"><i class="fas fa-palette"></i> Добавить поле</button>
<label style="margin-top: 15px;">Размеры/Объем:</label>
<div id="edit-size-inputs-{{ loop.index0 }}" class="option-inputs" data-type="size">
{% set current_sizes = product.get('sizes', []) %}
{% if current_sizes and current_sizes|select('ne', '')|list|length > 0 %}
{% for size in current_sizes %}
{% if size.strip() %}
<div class="option-input-group">
<input type="text" name="sizes" value="{{ size }}">
<button type="button" class="remove-option-btn" onclick="removeOptionInput(this)"><i class="fas fa-times"></i></button>
</div>
{% endif %}
{% endfor %}
{% else %}
<div class="option-input-group">
<input type="text" name="sizes" placeholder="Например: L">
<button type="button" class="remove-option-btn" onclick="removeOptionInput(this)"><i class="fas fa-times"></i></button>
</div>
{% endif %}
</div>
<button type="button" class="button add-option-btn" style="margin-top: 5px;" onclick="addOptionInput('edit-size-inputs-{{ loop.index0 }}', 'sizes')"><i class="fas fa-ruler-combined"></i> Добавить поле</button>
<h3 style="margin-top: 20px;">Особые цены для вариантов</h3>
<div id="edit-variant-prices-container-{{ loop.index0 }}" class="variant-price-grid"></div>
<br>
<div style="margin-top: 15px;">
<input type="checkbox" id="edit_in_stock_{{ loop.index0 }}" name="in_stock" {% if product.get('in_stock', True) %}checked{% endif %}>
<label for="edit_in_stock_{{ loop.index0 }}" class="inline-label">В наличии</label>
</div>
<div style="margin-top: 5px;">
<input type="checkbox" id="edit_is_top_{{ loop.index0 }}" name="is_top" {% if product.get('is_top', False) %}checked{% endif %}>
<label for="edit_is_top_{{ loop.index0 }}" class="inline-label">Топ товар</label>
</div>
<br>
<button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Сохранить</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p>Товаров пока нет.</p>
{% endif %}
</div>
</div>
<div id="chat-modal" class="modal">
<div class="modal-content">
<span class="button delete-button" style="float:right;" onclick="closeModal('chat-modal')">&times;</span>
<h2 id="chat-modal-title">Просмотр диалога</h2>
<div id="chat-modal-body" class="chat-message-viewer"></div>
</div>
</div>
<script>
const allProductsForAdmin = {{ products|tojson|safe }};
function toggleEditForm(formId) {
const formContainer = document.getElementById(formId);
if (formContainer) {
const isOpening = formContainer.style.display === 'none' || formContainer.style.display === '';
formContainer.style.display = isOpening ? 'block' : 'none';
if (isOpening) {
const index = parseInt(formId.split('-').pop());
const product = allProductsForAdmin[index];
const priceContainer = document.getElementById(`edit-variant-prices-container-${index}`);
const colorContainer = document.getElementById(`edit-color-inputs-${index}`);
const sizeContainer = document.getElementById(`edit-size-inputs-${index}`);
const updateVariantPrices = () => generateVariantPriceInputs(colorContainer, sizeContainer, priceContainer, product.variant_prices || {});
colorContainer.addEventListener('input', updateVariantPrices);
sizeContainer.addEventListener('input', updateVariantPrices);
updateVariantPrices();
}
}
}
function generateVariantPriceInputs(colorContainer, sizeContainer, priceContainer, existingPrices) {
priceContainer.innerHTML = '';
const colors = Array.from(colorContainer.querySelectorAll('input[name="colors"]')).map(i => i.value.trim()).filter(Boolean);
const sizes = Array.from(sizeContainer.querySelectorAll('input[name="sizes"]')).map(i => i.value.trim()).filter(Boolean);
if (colors.length === 0 && sizes.length > 0) {
sizes.forEach(size => {
const key = size;
const price = existingPrices[key] || '';
priceContainer.innerHTML += `
<div class="variant-price-item">
<label for="variant_price_${key}">${size}:</label>
<input type="number" step="0.01" name="variant_price_${key}" value="${price}" placeholder="Базовая цена">
</div>`;
});
} else if (colors.length > 0 && sizes.length === 0) {
colors.forEach(color => {
const key = color;
const price = existingPrices[key] || '';
priceContainer.innerHTML += `
<div class="variant-price-item">
<label for="variant_price_${key}">${color}:</label>
<input type="number" step="0.01" name="variant_price_${key}" value="${price}" placeholder="Базовая цена">
</div>`;
});
} else if (colors.length > 0 && sizes.length > 0) {
colors.forEach(color => {
sizes.forEach(size => {
const key = `${color}-${size}`;
const price = existingPrices[key] || '';
priceContainer.innerHTML += `
<div class="variant-price-item">
<label for="variant_price_${key}">${color} - ${size}:</label>
<input type="number" step="0.01" name="variant_price_${key}" value="${price}" placeholder="Базовая цена">
</div>`;
});
});
}
}
function addOptionInput(containerId, name) {
const container = document.getElementById(containerId);
if (container) {
const newInputGroup = document.createElement('div');
newInputGroup.className = 'option-input-group';
newInputGroup.innerHTML = `
<input type="text" name="${name}" placeholder="Новый вариант">
<button type="button" class="remove-option-btn" onclick="removeOptionInput(this)"><i class="fas fa-times"></i></button>
`;
container.appendChild(newInputGroup);
newInputGroup.querySelector(`input[name="${name}"]`).focus();
}
}
function removeOptionInput(button) {
const group = button.closest('.option-input-group');
if (group) {
const container = group.parentNode;
group.remove();
if (container && container.children.length === 0) {
const name = container.dataset.type === 'color' ? 'colors' : 'sizes';
const placeholder = container.dataset.type === 'color' ? 'Например: Цвет' : 'Например: L';
addOptionInput(container.id, name, placeholder);
}
}
}
document.addEventListener('DOMContentLoaded', () => {
const addForm = document.getElementById('add-product-form');
const addColorContainer = document.getElementById('add-color-inputs');
const addSizeContainer = document.getElementById('add-size-inputs');
const addPriceContainer = document.getElementById('add-variant-prices-container');
const updateAddFormPrices = () => generateVariantPriceInputs(addColorContainer, addSizeContainer, addPriceContainer, {});
addColorContainer.addEventListener('input', updateAddFormPrices);
addSizeContainer.addEventListener('input', updateAddFormPrices);
});
async function generateDescription(photoInputId, descriptionTextareaId, languageSelectId) {
const photoInput = document.getElementById(photoInputId);
const descriptionTextarea = document.getElementById(descriptionTextareaId);
const languageSelect = document.getElementById(languageSelectId);
const generateButton = descriptionTextarea.nextElementSibling;
if (!photoInput || !descriptionTextarea || !languageSelect || !generateButton) {
alert("Ошибка: Не найдены элементы формы для генерации.");
return;
}
if (!photoInput.files || photoInput.files.length === 0) {
alert("Пожалуйста, сначала загрузите фотографию товара.");
return;
}
const file = photoInput.files[0];
if (!file.type.startsWith('image/')) {
alert("Выбранный файл не является изображением.");
return;
}
generateButton.disabled = true;
const originalText = generateButton.innerHTML;
generateButton.innerHTML = '<i class="fas fa-sync fa-spin"></i> Генерация...';
descriptionTextarea.value = 'Генерация описания, пожалуйста подождите...';
descriptionTextarea.disabled = true;
const reader = new FileReader();
reader.onload = async (e) => {
const base64Image = e.target.result.split(',')[1];
const language = languageSelect.value;
try {
const response = await fetch('/generate_description_ai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: base64Image, language: language })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || `Ошибка сервера: ${response.status}`);
}
descriptionTextarea.value = result.text;
} catch (error) {
console.error("AI Generation Error:", error);
descriptionTextarea.value = `Ошибка генерации: ${error.message}`;
alert(`Ошибка генерации: ${error.message}`);
} finally {
generateButton.disabled = false;
generateButton.innerHTML = originalText;
descriptionTextarea.disabled = false;
}
};
reader.onerror = (e) => {
console.error("FileReader error:", e);
descriptionTextarea.value = "Ошибка чтения файла изображения.";
generateButton.disabled = false;
generateButton.innerHTML = originalText;
descriptionTextarea.disabled = false;
};
reader.readAsDataURL(file);
}
function openModal(modalId) {
document.getElementById(modalId).style.display = 'block';
}
function closeModal(modalId) {
document.getElementById(modalId).style.display = 'none';
}
async function viewChat(chatId) {
const modalBody = document.getElementById('chat-modal-body');
modalBody.innerHTML = 'Загрузка...';
openModal('chat-modal');
try {
const response = await fetch(`/${env_id}/get_chat/${chatId}`);
if(!response.ok) throw new Error('Chat not found');
const chatHistory = await response.json();
modalBody.innerHTML = '';
chatHistory.forEach(msg => {
const msgDiv = document.createElement('div');
msgDiv.className = `chat-message ${msg.role}`;
const bubble = document.createElement('div');
bubble.className = 'bubble';
bubble.innerText = msg.text;
msgDiv.appendChild(bubble);
modalBody.appendChild(msgDiv);
});
} catch (e) {
modalBody.innerHTML = 'Не удалось загрузить диалог.';
}
}
window.onclick = function(event) {
if (event.target.classList.contains('modal')) {
closeModal(event.target.id);
}
}
</script>
</body>
</html>
'''
@app.route('/')
def index():
return render_template_string(LANDING_PAGE_TEMPLATE)
@app.route('/admhosto', methods=['GET'])
def admhosto():
data = load_data()
environments_data = []
for env_id, env_data in data.items():
settings = env_data.get('settings', {})
is_active = False
expires_soon = False
expires_date_str = "N/A"
if settings.get('chat_activated', False):
expires_str = settings.get('chat_activation_expires')
if expires_str:
try:
expires_dt = datetime.fromisoformat(expires_str)
now_almaty = datetime.now(ALMATY_TZ)
if expires_dt > now_almaty:
is_active = True
expires_date_str = expires_dt.strftime('%Y-%m-%d')
if (expires_dt - now_almaty).days <= 4:
expires_soon = True
except (ValueError, TypeError):
pass
environments_data.append({
"id": env_id,
"chat_active": is_active,
"expires_soon": expires_soon,
"expires_date": expires_date_str
})
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': [],
'orders': {},
'organization_info': {
"about_us": "Мы — Gippo312, ваш надежный партнер в мире уникальных товаров.",
"shipping": "Доставка осуществляется по всему Кыргызстану.",
"returns": "Возврат и обмен товара возможен в течение 14 дней.",
"contact": "Наш магазин находится по адресу: ... Связаться с нами можно по телефону ..."
},
'settings': {
"organization_name": "Gippo312",
"whatsapp_number": "+996701202013",
"currency_code": "KGS",
"chat_name": "EVA",
"chat_avatar": None,
"color_scheme": "default",
"chat_activated": False,
"chat_activation_expires": None,
"business_type": "combined"
},
'chats': {}
}
save_data(all_data)
flash(f'Новая среда с ID {new_id} успешно создана.', 'success')
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('/admhosto/activate/<env_id>', methods=['POST'])
def activate_chat(env_id):
period = request.form.get('period')
if not period:
flash('Не выбран период активации.', 'error')
return redirect(url_for('admhosto'))
delta = None
if period == 'month':
delta = timedelta(days=30)
elif period == 'half_year':
delta = timedelta(days=182)
elif period == 'year':
delta = timedelta(days=365)
if not delta:
flash('Неверный период активации.', 'error')
return redirect(url_for('admhosto'))
data = get_env_data(env_id)
if not data:
flash(f'Среда {env_id} не найдена.', 'error')
return redirect(url_for('admhosto'))
settings = data.get('settings', {})
now_almaty = datetime.now(ALMATY_TZ)
start_date = now_almaty
expires_str = settings.get('chat_activation_expires')
if expires_str:
try:
current_expires_dt = datetime.fromisoformat(expires_str)
if current_expires_dt > now_almaty:
start_date = current_expires_dt
except (ValueError, TypeError):
pass
new_expires_date = start_date + delta
settings['chat_activated'] = True
settings['chat_activation_expires'] = new_expires_date.isoformat()
data['settings'] = settings
save_env_data(env_id, data)
flash(f'Чат для среды {env_id} активирован/продлен до {new_expires_date.strftime("%Y-%m-%d")}.', 'success')
return redirect(url_for('admhosto'))
@app.route('/<env_id>/catalog')
def catalog(env_id):
data = get_env_data(env_id)
all_products_raw = data.get('products', [])
settings = data.get('settings', {})
product_categories = set(p.get('category', 'Без категории') for p in all_products_raw)
admin_categories = set(data.get('categories', []))
all_cat_names = sorted(list(product_categories.union(admin_categories)))
products_in_stock = [p for p in all_products_raw if p.get('in_stock', True)]
products_sorted_for_js = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
products_by_category = {cat: [] for cat in all_cat_names}
for product in products_in_stock:
products_by_category[product.get('category', 'Без категории')].append(product)
for category in products_by_category:
products_by_category[category].sort(key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
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"
chat_active = is_chat_active(env_id)
return render_template_string(
CATALOG_TEMPLATE,
products_by_category=products_by_category,
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,
chat_is_active=chat_active
)
@app.route('/<env_id>/product/<int:index>')
def product_detail(env_id, index):
data = get_env_data(env_id)
all_products_raw = data.get('products', [])
settings = data.get('settings', {})
products_in_stock = [p for p in all_products_raw if p.get('in_stock', True)]
products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
try:
product = products_sorted[index]
except IndexError:
return "Товар не найден или отсутствует в наличии.", 404
return render_template_string(
PRODUCT_DETAIL_TEMPLATE,
product=product,
repo_id=REPO_ID,
currency_code=settings.get('currency_code', 'KGS'),
settings=settings,
env_id=env_id
)
@app.route('/<env_id>/create_order', methods=['POST'])
def create_order(env_id):
order_data = request.get_json()
if not order_data or 'cart' not in order_data or not order_data['cart']:
return jsonify({"error": "Корзина пуста или не передана."}), 400
cart_items = order_data['cart']
total_price = 0
processed_cart = []
for item in cart_items:
if not all(k in item for k in ('name', 'price', 'quantity')):
return jsonify({"error": "Неверный формат товара в корзине."}), 400
try:
price = float(item['price'])
quantity = int(item['quantity'])
if price < 0 or quantity <= 0:
raise ValueError("Invalid price or quantity")
processed_cart.append({
"product_id": item.get('product_id', 'N/A'),
"name": item['name'],
"price": price,
"quantity": quantity,
"color": item.get('color', 'N/A'),
"size": item.get('size', 'N/A'),
"photo": item.get('photo'),
"photo_url": f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{item['photo']}" if item.get('photo') else "https://via.placeholder.com/60x60.png?text=N/A"
})
total_price += price * quantity
except (ValueError, TypeError) as e:
return jsonify({"error": "Неверная цена или количество в товаре."}), 400
order_id = f"{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6]}"
order_timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
new_order = {
"id": order_id,
"created_at": order_timestamp,
"cart": processed_cart,
"total_price": round(total_price, 2),
"user_info": None,
"status": "new"
}
try:
data = get_env_data(env_id)
if 'orders' not in data or not isinstance(data.get('orders'), dict):
data['orders'] = {}
data['orders'][order_id] = new_order
save_env_data(env_id, data)
return jsonify({"order_id": order_id}), 201
except Exception as e:
return jsonify({"error": "Ошибка сервера при сохранении заказа."}), 500
@app.route('/<env_id>/order/<order_id>')
def view_order(env_id, order_id):
data = get_env_data(env_id)
order = data.get('orders', {}).get(order_id)
settings = data.get('settings', {})
return render_template_string(ORDER_TEMPLATE,
order=order,
repo_id=REPO_ID,
currency_code=settings.get('currency_code', 'KGS'),
whatsapp_number=settings.get('whatsapp_number', ''),
settings=settings,
env_id=env_id)
@app.route('/<env_id>/admin', methods=['GET', 'POST'])
def admin(env_id):
data = get_env_data(env_id)
products = data.get('products', [])
categories = data.get('categories', [])
organization_info = data.get('organization_info', {})
chats = data.get('chats', {})
settings = data.get('settings', {})
if 'orders' not in data or not isinstance(data.get('orders'), dict):
data['orders'] = {}
if request.method == 'POST':
action = request.form.get('action')
try:
if action == 'add_category':
category_name = request.form.get('category_name', '').strip()
if category_name and category_name not in categories:
categories.append(category_name)
data['categories'] = categories
save_env_data(env_id, data)
flash(f"Категория '{category_name}' успешно добавлена.", 'success')
elif not category_name:
flash("Название категории не может быть пустым.", 'error')
else:
flash(f"Категория '{category_name}' уже существует.", 'error')
elif action == 'delete_category':
category_to_delete = request.form.get('category_name')
if category_to_delete and category_to_delete in categories:
categories.remove(category_to_delete)
updated_count = 0
for product in products:
if product.get('category') == category_to_delete:
product['category'] = 'Без категории'
updated_count += 1
data['categories'] = categories
data['products'] = products
save_env_data(env_id, data)
flash(f"Категория '{category_to_delete}' удалена. {updated_count} товаров обновлено.", 'success')
else:
flash(f"Не удалось удалить категорию '{category_to_delete}'.", 'error')
elif action == 'update_org_info':
organization_info['about_us'] = request.form.get('about_us', '').strip()
organization_info['shipping'] = request.form.get('shipping', '').strip()
organization_info['returns'] = request.form.get('returns', '').strip()
organization_info['contact'] = request.form.get('contact', '').strip()
data['organization_info'] = organization_info
save_env_data(env_id, data)
flash("Информация о магазине успешно обновлена.", 'success')
elif action == 'update_settings':
settings['organization_name'] = request.form.get('organization_name', 'Gippo312').strip()
settings['whatsapp_number'] = request.form.get('whatsapp_number', '').strip()
settings['currency_code'] = request.form.get('currency_code', 'KGS')
settings['business_type'] = request.form.get('business_type', 'combined')
settings['chat_name'] = request.form.get('chat_name', 'EVA').strip()
settings['color_scheme'] = request.form.get('color_scheme', 'default')
avatar_file = request.files.get('chat_avatar')
if avatar_file and avatar_file.filename:
if HF_TOKEN_WRITE:
try:
api = HfApi()
old_avatar = settings.get('chat_avatar')
if old_avatar:
try:
api.delete_files(repo_id=REPO_ID, paths_in_repo=[f"avatars/{old_avatar}"], repo_type="dataset", token=HF_TOKEN_WRITE)
except Exception:
pass
ext = os.path.splitext(avatar_file.filename)[1].lower()
avatar_filename = f"avatar_{env_id}_{int(time.time())}{ext}"
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
temp_path = os.path.join(uploads_dir, avatar_filename)
avatar_file.save(temp_path)
api.upload_file(
path_or_fileobj=temp_path,
path_in_repo=f"avatars/{avatar_filename}",
repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE
)
settings['chat_avatar'] = avatar_filename
os.remove(temp_path)
flash("Аватар чата успешно обновлен.", 'success')
except Exception as e:
flash(f"Ошибка при загрузке аватара: {e}", 'error')
else:
flash("HF_TOKEN (write) не настроен. Аватар не был загружен.", "warning")
data['settings'] = settings
save_env_data(env_id, data)
flash("Настройки магазина и чата успешно обновлены.", 'success')
elif action == 'add_product' or action == 'edit_product':
product_id = request.form.get('product_id')
product_data = {}
is_edit = action == 'edit_product'
if is_edit:
product_data = next((p for p in products if p.get('product_id') == product_id), None)
if not product_data:
flash(f"Ошибка: товар с ID {product_id} не найден.", 'error')
return redirect(url_for('admin', env_id=env_id))
product_data['name'] = request.form.get('name', '').strip()
price_str = request.form.get('price', '0').replace(',', '.')
product_data['description'] = request.form.get('description', '').strip()
category = request.form.get('category')
product_data['category'] = category if category in categories else 'Без категории'
product_data['colors'] = sorted(list(set(c.strip() for c in request.form.getlist('colors') if c.strip())))
product_data['sizes'] = sorted(list(set(s.strip() for s in request.form.getlist('sizes') if s.strip())))
product_data['in_stock'] = 'in_stock' in request.form
product_data['is_top'] = 'is_top' in request.form
w_price_str = request.form.get('wholesale_price', '').replace(',', '.')
w_quant_str = request.form.get('min_wholesale_quantity', '')
if settings.get('business_type') == 'wholesale':
if not w_price_str or not w_quant_str:
flash("Для оптового типа бизнеса, оптовая цена и минимальное количество обязательны.", 'error')
return redirect(url_for('admin', env_id=env_id))
product_data['price'] = 0
product_data['wholesale_price'] = float(w_price_str) if w_price_str else None
product_data['min_wholesale_quantity'] = int(w_quant_str) if w_quant_str else None
if not product_data['name']:
flash("Название товара обязательно.", 'error')
return redirect(url_for('admin', env_id=env_id))
try:
price = round(float(price_str), 2)
if price < 0: price = 0
product_data['price'] = price
except ValueError:
flash("Неверный формат цены.", 'error')
return redirect(url_for('admin', env_id=env_id))
variant_prices = {}
for key, value in request.form.items():
if key.startswith('variant_price_') and value:
variant_key = key.replace('variant_price_', '')
try:
variant_prices[variant_key] = round(float(value), 2)
except ValueError:
pass
product_data['variant_prices'] = variant_prices
photos_files = request.files.getlist('photos')
if photos_files and any(f.filename for f in photos_files):
if HF_TOKEN_WRITE:
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
api = HfApi()
new_photos_list = []
photo_limit = 10
uploaded_count = 0
for photo in photos_files:
if uploaded_count >= photo_limit:
flash(f"Загружено только первые {photo_limit} фото.", "warning")
break
if photo and photo.filename:
try:
ext = os.path.splitext(photo.filename)[1].lower()
if ext not in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
flash(f"Файл {photo.filename} пропущен (не изображение).", "warning")
continue
safe_name = secure_filename(product_data['name'].replace(' ', '_'))[:50]
photo_filename = f"{safe_name}_{uuid4().hex[:8]}{ext}"
temp_path = os.path.join(uploads_dir, photo_filename)
photo.save(temp_path)
api.upload_file(path_or_fileobj=temp_path, path_in_repo=f"photos/{photo_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
new_photos_list.append(photo_filename)
os.remove(temp_path)
uploaded_count += 1
except Exception as e:
flash(f"Ошибка при загрузке фото {photo.filename}: {e}", 'error')
if new_photos_list and is_edit and product_data.get('photos'):
try:
api.delete_files(repo_id=REPO_ID, paths_in_repo=[f"photos/{p}" for p in product_data['photos']], repo_type="dataset", token=HF_TOKEN_WRITE)
except Exception: pass
if new_photos_list:
product_data['photos'] = new_photos_list
else:
flash("HF_TOKEN (write) не настроен. Фотографии не были загружены.", "warning")
if is_edit:
product_index = next((i for i, p in enumerate(products) if p.get('product_id') == product_id), -1)
if product_index != -1:
products[product_index] = product_data
flash(f"Товар '{product_data['name']}' успешно обновлен.", 'success')
else:
product_data['product_id'] = uuid4().hex
products.append(product_data)
flash(f"Товар '{product_data['name']}' успешно добавлен.", 'success')
data['products'] = products
save_env_data(env_id, data)
elif action == 'delete_product':
product_id = request.form.get('product_id')
product_index = next((i for i, p in enumerate(products) if p.get('product_id') == product_id), -1)
if product_index == -1:
flash(f"Ошибка удаления: товар с ID '{product_id}' не найден.", 'error')
return redirect(url_for('admin', env_id=env_id))
deleted_product = products.pop(product_index)
product_name = deleted_product.get('name', 'N/A')
photos_to_delete = deleted_product.get('photos', [])
if photos_to_delete and HF_TOKEN_WRITE:
try:
api = HfApi()
api.delete_files(
repo_id=REPO_ID,
paths_in_repo=[f"photos/{p}" for p in photos_to_delete],
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Delete photos for deleted product {product_name}"
)
except Exception as e:
flash(f"Не удалось удалить фото для товара '{product_name}' с сервера. Товар удален локально.", "warning")
elif photos_to_delete and not HF_TOKEN_WRITE:
flash(f"Товар '{product_name}' удален локально, но фото не удалены с сервера (токен не задан).", "warning")
data['products'] = products
save_env_data(env_id, data)
flash(f"Товар '{product_name}' удален.", 'success')
else:
flash(f"Неизвестное действие: {action}", 'warning')
return redirect(url_for('admin', env_id=env_id))
except Exception as e:
flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
return redirect(url_for('admin', env_id=env_id))
display_products = sorted(data.get('products', []), key=lambda p: p.get('name', '').lower())
display_categories = sorted(data.get('categories', []))
display_organization_info = data.get('organization_info', {})
display_chats = data.get('chats', {})
display_settings = data.get('settings', {})
chat_status = { "active": False, "expires_soon": False, "expires_date": "N/A" }
if display_settings.get('chat_activated'):
expires_str = display_settings.get('chat_activation_expires')
if expires_str:
try:
expires_dt = datetime.fromisoformat(expires_str)
now_almaty = datetime.now(ALMATY_TZ)
if expires_dt > now_almaty:
chat_status["active"] = True
chat_status["expires_date"] = expires_dt.strftime('%Y-%m-%d %H:%M:%S')
if (expires_dt - now_almaty).days <= 4:
chat_status["expires_soon"] = True
else:
chat_status["expires_date"] = expires_dt.strftime('%Y-%m-%d %H:%M:%S')
except (ValueError, TypeError):
pass
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"
return render_template_string(
ADMIN_TEMPLATE,
products=display_products,
categories=display_categories,
organization_info=display_organization_info,
chats=display_chats,
settings=display_settings,
repo_id=REPO_ID,
currency_code=display_settings.get('currency_code', 'KGS'),
chat_avatar_url=chat_avatar_url,
currencies=CURRENCIES,
color_schemes=COLOR_SCHEMES,
env_id=env_id,
chat_status=chat_status,
whatsapp_is_active=whatsapp_active.is_set(),
whatsapp_env_id=whatsapp_env_id
)
@app.route('/generate_description_ai', methods=['POST'])
def handle_generate_description_ai():
request_data = request.get_json()
base64_image = request_data.get('image')
language = request_data.get('language', 'Русский')
if not base64_image:
return jsonify({"error": "Изображение не найдено в запросе."}), 400
try:
image_data = base64.b64decode(base64_image)
result_text = generate_ai_description_from_image(image_data, language)
return jsonify({"text": result_text})
except ValueError as ve:
return jsonify({"error": str(ve)}), 400
except Exception as e:
return jsonify({"error": f"Внутренняя ошибка сервера: {e}"}), 500
@app.route('/<env_id>/chat_with_ai', methods=['POST'])
def handle_chat_with_ai(env_id):
if not is_chat_active(env_id):
return jsonify({"error": "Чат неактивен."}), 403
request_data = request.get_json()
user_message = request_data.get('message')
chat_history_from_client = request_data.get('history', [])
chat_id = request_data.get('chat_id')
if not user_message:
return jsonify({"error": "Сообщение не может быть пустым."}), 400
if not chat_id:
return jsonify({"error": "ID чата не предоставлен."}), 400
try:
ai_response_text = generate_chat_response(user_message, chat_history_from_client, env_id)
data = get_env_data(env_id)
if 'chats' not in data:
data['chats'] = {}
if chat_id not in data['chats']:
data['chats'][chat_id] = []
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
data['chats'][chat_id].append({'role': 'user', 'text': user_message, 'timestamp': timestamp})
data['chats'][chat_id].append({'role': 'ai', 'text': ai_response_text, 'timestamp': timestamp})
save_env_data(env_id, data)
return jsonify({"text": ai_response_text})
except Exception as e:
return jsonify({"error": f"Ошибка чата: {e}"}), 500
@app.route('/<env_id>/chat')
def chat_page(env_id):
if not is_chat_active(env_id):
return "Чат для этой среды неактивен. Обратитесь к администратору.", 403
data = get_env_data(env_id)
all_products_raw = data.get('products', [])
settings = data.get('settings', {})
products_in_stock = [p for p in all_products_raw if p.get('in_stock', True)]
products_sorted_for_js = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
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(
CHAT_TEMPLATE,
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
)
@app.route('/<env_id>/get_chat/<chat_id>')
def get_chat_history(env_id, chat_id):
data = get_env_data(env_id)
chat_history = data.get('chats', {}).get(chat_id)
if chat_history:
return jsonify(chat_history)
else:
return jsonify({"error": "Chat not found"}), 404
@app.route('/<env_id>/force_upload', methods=['POST'])
def force_upload(env_id):
try:
upload_db_to_hf()
flash("Данные успешно загружены на Hugging Face.", 'success')
except Exception as e:
flash(f"Ошибка при загрузке на Hugging Face: {e}", 'error')
return redirect(url_for('admin', env_id=env_id))
@app.route('/<env_id>/force_download', methods=['POST'])
def force_download(env_id):
try:
if download_db_from_hf():
flash("Данные успешно скачаны с Hugging Face. Локальные файлы обновлены.", 'success')
else:
flash("Не удалось скачать данные с Hugging Face после нескольких попыток. Проверьте логи.", 'error')
except Exception as e:
flash(f"Ошибка при скачивании с Hugging Face: {e}", 'error')
return redirect(url_for('admin', env_id=env_id))
@app.route('/<env_id>/whatsapp/start')
def start_whatsapp_bot(env_id):
global whatsapp_thread
if whatsapp_thread and whatsapp_thread.is_alive():
flash('WhatsApp бот уже запущен.', 'warning')
else:
whatsapp_thread = threading.Thread(target=whatsapp_bot_thread, args=(env_id,), daemon=True)
whatsapp_thread.start()
flash('Запуск WhatsApp бота... Пожалуйста, будьте готовы отсканировать QR-код в новом окне браузера.', 'success')
return redirect(url_for('admin', env_id=env_id))
@app.route('/<env_id>/whatsapp/stop')
def stop_whatsapp_bot(env_id):
global driver, whatsapp_thread
if not (whatsapp_thread and whatsapp_thread.is_alive()):
flash('WhatsApp бот не был запущен.', 'warning')
else:
whatsapp_active.clear()
if driver:
try:
driver.quit()
except:
pass
flash('WhatsApp бот остановлен.', 'success')
return redirect(url_for('admin', env_id=env_id))
if __name__ == '__main__':
configure_gemini()
download_db_from_hf()
load_data()
if HF_TOKEN_WRITE:
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
backup_thread.start()
else:
pass
port = int(os.environ.get('PORT', 7860))
app.run(debug=False, host='0.0.0.0', port=port)