admin / app.py
Kgshop's picture
Update app.py
8fc1afc verified
import os
import io
import base64
import json
import logging
import threading
import time
from datetime import datetime
from uuid import uuid4
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
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/gippobase"
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
STORE_ADDRESS = "Рынок Кербент, 6 ряд , 43 контейнер "
WHATSAPP_NUMBER = "+996701202013"
CURRENCY_CODE = 'KGS'
CURRENCY_NAME = 'Кыргызский сом'
DOWNLOAD_RETRIES = 3
DOWNLOAD_DELAY = 5
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
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({'products': [], 'categories': [], 'orders': {}, 'organization_info': {}}, 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():
default_organization_info = {
"about_us": "Мы — Gippo312, ваш надежный партнер в мире уникальных товаров. Мы предлагаем широкий ассортимент продукции, от электроники до товаров для дома, всегда стремясь к качеству и доступности. Наша миссия — сделать ваш шопинг приятным и удобным, предлагая только лучшие товары, тщательно отобранные для вас.",
"shipping": "Доставка осуществляется по всему Кыргызстану. Стоимость и сроки доставки зависят от региона и веса товара. По Бишкеку доставка возможна в течение 1-2 рабочих дней, в регионы — от 3 до 7 дней. Для уточнения деталей свяжитесь с нами.",
"returns": "Возврат и обмен товара возможен в течение 14 дней с момента покупки, при условии сохранения товарного вида, упаковки и чека. Некоторые категории товаров могут иметь особые условия возврата. Пожалуйста, свяжитесь с нами для оформления возврата или обмена.",
"contact": f"Наш магазин находится по адресу: {STORE_ADDRESS}. Связаться с нами можно по телефону: {WHATSAPP_NUMBER} или через WhatsApp по этому же номеру. Мы работаем ежедневно с 9:00 до 18:00."
}
default_data = {'products': [], 'categories': [], 'orders': {}, 'organization_info': default_organization_info}
data = default_data
try:
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
if not isinstance(data, dict):
raise FileNotFoundError
if 'products' not in data: data['products'] = []
if 'categories' not in data: data['categories'] = []
if 'orders' not in data: data['orders'] = {}
if 'organization_info' not in data: data['organization_info'] = default_organization_info
except FileNotFoundError:
if download_db_from_hf(specific_file=DATA_FILE):
try:
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
if not isinstance(data, dict):
data = default_data
if 'products' not in data: data['products'] = []
if 'categories' not in data: data['categories'] = []
if 'orders' not in data: data['orders'] = {}
if 'organization_info' not in data: data['organization_info'] = default_organization_info
except (FileNotFoundError, json.JSONDecodeError, Exception) as e:
data = default_data
else:
data = default_data
except json.JSONDecodeError:
if download_db_from_hf(specific_file=DATA_FILE):
try:
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
if not isinstance(data, dict):
data = default_data
if 'products' not in data: data['products'] = []
if 'categories' not in data: data['categories'] = []
if 'orders' not in data: data['orders'] = {}
if 'organization_info' not in data: data['organization_info'] = default_organization_info
except (FileNotFoundError, json.JSONDecodeError, Exception) as e:
data = default_data
else:
data = default_data
except Exception as e:
data = default_data
for product in data['products']:
if 'product_id' not in product:
product['product_id'] = uuid4().hex
if any('product_id' not in p for p in data['products']):
save_data(data)
if not os.path.exists(DATA_FILE):
try:
with open(DATA_FILE, 'w', encoding='utf-8') as f:
json.dump(default_data, f)
except Exception as create_e:
pass
return data
def save_data(data):
try:
if not isinstance(data, dict):
return
if 'products' not in data: data['products'] = []
if 'categories' not in data: data['categories'] = []
if 'orders' not in data: data['orders'] = {}
if 'organization_info' not in data: data['organization_info'] = {}
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 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):
if not configure_gemini():
return "Извините, сервис чата временно недоступен. Пожалуйста, попробуйте позже."
data = load_data()
products = data.get('products', [])
categories = data.get('categories', [])
organization_info = data.get('organization_info', {})
product_info_list = []
for p in products:
if p.get('in_stock', True):
price_display = f"{p.get('price', 0):.2f}".replace('.00', '')
product_info_list.append(f"- [ID_ТОВАРА: {p.get('product_id', 'N/A')} Название: {p.get('name', 'Без названия')}], Категория: {p.get('category', 'Без категории')}, Цена: {price_display} {CURRENCY_CODE}, Описание: {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"
system_instruction_content = (
"Ты - доброжелательный и очень полезный виртуальный консультант для магазина Gippo312. "
"Твоя задача - помогать пользователям находить товары, отвечать на вопросы о них, предлагать варианты, а также предоставлять информацию о магазине. "
"Всегда будь вежлив, информативен и стремись решить проблему пользователя. "
"Никогда не выдумывай товары или категории, которых нет в предоставленных списках. "
"Когда ты предлагаешь товар, всегда указывай его название и ID, используя *точный формат*: [ID_ТОВАРА: <product_id> Название: <product_name>]. Это *очень важно* для клиента. "
"Если пользователь ищет товар или категорию, предлагай несколько наиболее подходящих вариантов или перечисляй доступные из этой категории.\n\n"
f"Список доступных категорий: {category_list_str}.\n\n"
f"Список доступных товаров в магазине:\n"
f"{product_list_str}"
f"{org_info_str}\n\n"
"Если пользователь спрашивает про товары или категории, которых нет в списках, вежливо сообщи, что таких товаров/категорий нет и предложи что-то из имеющихся, или перечисли доступные категории. "
"Если вопрос касается общей информации о магазине (например, 'о нас', 'доставка', 'возврат', 'контакты'), используй данные из блока 'Информация о магазине'. "
"Старайся быть кратким, но информативным. Используй эмодзи для дружелюбности. "
"Избегай упоминания Hugging Face или Hugging Face Hub."
)
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}"
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>Gippo312 - Каталог</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>
:root {
--bg-dark: #003C43;
--bg-medium: #135D66;
--accent: #48D1CC;
--accent-hover: #77E4D8;
--text-light: #E3FEF7;
--text-dark: #333;
--danger: #E57373;
--danger-hover: #EF5350;
}
* { 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(--bg-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(--bg-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);
}
.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; }
@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 auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #e0e0e0; }
.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; }
.cart-item-details strong { display: block; margin-bottom: 5px; font-size: 1rem; color: var(--text-dark);}
.cart-item-price { font-size: 0.9rem; color: #666; }
.cart-item-quantity { display: flex; align-items: center; gap: 8px; grid-column: 3;}
.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; }
.cart-item-total { font-weight: bold; text-align: right; grid-column: 4; font-size: 1rem; color: var(--bg-medium);}
.cart-item-remove { grid-column: 5; 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, .color-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; }
.quantity-input:focus, .color-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; }
.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: white; 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; }
.clear-cart:hover { background-color: #5a6268; }
.formulate-order-button { background-color: var(--accent); color: var(--bg-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(--bg-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;}
#chatModal .modal-content { max-width: 450px; }
#chat-messages { height: 350px; overflow-y: auto; border: 1px solid #e0e0e0; border-radius: 8px; padding: 15px; margin-bottom: 15px; display: flex; flex-direction: column; gap: 10px; background-color: #fcfcfc; }
.chat-message { padding: 10px 15px; border-radius: 15px; max-width: 80%; word-wrap: break-word; line-height: 1.4;}
.chat-message.user { align-self: flex-end; background-color: var(--bg-medium); color: white; border-bottom-right-radius: 2px; }
.chat-message.ai { align-self: flex-start; background-color: #e6e6e6; color: var(--text-dark); border-bottom-left-radius: 2px; }
.chat-input-container { display: flex; gap: 10px; }
#chat-input { flex-grow: 1; padding: 10px 15px; border: 1px solid #e0e0e0; border-radius: 20px; font-size: 0.95rem; outline: none; }
#chat-input:focus { border-color: var(--bg-medium); box-shadow: 0 0 0 2px rgba(19, 93, 102, 0.15); }
#chat-send-button { background-color: var(--bg-medium); color: white; border: none; border-radius: 50%; width: 40px; height: 40px; display: flex; justify-content: center; align-items: center; cursor: pointer; transition: background-color 0.3s; flex-shrink: 0; }
#chat-send-button:hover { background-color: var(--bg-dark); }
#chat-send-button:disabled { background-color: #cccccc; cursor: not-allowed; }
.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; }
.chat-product-card-actions .fa-cart-plus { font-size: 0.9em; }
</style>
</head>
<body>
<div class="container">
<div class="top-bar">
<a href="/" class="logo">
<img src="https://huggingface.co/spaces/gippo312/admin/resolve/main/Picsart_25-11-04_12-02-21-390.png" alt="Gippo312 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=Gippo312" alt="No Image" loading="lazy">
{% endif %}
</div>
<div class="product-info-overlay">
<span class="product-price">{{ "%.2f"|format(product.price) }} {{ currency_code }}</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>
<label for="quantityInput">Количество:</label>
<input type="number" id="quantityInput" class="quantity-input" min="1" value="1">
<label for="colorSelect">Цвет/Вариант:</label>
<select id="colorSelect" class="color-select"></select>
<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 id="chatModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('chatModal')" aria-label="Закрыть">×</span>
<h2><i class="fas fa-comment-dots"></i> Чат с EVA</h2>
<div id="chat-messages"></div>
<div class="chat-input-container">
<input type="text" id="chat-input" placeholder="Напишите сообщение...">
<button id="chat-send-button"><i class="fas fa-paper-plane"></i></button>
</div>
<button id="clear-chat-button" class="product-button" style="background-color: var(--danger); margin-top: 15px;"><i class="fas fa-trash"></i> Очистить чат</button>
</div>
</div>
<div class="floating-buttons-container">
<button id="chat-open-button" class="floating-button" onclick="openChatModal()" aria-label="Открыть чат">
<i class="fas fa-comment-dots"></i>
</button>
<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 }}';
let selectedProductId = null;
let cart = JSON.parse(localStorage.getItem('mekaCart') || '[]');
let chatHistory = JSON.parse(localStorage.getItem('evaChatHistory') || '[]');
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('/product/' + index)
.then(response => {
if (!response.ok) throw new Error(`Ошибка ${response.status}: ${response.statusText}`);
return response.text();
})
.then(data => {
modalContent.innerHTML = data;
initializeSwiper();
})
.catch(error => {
console.error('Ошибка загрузки деталей продукта:', error);
modalContent.innerHTML = `<p style="color: var(--danger); text-align:center; padding: 40px;">Не удалось загрузить информацию о товаре. ${error.message}</p>`;
});
}
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 openQuantityModalById(productId) {
selectedProductId = productId;
const product = getProductById(productId);
if (!product) {
console.error("Product not found for ID:", productId);
alert("Ошибка: товар не найден.");
return;
}
const colorSelect = document.getElementById('colorSelect');
const colorLabel = document.querySelector('label[for="colorSelect"]');
colorSelect.innerHTML = '';
const validColors = product.colors ? product.colors.filter(c => c && c.trim() !== "") : [];
if (validColors.length > 0) {
validColors.forEach(color => {
const option = document.createElement('option');
option.value = color.trim();
option.text = color.trim();
colorSelect.appendChild(option);
});
colorSelect.style.display = 'block';
if(colorLabel) colorLabel.style.display = 'block';
} else {
colorSelect.style.display = 'none';
if(colorLabel) colorLabel.style.display = 'none';
}
document.getElementById('quantityInput').value = 1;
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('colorSelect');
const color = colorSelect.style.display !== 'none' && colorSelect.value ? colorSelect.value : 'N/A';
if (isNaN(quantity) || quantity <= 0) {
alert("Пожалуйста, укажите корректное количество (больше 0).");
quantityInput.focus();
return;
}
const product = getProductById(selectedProductId);
if (!product) {
alert("Ошибка добавления: товар не найден.");
return;
}
const cartItemId = `${product.product_id}-${color}`;
const existingItemIndex = cart.findIndex(item => item.id === cartItemId);
if (existingItemIndex > -1) {
cart[existingItemIndex].quantity += quantity;
} else {
cart.push({
id: cartItemId,
product_id: product.product_id,
name: product.name,
price: product.price,
photo: product.photos && product.photos.length > 0 ? product.photos[0] : null,
quantity: quantity,
color: color
});
}
localStorage.setItem('mekaCart', 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';
cartButton.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 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';
const colorText = item.color !== 'N/A' ? ` (Цвет: ${item.color})` : '';
return `
<div class="cart-item">
<img src="${photoUrl}" alt="${item.name}">
<div class="cart-item-details">
<strong>${item.name}${colorText}</strong>
<p class="cart-item-price">${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', JSON.stringify(cart));
openCartModal();
updateCartButton();
}
}
function decrementCartItem(itemId) {
const itemIndex = cart.findIndex(item => item.id === itemId);
if (itemIndex > -1) {
cart[itemIndex].quantity--;
if (cart[itemIndex].quantity <= 0) {
cart.splice(itemIndex, 1);
}
localStorage.setItem('mekaCart', JSON.stringify(cart));
openCartModal();
updateCartButton();
}
}
function removeFromCart(itemId) {
cart = cart.filter(item => item.id !== itemId);
localStorage.setItem('mekaCart', JSON.stringify(cart));
openCartModal();
updateCartButton();
}
function clearCart() {
if (confirm("Вы уверены, что хотите очистить корзину?")) {
cart = [];
localStorage.removeItem('mekaCart');
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('/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');
cart = [];
updateCartButton();
closeModal('cartModal');
window.location.href = `/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);
}
function openChatModal() {
const modal = document.getElementById('chatModal');
if(modal) {
modal.style.display = "block";
document.body.style.overflow = 'hidden';
displayChatHistory();
document.getElementById('chat-input').focus();
}
}
function displayChatHistory() {
const chatMessagesDiv = document.getElementById('chat-messages');
chatMessagesDiv.innerHTML = '';
chatHistory.forEach(msg => {
addMessageToChat(msg.text, msg.role, false);
});
chatMessagesDiv.scrollTop = chatMessagesDiv.scrollHeight;
}
function addMessageToChat(text, role, save = true) {
const chatMessagesDiv = document.getElementById('chat-messages');
const messageElement = document.createElement('div');
messageElement.className = `chat-message ${role}`;
const productMatchRegex = /\[ID_ТОВАРА:\s*([a-fA-F0-9]+)\s*Название:\s*([^\]]+)\]/g;
let lastIndex = 0;
const contentFragment = document.createDocumentFragment();
let match;
while ((match = productMatchRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
const textPart = document.createElement('span');
textPart.innerHTML = text.substring(lastIndex, match.index).replace(/\\n/g, '<br>');
contentFragment.appendChild(textPart);
}
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]}`
: 'https://via.placeholder.com/50x50.png?text=Gippo312';
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(2)} ${currencyCode}</span>
</div>
<div class="chat-product-card-actions">
<a href="#" class="chat-product-link" data-product-id="${productId}">Обзор</a>
<a href="#" class="chat-add-to-cart" data-product-id="${productId}"><i class="fas fa-cart-plus"></i></a>
</div>
`;
contentFragment.appendChild(card);
} else {
const productName = match[2];
const notFoundText = document.createElement('span');
notFoundText.innerHTML = `[ID_ТОВАРА: ${productId} Название: ${productName}] (товар не найден) `;
contentFragment.appendChild(notFoundText);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
const textPart = document.createElement('span');
textPart.innerHTML = text.substring(lastIndex).replace(/\\n/g, '<br>');
contentFragment.appendChild(textPart);
}
messageElement.appendChild(contentFragment);
chatMessagesDiv.appendChild(messageElement);
chatMessagesDiv.scrollTop = chatMessagesDiv.scrollHeight;
if (save) {
chatHistory.push({ text: text, role: role });
localStorage.setItem('evaChatHistory', JSON.stringify(chatHistory));
}
messageElement.querySelectorAll('.chat-product-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const id = e.currentTarget.dataset.productId;
closeModal('chatModal');
openModalById(id);
});
});
messageElement.querySelectorAll('.chat-add-to-cart').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const id = e.currentTarget.dataset.productId;
closeModal('chatModal');
openQuantityModalById(id);
});
});
}
async function sendMessage() {
const chatInput = document.getElementById('chat-input');
const chatSendButton = document.getElementById('chat-send-button');
const message = chatInput.value.trim();
if (!message) return;
addMessageToChat(message, 'user');
chatInput.value = '';
chatSendButton.disabled = true;
try {
const response = await fetch('/chat_with_ai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: message, history: chatHistory })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Ошибка при получении ответа от ИИ.');
}
addMessageToChat(result.text, 'ai');
} catch (error) {
console.error("Chat AI Error:", error);
addMessageToChat(`Извините, произошла ошибка: ${error.message}`, 'ai', false);
} finally {
chatSendButton.disabled = false;
}
}
function clearChatHistory() {
if (confirm("Вы уверены, что хотите очистить историю чата?")) {
chatHistory = [];
localStorage.removeItem('evaChatHistory');
displayChatHistory();
showNotification("История чата очищена.");
}
}
document.addEventListener('DOMContentLoaded', () => {
updateCartButton();
document.getElementById('search-input').addEventListener('input', filterProducts);
document.getElementById('chat-send-button').addEventListener('click', sendMessage);
document.getElementById('chat-input').addEventListener('keypress', function(e) {
if (e.key === 'Enter') { sendMessage(); }
});
document.getElementById('clear-chat-button').addEventListener('click', clearChatHistory);
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>
'''
PRODUCT_DETAIL_TEMPLATE = '''
<div style="padding: 10px;">
<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="text-align:center; margin-top:20px; padding: 0 10px;">
<p style="font-size: 1.5rem; font-weight: bold; color: #135D66; margin-bottom: 15px;"><strong>Цена:</strong> {{ "%.2f"|format(product.price) }} {{ currency_code }}</p>
<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>
{% set colors = product.get('colors', []) %}
{% if colors and colors|select('ne', '')|list|length > 0 %}
<p><strong>Доступные цвета/варианты:</strong> {{ colors|select('ne', '')|join(', ') }}</p>
{% endif %}
</div>
</div>
'''
ORDER_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Заказ №{{ order.id }} - Gippo312</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 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 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 { 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; 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; text-align: right; 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;}
</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') }}" 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) => `
<div class="order-item">
<img src="${item.photo_url}" alt="${item.name}">
<div class="item-details">
<strong>${item.name} ${item.color !== 'N/A' ? `(${item.color})` : ''}</strong>
<span>${item.price.toFixed(2)} {{ currency_code }}</span>
</div>
<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>
`).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 }}";
let message = `Здравствуйте! Хочу подтвердить или изменить свой заказ на Gippo312:%0A%0A`;
message += `*Номер заказа:* ${orderId}%0A%0A`;
order.cart.forEach(item => {
message += `*${item.name}* ${item.color !== 'N/A' ? `(${item.color})` : ''}%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') }}" 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>Админ-панель - Gippo312</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; border-bottom: 1px solid #e0e0e0; list-style: none; position: relative; }
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; }
.color-input-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.color-input-group input { flex-grow: 1; margin: 0; }
.remove-color-btn { background-color: var(--danger); color: white; padding: 6px 10px; font-size: 0.8rem; margin-top: 0; line-height: 1; }
.remove-color-btn:hover { background-color: var(--danger-hover); }
.add-color-btn { background-color: #B2DFDB; color: var(--bg-medium); border: 1px solid #e0e0e0; }
.add-color-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;}
.ai-generate-button { background-color: #8D6EC8; color: white; margin-top: 5px; margin-bottom: 10px; }
.ai-generate-button:hover { background-color: #7B4DB5; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo-title-container" style="display: flex; align-items: center; gap: 15px;">
<img src="https://huggingface.co/spaces/gippo312/admin/resolve/main/Picsart_25-11-04_12-02-21-390.png" alt="Gippo312 Logo">
<h1><i class="fas fa-tools"></i> Админ-панель Gippo312</h1>
</div>
<a href="{{ url_for('catalog') }}" 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-sync-alt"></i> Синхронизация с Датацентром</h2>
<div class="sync-buttons">
<form method="POST" action="{{ url_for('force_upload') }}" 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') }}" 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="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>
<label for="add_price">Цена ({{ currency_code }}) *:</label>
<input type="number" id="add_price" name="price" step="0.01" min="0" required>
<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">
<div class="color-input-group">
<input type="text" name="colors" placeholder="Например: Розовый">
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
</div>
</div>
<button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('add-color-inputs')"><i class="fas fa-palette"></i> Добавить поле</button>
<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>
<p><strong>Цена:</strong> {{ "%.2f"|format(product.price) }} {{ currency_code }}</p>
<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', []) %}
<p><strong>Цвета/Вар-ты:</strong> {{ colors|select('ne', '')|join(', ') if colors|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>
<label>Цена ({{ currency_code }}) *:</label>
<input type="number" name="price" step="0.01" min="0" value="{{ product['price'] }}" required>
<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 }}">
{% 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="color-input-group">
<input type="text" name="colors" value="{{ color }}">
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
</div>
{% endif %}
{% endfor %}
{% else %}
<div class="color-input-group">
<input type="text" name="colors" placeholder="Например: Цвет">
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
</div>
{% endif %}
</div>
<button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('edit-color-inputs-{{ loop.index0 }}')"><i class="fas fa-palette"></i> Добавить поле</button>
<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>
<script>
function toggleEditForm(formId) {
const formContainer = document.getElementById(formId);
if (formContainer) {
formContainer.style.display = formContainer.style.display === 'none' || formContainer.style.display === '' ? 'block' : 'none';
}
}
function addColorInput(containerId) {
const container = document.getElementById(containerId);
if (container) {
const newInputGroup = document.createElement('div');
newInputGroup.className = 'color-input-group';
newInputGroup.innerHTML = `
<input type="text" name="colors" placeholder="Новый цвет/вариант">
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
`;
container.appendChild(newInputGroup);
const newInput = newInputGroup.querySelector('input[name="colors"]');
if (newInput) {
newInput.focus();
}
}
}
function removeColorInput(button) {
const group = button.closest('.color-input-group');
if (group) {
const container = group.parentNode;
group.remove();
if (container && container.children.length === 0) {
const placeholderGroup = document.createElement('div');
placeholderGroup.className = 'color-input-group';
placeholderGroup.innerHTML = `
<input type="text" name="colors" placeholder="Например: Цвет">
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
`;
container.appendChild(placeholderGroup);
}
} else {
console.warn("Could not find parent .color-input-group for remove button");
}
}
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);
}
</script>
</body>
</html>
'''
@app.route('/')
def catalog():
data = load_data()
all_products_raw = data.get('products', [])
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)]
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,
store_address=STORE_ADDRESS,
currency_code=CURRENCY_CODE
)
@app.route('/product/<int:index>')
def product_detail(index):
data = load_data()
all_products_raw = data.get('products', [])
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=CURRENCY_CODE
)
@app.route('/create_order', methods=['POST'])
def create_order():
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'),
"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 = load_data()
if 'orders' not in data or not isinstance(data.get('orders'), dict):
data['orders'] = {}
data['orders'][order_id] = new_order
save_data(data)
return jsonify({"order_id": order_id}), 201
except Exception as e:
return jsonify({"error": "Ошибка сервера при сохранении заказа."}), 500
@app.route('/order/<order_id>')
def view_order(order_id):
data = load_data()
order = data.get('orders', {}).get(order_id)
return render_template_string(ORDER_TEMPLATE,
order=order,
repo_id=REPO_ID,
currency_code=CURRENCY_CODE,
whatsapp_number=WHATSAPP_NUMBER)
@app.route('/admin', methods=['GET', 'POST'])
def admin():
data = load_data()
products = data.get('products', [])
categories = data.get('categories', [])
organization_info = data.get('organization_info', {})
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_data(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_data(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_data(data)
flash("Информация о магазине успешно обновлена.", 'success')
elif action == 'add_product':
name = request.form.get('name', '').strip()
price_str = request.form.get('price', '').replace(',', '.')
description = request.form.get('description', '').strip()
category = request.form.get('category')
photos_files = request.files.getlist('photos')
colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
in_stock = 'in_stock' in request.form
is_top = 'is_top' in request.form
if not name or not price_str:
flash("Название и цена товара обязательны.", 'error')
return redirect(url_for('admin'))
try:
price = round(float(price_str), 2)
if price < 0: price = 0
except ValueError:
flash("Неверный формат цены.", 'error')
return redirect(url_for('admin'))
photos_list = []
if photos_files and HF_TOKEN_WRITE:
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
api = HfApi()
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(name.replace(' ', '_'))[:50]
photo_filename = f"{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{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,
commit_message=f"Add photo for product {name}"
)
photos_list.append(photo_filename)
os.remove(temp_path)
uploaded_count += 1
except Exception as e:
flash(f"Ошибка при загрузке фото {photo.filename}.", 'error')
if os.path.exists(temp_path):
try: os.remove(temp_path)
except OSError: pass
elif photo and not photo.filename:
pass
try:
if os.path.exists(uploads_dir) and not os.listdir(uploads_dir):
os.rmdir(uploads_dir)
except OSError as e:
pass
elif not HF_TOKEN_WRITE and photos_files and any(f.filename for f in photos_files):
flash("HF_TOKEN (write) не настроен. Фотографии не были загружены.", "warning")
new_product = {
'product_id': uuid4().hex,
'name': name, 'price': price, 'description': description,
'category': category if category in categories else 'Без категории',
'photos': photos_list, 'colors': colors,
'in_stock': in_stock, 'is_top': is_top
}
products.append(new_product)
data['products'] = products
save_data(data)
flash(f"Товар '{name}' успешно добавлен.", 'success')
elif action == 'edit_product':
product_id = request.form.get('product_id')
product_to_edit = next((p for p in products if p.get('product_id') == product_id), None)
if product_to_edit is None:
flash(f"Ошибка редактирования: товар с ID '{product_id}' не найден.", 'error')
return redirect(url_for('admin'))
original_name = product_to_edit.get('name', 'N/A')
product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip()
price_str = request.form.get('price', str(product_to_edit['price'])).replace(',', '.')
product_to_edit['description'] = request.form.get('description', product_to_edit.get('description', '')).strip()
category = request.form.get('category')
product_to_edit['category'] = category if category in categories else 'Без категории'
product_to_edit['colors'] = [c.strip() for c in request.form.getlist('colors') if c.strip()]
product_to_edit['in_stock'] = 'in_stock' in request.form
product_to_edit['is_top'] = 'is_top' in request.form
try:
price = round(float(price_str), 2)
if price < 0: price = 0
product_to_edit['price'] = price
except ValueError:
flash(f"Неверный формат цены для товара '{original_name}'. Цена не изменена.", 'warning')
photos_files = request.files.getlist('photos')
if photos_files and any(f.filename for f in photos_files) and 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_to_edit['name'].replace(' ', '_'))[:50]
photo_filename = f"{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{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,
commit_message=f"Update photo for product {product_to_edit['name']}")
new_photos_list.append(photo_filename)
os.remove(temp_path)
uploaded_count += 1
except Exception as e:
flash(f"Ошибка при загрузке нового фото {photo.filename}.", 'error')
if os.path.exists(temp_path):
try: os.remove(temp_path)
except OSError: pass
try:
if os.path.exists(uploads_dir) and not os.listdir(uploads_dir):
os.rmdir(uploads_dir)
except OSError as e:
pass
if new_photos_list:
old_photos = product_to_edit.get('photos', [])
if old_photos:
try:
api = HfApi()
api.delete_files(
repo_id=REPO_ID,
paths_in_repo=[f"photos/{p}" for p in old_photos],
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Delete old photos for product {product_to_edit['name']}"
)
except Exception as e:
flash("Не удалось удалить старые фотографии с сервера. Новые фото загружены.", "warning")
product_to_edit['photos'] = new_photos_list
flash("Фотографии товара успешно обновлены.", "success")
elif uploaded_count == 0 and any(f.filename for f in photos_files):
flash("Не удалось загрузить новые фотографии (возможно, неверный формат).", "error")
elif not HF_TOKEN_WRITE and photos_files and any(f.filename for f in photos_files):
flash("HF_TOKEN (write) не настроен. Фотографии не были обновлены.", "warning")
save_data(data)
flash(f"Товар '{product_to_edit['name']}' успешно обновлен.", 'success')
elif action == 'delete_product':
product_id = request.form.get('product_id')
product_index = next((i for i, p in enumerate(products) if p.get('product_id') == product_id), -1)
if product_index == -1:
flash(f"Ошибка удаления: товар с ID '{product_id}' не найден.", 'error')
return redirect(url_for('admin'))
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_data(data)
flash(f"Товар '{product_name}' удален.", 'success')
else:
flash(f"Неизвестное действие: {action}", 'warning')
return redirect(url_for('admin'))
except Exception as e:
flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
return redirect(url_for('admin'))
current_data = load_data()
display_products = sorted(current_data.get('products', []), key=lambda p: p.get('name', '').lower())
display_categories = sorted(current_data.get('categories', []))
display_organization_info = current_data.get('organization_info', {})
return render_template_string(
ADMIN_TEMPLATE,
products=display_products,
categories=display_categories,
organization_info=display_organization_info,
repo_id=REPO_ID,
currency_code=CURRENCY_CODE
)
@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('/chat_with_ai', methods=['POST'])
def handle_chat_with_ai():
request_data = request.get_json()
user_message = request_data.get('message')
chat_history_from_client = request_data.get('history', [])
if not user_message:
return jsonify({"error": "Сообщение не может быть пустым."}), 400
try:
ai_response_text = generate_chat_response(user_message, chat_history_from_client)
return jsonify({"text": ai_response_text})
except Exception as e:
return jsonify({"error": f"Ошибка чата: {e}"}), 500
@app.route('/force_upload', methods=['POST'])
def force_upload():
try:
upload_db_to_hf()
flash("Данные успешно загружены на Hugging Face.", 'success')
except Exception as e:
flash(f"Ошибка при загрузке на Hugging Face: {e}", 'error')
return redirect(url_for('admin'))
@app.route('/force_download', methods=['POST'])
def force_download():
try:
if download_db_from_hf():
flash("Данные успешно скачаны с Hugging Face. Локальные файлы обновлены.", 'success')
load_data()
else:
flash("Не удалось скачать данные с Hugging Face после нескольких попыток. Проверьте логи.", 'error')
except Exception as e:
flash(f"Ошибка при скачивании с Hugging Face: {e}", 'error')
return redirect(url_for('admin'))
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)