Emir / app.py
Kgshop's picture
Rename app (1) (16).py to app.py
5d1ca7b verified
# -*- coding: utf-8 -*-
from flask import Flask, render_template_string, request, redirect, url_for, jsonify
import json
import os
import logging
import threading
import time
from datetime import datetime, timedelta, timezone
import pytz
from huggingface_hub import HfApi, hf_hub_download
from huggingface_hub.utils import RepositoryNotFoundError
from werkzeug.utils import secure_filename
from werkzeug.security import generate_password_hash, check_password_hash
import jwt
from functools import wraps
import math
app = Flask(__name__)
app.config['SECRET_KEY'] = os.getenv("FLASK_SECRET_KEY", "your_very_strong_jwt_secret_key_here_CHANGE_ME")
DATA_FILE = 'data_exmenu.json'
USER_DATA_FILE = 'data_emirusers.json'
REPO_ID = "Kgshop/clients"
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
LOGO_URL = os.getenv("LOGO_URL","https://huggingface.co/spaces/kgmenu/Emir/resolve/main/emir_chaihana-20250405-0001.jpg")
CASHBACK_PERCENTAGE = 5
logging.basicConfig(level=logging.INFO)
def load_data():
try:
try:
download_db_from_hf()
except RepositoryNotFoundError:
logging.error(f"Репозиторий {REPO_ID} не найден при попытке скачивания {DATA_FILE}. Используется локальная версия, если есть.")
except Exception as e:
logging.error(f"Ошибка скачивания {DATA_FILE} с Hugging Face: {e}. Используется локальная версия, если есть.")
if not os.path.exists(DATA_FILE):
logging.warning(f"Локальный файл {DATA_FILE} не найден. Возвращаем пустую структуру.")
return {'products': [], 'categories': [], 'stoplist': {}, 'qr_code': None, 'news': []}
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
logging.info("Данные меню успешно загружены из локального JSON")
if not isinstance(data, dict) or 'products' not in data or 'categories' not in data:
logging.warning(f"Структура файла {DATA_FILE} некорректна. Сброс к дефолтной.")
data = {'products': [], 'categories': [], 'stoplist': {}, 'qr_code': None, 'news': []}
if 'stoplist' not in data: data['stoplist'] = {}
if 'qr_code' not in data: data['qr_code'] = None
if 'news' not in data: data['news'] = []
for product in data.get('products', []):
product.setdefault('has_container', False)
product.setdefault('container_price', 0)
current_time_utc = datetime.now(timezone.utc)
updated_news = []
for news_item in data.get('news', []):
expiry_datetime_utc = None
if isinstance(news_item, dict) and 'expiry' in news_item and news_item['expiry']:
try:
dt_str = news_item['expiry']
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
if dt.tzinfo is None:
expiry_datetime_utc = dt.replace(tzinfo=timezone.utc)
else:
expiry_datetime_utc = dt.astimezone(timezone.utc)
except ValueError:
logging.error(f"Неверный формат даты истечения новости: {news_item['expiry']}")
updated_news.append(news_item)
continue
except TypeError:
logging.error(f"Неверный тип данных для даты истечения новости: {news_item['expiry']}")
updated_news.append(news_item)
continue
if expiry_datetime_utc:
if expiry_datetime_utc > current_time_utc:
updated_news.append(news_item)
else:
logging.info(f"Новость '{news_item.get('title', 'N/A')}' истекла и удалена.")
else:
updated_news.append(news_item)
data['news'] = updated_news
stoplist_processed = {}
current_time_utc_check = datetime.now(timezone.utc)
for product_id, stop_info in data.get('stoplist', {}).items():
if isinstance(stop_info, dict) and 'until' in stop_info:
try:
until_dt_iso = stop_info['until']
until_dt = datetime.fromisoformat(until_dt_iso.replace('Z', '+00:00'))
if until_dt.tzinfo is None:
until_dt = until_dt.replace(tzinfo=timezone.utc)
else:
until_dt = until_dt.astimezone(timezone.utc)
if until_dt > current_time_utc_check:
stoplist_processed[str(product_id)] = {
'until': until_dt,
'minutes': stop_info.get('minutes', 0)
}
else:
logging.info(f"Запись стоп-листа для продукта {product_id} истекла при загрузке.")
except (ValueError, TypeError) as e:
logging.error(f"Ошибка обработки времени стоп-листа для продукта {product_id} (ISO: {stop_info.get('until')}): {e}. Запись игнорируется.")
else:
logging.warning(f"Некорректная запись стоп-листа для продукта {product_id}: {stop_info}. Запись игнорируется.")
data['stoplist'] = stoplist_processed
return data
except FileNotFoundError:
logging.warning(f"Локальный файл базы данных меню {DATA_FILE} не найден.")
return {'products': [], 'categories': [], 'stoplist': {}, 'qr_code': None, 'news': []}
except json.JSONDecodeError:
logging.error(f"Ошибка: Невозможно декодировать JSON-файл меню {DATA_FILE}.")
return {'products': [], 'categories': [], 'stoplist': {}, 'qr_code': None, 'news': []}
except Exception as e:
logging.error(f"Непредвиденная ошибка при загрузке данных меню: {e}", exc_info=True)
return {'products': [], 'categories': [], 'stoplist': {}, 'qr_code': None, 'news': []}
def save_data(data):
try:
data_to_save = data.copy()
data_to_save['stoplist'] = {
pid: {
'until': info['until'].isoformat(),
'minutes': info.get('minutes', 0)
}
for pid, info in data.get('stoplist', {}).items()
if isinstance(info.get('until'), datetime)
}
news_list = data.get('news', [])
if not isinstance(news_list, list):
logging.warning("Ключ 'news' не является списком при сохранении. Сброс на пустой список.")
news_list = []
valid_news = []
for item in news_list:
if isinstance(item, dict):
valid_news.append(item)
else:
logging.warning(f"Некорректный элемент в списке новостей: {item}. Пропуск при сохранении.")
data_to_save['news'] = valid_news
with open(DATA_FILE, 'w', encoding='utf-8') as file:
json.dump(data_to_save, file, ensure_ascii=False, indent=4)
logging.info(f"Данные меню успешно сохранены в {DATA_FILE}")
upload_db_to_hf()
except Exception as e:
logging.error(f"Ошибка при сохранении данных меню в {DATA_FILE}: {e}", exc_info=True)
def upload_db_to_hf():
if not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN (write) не установлен. Пропуск загрузки базы данных меню на Hugging Face.")
return
if not os.path.exists(DATA_FILE):
logging.error(f"Файл {DATA_FILE} не найден для загрузки на Hugging Face.")
return
try:
api = HfApi()
api.upload_file(
path_or_fileobj=DATA_FILE,
path_in_repo=DATA_FILE,
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Автоматическое резервное копирование базы данных меню {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
)
logging.info(f"Резервная копия {DATA_FILE} загружена на Hugging Face.")
except Exception as e:
logging.error(f"Ошибка при загрузке резервной копии базы данных меню {DATA_FILE}: {e}")
def download_db_from_hf():
if not HF_TOKEN_READ:
logging.warning("HF_TOKEN_READ не установлен. Пропуск скачивания базы данных меню с Hugging Face.")
return
try:
downloaded_path = hf_hub_download(
repo_id=REPO_ID,
filename=DATA_FILE,
repo_type="dataset",
token=HF_TOKEN_READ,
local_dir=".",
local_dir_use_symlinks=False,
force_download=True
)
logging.info(f"JSON-база данных меню {DATA_FILE} загружена с Hugging Face в {downloaded_path}.")
except RepositoryNotFoundError as e:
logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face (для файла {DATA_FILE}): {e}")
raise
except Exception as e:
logging.error(f"Ошибка при загрузке JSON-базы данных меню {DATA_FILE} с Hugging Face: {e}", exc_info=True)
raise
def load_user_data():
try:
try:
download_user_db_from_hf()
except RepositoryNotFoundError:
logging.error(f"Репозиторий {REPO_ID} не найден при попытке скачивания {USER_DATA_FILE}. Используется локальная версия, если есть.")
except Exception as e:
logging.error(f"Ошибка скачивания {USER_DATA_FILE} с Hugging Face: {e}. Используется локальная версия, если есть.")
if not os.path.exists(USER_DATA_FILE):
logging.warning(f"Локальный файл {USER_DATA_FILE} не найден. Возвращаем пустую структуру.")
return {'users': []}
with open(USER_DATA_FILE, 'r', encoding='utf-8') as file:
user_data = json.load(file)
logging.info("Данные пользователей успешно загружены из локального JSON")
if not isinstance(user_data, dict) or 'users' not in user_data:
logging.warning(f"Структура файла {USER_DATA_FILE} некорректна. Сброс к дефолтной.")
return {'users': []}
if not isinstance(user_data.get('users'), list):
logging.warning(f"Ключ 'users' в {USER_DATA_FILE} не является списком. Сброс к дефолтной.")
return {'users': []}
for user in user_data['users']:
if 'points' not in user:
user['points'] = 0
if 'order_history' not in user:
user['order_history'] = []
if 'phone' not in user:
user['phone'] = None
if 'address' not in user:
user['address'] = None
return user_data
except FileNotFoundError:
logging.warning(f"Локальный файл базы данных пользователей {USER_DATA_FILE} не найден.")
return {'users': []}
except json.JSONDecodeError:
logging.error(f"Ошибка: Невозможно декодировать JSON-файл пользователей {USER_DATA_FILE}.")
return {'users': []}
except Exception as e:
logging.error(f"Непредвиденная ошибка при загрузке данных пользователей: {e}", exc_info=True)
return {'users': []}
def save_user_data(user_data):
try:
if not isinstance(user_data, dict) or not isinstance(user_data.get('users'), list):
logging.error(f"Попытка сохранить некорректные данные пользователей: {user_data}. Сохранение отменено.")
return
with open(USER_DATA_FILE, 'w', encoding='utf-8') as file:
json.dump(user_data, file, ensure_ascii=False, indent=4)
logging.info(f"Данные пользователей успешно сохранены в {USER_DATA_FILE}")
upload_user_db_to_hf()
except Exception as e:
logging.error(f"Ошибка при сохранении данных пользователей в {USER_DATA_FILE}: {e}", exc_info=True)
def upload_user_db_to_hf():
if not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN (write) не установлен. Пропуск загрузки базы данных пользователей на Hugging Face.")
return
if not os.path.exists(USER_DATA_FILE):
logging.error(f"Файл {USER_DATA_FILE} не найден для загрузки на Hugging Face.")
return
try:
api = HfApi()
api.upload_file(
path_or_fileobj=USER_DATA_FILE,
path_in_repo=USER_DATA_FILE,
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Автоматическое резервное копирование базы данных пользователей {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
)
logging.info(f"Резервная копия {USER_DATA_FILE} загружена на Hugging Face.")
except Exception as e:
logging.error(f"Ошибка при загрузке резервной копии базы данных пользователей {USER_DATA_FILE}: {e}")
def download_user_db_from_hf():
if not HF_TOKEN_READ:
logging.warning("HF_TOKEN_READ не установлен. Пропуск скачивания базы данных пользователей с Hugging Face.")
return
try:
downloaded_path = hf_hub_download(
repo_id=REPO_ID,
filename=USER_DATA_FILE,
repo_type="dataset",
token=HF_TOKEN_READ,
local_dir=".",
local_dir_use_symlinks=False,
force_download=True
)
logging.info(f"JSON-база данных пользователей {USER_DATA_FILE} загружена с Hugging Face в {downloaded_path}.")
except RepositoryNotFoundError as e:
logging.error(f"Репозиторий {REPO_ID} не найден (для файла {USER_DATA_FILE}): {e}")
raise
except Exception as e:
logging.error(f"Ошибка при загрузке JSON-базы данных пользователей {USER_DATA_FILE} с Hugging Face: {e}", exc_info=True)
raise
def periodic_backup():
interval_seconds = 800
logging.info(f"Периодический бэкап настроен с интервалом {interval_seconds} секунд.")
while True:
time.sleep(interval_seconds)
logging.info("Запуск периодического бэкапа...")
try:
upload_db_to_hf()
upload_user_db_to_hf()
logging.info("Периодический бэкап (загрузка на HF) завершен.")
except Exception as e:
logging.error(f"Ошибка во время периодического бэкапа: {e}", exc_info=True)
def get_category_counts(products):
counts = {}
for product in products:
category = product.get('category', 'Без категории')
counts[category] = counts.get(category, 0) + 1
return counts
def register_user(login, password):
user_data_dict = load_user_data()
users = user_data_dict.get('users', [])
if any(user.get('login') == login for user in users):
return False, "Логин уже занят."
if not login or not password:
return False, "Логин и пароль обязательны."
hashed_password = generate_password_hash(password)
new_user = {
'login': login,
'password': hashed_password,
'phone': None,
'address': None,
'points': 0,
'order_history': []
}
users.append(new_user)
try:
save_user_data(user_data_dict)
return True, "Регистрация успешна."
except Exception as e:
logging.error(f"Ошибка сохранения данных пользователя при регистрации: {e}")
return False, "Ошибка сервера при сохранении данных."
def authenticate_user(login, password):
user_data_dict = load_user_data()
users = user_data_dict.get('users', [])
user = next((user for user in users if user.get('login') == login), None)
if user and 'password' in user and check_password_hash(user['password'], password):
user_info = user.copy()
del user_info['password']
return user_info
return None
def get_user_profile_data(login):
user_data_dict = load_user_data()
users = user_data_dict.get('users', [])
user = next((user for user in users if user.get('login') == login), None)
if user:
profile_data = {
'login': user.get('login'),
'phone': user.get('phone'),
'address': user.get('address'),
'points': user.get('points', 0),
}
return profile_data
return None
def update_user_profile(login, phone, address):
user_data_dict = load_user_data()
users = user_data_dict.get('users', [])
user_found = False
if not phone or not address:
return False, "Телефон и адрес не могут быть пустыми."
for user in users:
if user.get('login') == login:
user['phone'] = phone
user['address'] = address
user_found = True
break
if user_found:
try:
save_user_data(user_data_dict)
return True, "Профиль обновлен."
except Exception as e:
logging.error(f"Ошибка сохранения данных при обновлении профиля пользователя {login}: {e}")
return False, "Ошибка сервера при сохранении данных."
return False, "Пользователь не найден."
def add_points_to_user(login, points):
user_data_dict = load_user_data()
users = user_data_dict.get('users', [])
user_found = False
points_to_add = math.floor(points)
if points_to_add <= 0:
logging.info(f"Попытка начислить/вернуть <= 0 баллов ({points} -> {points_to_add}) для {login}. Пропуск.")
return True, "Баллы не начислены (сумма < 1)."
for user in users:
if user.get('login') == login:
current_points = user.get('points', 0)
if not isinstance(current_points, (int, float)):
logging.warning(f"Некорректное значение баллов ({current_points}) у пользователя {login}. Сброс на 0 перед добавлением.")
current_points = 0
user['points'] = current_points + points_to_add
user_found = True
logging.info(f"Пользователю {login} начислено/возвращено {points_to_add} баллов. Новое значение: {user['points']}")
break
if user_found:
try:
save_user_data(user_data_dict)
return True, f"{points_to_add} баллов успешно начислено/возвращено."
except Exception as e:
logging.error(f"Ошибка сохранения данных при начислении/возврате баллов пользователю {login}: {e}")
return False, "Ошибка сервера при сохранении данных."
return False, "Пользователь не найден."
def redeem_points_from_user(login, points_to_redeem):
user_data_dict = load_user_data()
users = user_data_dict.get('users', [])
user_found = False
points_to_redeem_int = math.floor(points_to_redeem)
if points_to_redeem_int <= 0:
return False, "Количество баллов для списания должно быть положительным.", 0
message = "Пользователь не найден."
success = False
updated_points = 0
for user in users:
if user.get('login') == login:
user_found = True
current_points = user.get('points', 0)
if not isinstance(current_points, (int, float)):
logging.warning(f"Некорректное значение баллов ({current_points}) у пользователя {login} перед списанием. Считаем как 0.")
current_points = 0
if current_points >= points_to_redeem_int:
user['points'] = current_points - points_to_redeem_int
updated_points = user['points']
try:
save_user_data(user_data_dict)
success = True
message = f"{points_to_redeem_int} баллов успешно списано."
logging.info(f"У пользователя {login} списано {points_to_redeem_int} баллов. Остаток: {updated_points}")
except Exception as e:
logging.error(f"Ошибка сохранения данных при списании баллов у пользователя {login}: {e}")
message = "Ошибка сервера при сохранении данных."
user['points'] = current_points
updated_points = current_points
else:
message = f"Недостаточно баллов для списания. Доступно: {current_points}, требуется: {points_to_redeem_int}."
updated_points = current_points
break
if not user_found:
temp_user_data = get_user_profile_data(login)
updated_points = temp_user_data.get('points', 0) if temp_user_data else 0
return success, message, updated_points
def save_order_to_history(login, order_details):
user_data_dict = load_user_data()
users = user_data_dict.get('users', [])
user_found = False
bishkek_tz = pytz.timezone('Asia/Bishkek')
order_timestamp_utc = datetime.now(timezone.utc).isoformat()
order_timestamp_local = datetime.now(bishkek_tz).strftime('%Y-%m-%d %H:%M:%S %Z%z')
required_keys = ['items', 'original_total', 'final_amount', 'redeemed_points', 'delivery_address', 'delivery_time_preference', 'payment_method']
if not isinstance(order_details, dict) or not all(key in order_details for key in required_keys):
logging.error(f"Некорректные детали заказа для сохранения в историю: {order_details}")
return False, "Некорректные детали заказа."
order_details_to_save = order_details.copy()
order_details_to_save['timestamp_utc'] = order_timestamp_utc
order_details_to_save['timestamp_local'] = order_timestamp_local
if 'earned_points' in order_details_to_save:
del order_details_to_save['earned_points']
for user in users:
if user.get('login') == login:
if 'order_history' not in user or not isinstance(user['order_history'], list):
user['order_history'] = []
MAX_HISTORY = 50
user['order_history'].append(order_details_to_save)
if len(user['order_history']) > MAX_HISTORY:
user['order_history'] = user['order_history'][-MAX_HISTORY:]
user_found = True
break
if user_found:
try:
save_user_data(user_data_dict)
logging.info(f"Заказ сохранен в историю для {login}")
return True, "Заказ сохранен в историю."
except Exception as e:
logging.error(f"Ошибка сохранения данных при добавлении заказа в историю для {login}: {e}")
return False, "Ошибка сервера при сохранении истории."
return False, "Пользователь не найден."
def get_order_history(login):
user_data_dict = load_user_data()
users = user_data_dict.get('users', [])
user = next((user for user in users if user.get('login') == login), None)
if user:
history = user.get('order_history', [])
try:
sorted_history = sorted(history, key=lambda x: x.get('timestamp_utc', ''), reverse=True)
return sorted_history
except Exception as e:
logging.error(f"Ошибка сортировки истории заказов для {login}: {e}")
return history
return []
def create_access_token(identity):
try:
payload = {
'exp': datetime.now(timezone.utc) + timedelta(days=30),
'iat': datetime.now(timezone.utc),
'sub': identity
}
token = jwt.encode(
payload,
app.config['SECRET_KEY'],
algorithm='HS256'
)
return token
except Exception as e:
logging.error(f"Ошибка создания JWT: {e}")
return None
def token_required(f):
@wraps(f)
def decorated(*args, **kwargs):
token = None
if 'Authorization' in request.headers:
auth_header = request.headers['Authorization']
parts = auth_header.split()
if len(parts) == 2 and parts[0].lower() == 'bearer':
token = parts[1]
else:
logging.warning(f"Некорректный формат заголовка Authorization: {auth_header}")
return jsonify({'message': 'Некорректный формат токена в заголовке'}), 401
if not token:
logging.info("Токен отсутствует в запросе")
return jsonify({'message': 'Токен отсутствует'}), 401
try:
data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])
current_user_login = data.get('sub')
if not current_user_login:
logging.error("Токен не содержит идентификатор пользователя (sub)")
raise jwt.InvalidTokenError("Отсутствует 'sub' в токене")
user_profile = get_user_profile_data(current_user_login)
if not user_profile:
logging.warning(f"Пользователь '{current_user_login}' из токена не найден в базе данных.")
pass
except jwt.ExpiredSignatureError:
logging.info("Срок действия токена истек")
return jsonify({'message': 'Срок действия токена истек'}), 401
except jwt.InvalidTokenError as e:
logging.error(f"Ошибка валидации токена: {e}")
return jsonify({'message': 'Недействительный токен'}), 401
except Exception as e:
logging.error(f"Непредвиденная ошибка при проверке токена: {e}", exc_info=True)
return jsonify({'message': 'Ошибка проверки токена'}), 500
return f(current_user_login, *args, **kwargs)
return decorated
@app.route('/')
def menu():
data = load_data()
products = data.get('products', [])
categories = data.get('categories', [])
stoplist_raw = data.get('stoplist', {})
category_counts = get_category_counts(products)
news_list = data.get('news', [])
qr_code_filename = data.get('qr_code')
current_time_utc = datetime.now(timezone.utc)
active_stoplist = {}
needs_save = False
for product_id, stop_info in stoplist_raw.items():
if isinstance(stop_info.get('until'), datetime):
if stop_info['until'] > current_time_utc:
active_stoplist[product_id] = stop_info
else:
needs_save = True
logging.info(f"Запись стоп-листа для продукта {product_id} истекла и не будет передана в шаблон.")
else:
logging.warning(f"Некорректная запись (не datetime) в stoplist_raw для ID {product_id}: {stop_info}")
if needs_save:
data['stoplist'] = active_stoplist
save_data(data)
stoplist_for_template = {
k: {
'until': v['until'].isoformat(),
'minutes': v.get('minutes', 0)
}
for k, v in active_stoplist.items()
}
def get_expiry_datetime(news_item):
expiry_str = news_item.get('expiry')
if expiry_str:
try:
dt = datetime.fromisoformat(expiry_str.replace('Z', '+00:00'))
if dt.tzinfo is None: return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
except (ValueError, TypeError):
return None
return None
now_utc_aware = datetime.now(timezone.utc)
news_for_template = sorted(
news_list,
key=lambda item: get_expiry_datetime(item) or datetime.max.replace(tzinfo=timezone.utc),
reverse=True
)
news_for_template = [item for item in news_for_template if not get_expiry_datetime(item) or get_expiry_datetime(item) > now_utc_aware]
qr_code_url = None
if qr_code_filename:
qr_code_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{qr_code_filename}"
menu_html = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ресторан Премиум</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=Nunito:wght@400;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 {
--primary-color: #FFD700;
--secondary-color: #D4AF37;
--accent-color: #EF4444;
--success-color: #22C55E;
--background-dark: #1A1A1A;
--background-medium: #2D2D2D;
--text-light: #F5F5F5;
--text-medium: #B0B0B0;
--box-shadow-light: rgba(255, 215, 0, 0.2);
--box-shadow-medium: rgba(255, 215, 0, 0.5);
--modal-background: rgba(0, 0, 0, 0.8);
--modal-backdrop-blur: blur(10px);
--button-hover-background: #FFD700;
--button-hover-text-color: #1A1A1A;
--input-border-color: #FFD700;
--input-focus-border-color: #FFF;
--input-background: rgba(255, 255, 255, 0.1);
--card-background: rgba(255, 255, 255, 0.05);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: 'Nunito', sans-serif;
background: linear-gradient(135deg, var(--background-dark), var(--background-medium));
color: var(--text-light);
line-height: 1.6;
overflow-x: hidden;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
padding-bottom: 80px;
}
.header {
text-align: center;
padding: 30px 0;
background: rgba(0, 0, 0, 0.7);
border-bottom: 2px solid var(--primary-color);
margin-bottom: 30px;
position: relative;
backdrop-filter: var(--modal-backdrop-blur);
}
.header-logo {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
border: 3px solid var(--primary-color);
transition: transform 0.5s ease;
}
.header-logo:hover {
transform: rotate(360deg) scale(1.1);
}
.header h1 {
font-size: 2.5rem;
color: var(--primary-color);
margin: 15px 0;
text-shadow: 0 0 10px var(--box-shadow-medium);
}
.motto {
font-size: 1.1rem;
color: var(--secondary-color);
font-style: italic;
}
.prep-time {
font-size: 0.9rem;
color: var(--text-medium);
}
.theme-toggle {
position: absolute;
top: 15px;
background: none;
border: none;
font-size: 1.6rem;
cursor: pointer;
color: var(--primary-color);
transition: transform 0.3s ease;
right: 20px;
}
.theme-toggle:hover, .profile-toggle:hover {
transform: scale(1.2);
color: #fff;
}
.filters-container {
margin: 20px 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
position: relative;
}
#allCategoriesButton {
position: fixed;
bottom: 80px;
left: 10px;
z-index: 999;
background-color: var(--background-medium);
}
.search-container {
margin: 20px 0;
text-align: center;
}
#search-input {
width: 80%;
max-width: 600px;
padding: 12px 15px;
font-size: 1rem;
border: 2px solid var(--input-border-color);
border-radius: 50px;
background: var(--input-background);
color: var(--text-light);
transition: all 0.3s ease;
}
#search-input:focus {
border-color: var(--input-focus-border-color);
box-shadow: 0 0 10px var(--box-shadow-medium);
outline: none;
}
.category-filter {
padding: 8px 18px;
border: 2px solid var(--primary-color);
border-radius: 25px;
background: rgba(255, 215, 0, 0.1);
color: var(--primary-color);
cursor: pointer;
transition: all 0.4s ease;
font-weight: 600;
font-size: 0.9rem;
white-space: nowrap;
}
.category-filter.active, .category-filter:hover {
background: var(--primary-color);
color: var(--background-dark);
box-shadow: 0 0 10px var(--box-shadow-medium);
}
.products-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
padding: 15px;
}
.product {
background: var(--card-background);
border-radius: 15px;
padding: 15px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
transition: all 0.4s ease;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.product-content {
flex-grow: 1;
cursor: pointer;
}
.product:hover {
transform: translateY(-8px) scale(1.03);
box-shadow: 0 10px 30px var(--box-shadow-light);
background: rgba(255, 255, 255, 0.1);
}
.product-image {
width: 100%;
aspect-ratio: 1;
background: var(--input-background);
border-radius: 12px;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s ease;
}
.product-image img {
max-width: 90%;
max-height: 90%;
object-fit: contain;
transition: transform 0.5s ease;
}
.product:hover .product-image img {
transform: scale(1.1);
}
.product h2 {
font-size: 1.2rem;
color: var(--primary-color);
margin: 10px 0;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-price {
font-size: 1.1rem;
color: var(--secondary-color);
font-weight: 700;
text-align: center;
margin-bottom: 8px;
}
.product-description {
font-size: 0.85rem;
color: var(--text-medium);
text-align: center;
margin-bottom: 15px;
overflow: hidden;
text-overflow: ellipsis;
height: 3.2em;
line-height: 1.6em;
}
.product-button {
display: block;
width: 100%;
padding: 10px;
border: 2px solid var(--primary-color);
border-radius: 25px;
background: transparent;
color: var(--primary-color);
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
text-decoration: none;
font-size: 0.9rem;
margin-top: auto;
}
.product-button:hover {
background: var(--button-hover-background);
color: var(--button-hover-text-color);
box-shadow: 0 0 10px var(--box-shadow-medium);
}
.product-actions {
margin-top: 10px;
min-height: 40px;
display: flex;
justify-content: center;
align-items: center;
}
.quantity-control {
align-items: center;
justify-content: center;
gap: 8px;
}
.quantity-control .quantity-button {
background: none;
border: 1px solid var(--primary-color);
color: var(--primary-color);
border-radius: 50%;
padding: 4px 8px;
font-size: 0.9rem;
margin: 0;
cursor: pointer;
transition: background-color 0.3s, color 0.3s;
line-height: 1;
height: 28px;
width: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.quantity-control .quantity-button:hover {
background: var(--primary-color);
color: var(--background-dark);
}
.quantity-control .quantity {
font-size: 1rem;
min-width: 15px;
text-align: center;
color: var(--text-light);
}
.stop-notice {
color: var(--accent-color);
font-size: 0.85rem;
text-align: center;
line-height: 1.3;
}
.stop-timer {
font-weight: bold;
}
.login-prompt {
color: var(--accent-color);
font-size: 0.85rem;
text-align: center;
line-height: 1.3;
}
.login-prompt a {
color: var(--accent-color);
text-decoration: underline;
}
.login-prompt a:hover {
color: var(--primary-color);
}
#cart-button {
position: fixed;
bottom: 80px;
right: 20px;
background: var(--secondary-color);
color: var(--background-dark);
border: none;
border-radius: 50%;
width: 60px;
height: 60px;
font-size: 1.8rem;
cursor: pointer;
box-shadow: 0 6px 15px var(--box-shadow-light);
transition: all 0.3s ease;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
#cart-button:hover {
transform: scale(1.1);
box-shadow: 0 10px 25px var(--box-shadow-medium);
}
.modal {
display: none;
position: fixed;
z-index: 1001;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: var(--modal-background);
backdrop-filter: var(--modal-backdrop-blur);
overflow-y: auto;
}
.modal-content {
background: var(--card-background);
margin: 2% auto;
padding: 20px;
border-radius: 15px;
width: 95%;
max-width: 700px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
border: 1px solid var(--primary-color);
animation: fadeInUp 0.4s ease-out;
overflow-y: auto;
max-height: 90vh;
position: relative;
}
@keyframes fadeInUp {
from { transform: translateY(30px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.close {
position: absolute;
top: 10px;
right: 15px;
float: none;
font-size: 1.8rem;
color: var(--primary-color);
cursor: pointer;
transition: all 0.3s ease;
opacity: 0.7;
line-height: 1;
}
.close:hover {
color: #fff;
opacity: 1;
transform: rotate(90deg);
}
.cart-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
border-bottom: 1px solid var(--box-shadow-light);
background: var(--card-background);
border-radius: 12px;
margin-bottom: 10px;
}
.cart-item img {
width: 60px;
height: 60px;
object-fit: contain;
border-radius: 8px;
margin-right: 15px;
}
.cart-item-details {
flex-grow: 1;
color: var(--text-light);
}
.cart-item-details strong {
font-size: 1.1rem;
color: var(--primary-color);
}
.cart-item-details p {
font-size: 0.9rem;
color: var(--text-medium);
}
.cart-total {
font-size: 1.3rem;
font-weight: 700;
color: var(--primary-color);
margin: 20px 0 10px 0;
text-align: right;
}
.quantity-input-container {
display: flex;
align-items: center;
max-width: 130px;
border: 2px solid var(--input-border-color);
border-radius: 25px;
overflow: hidden;
background: var(--input-background);
margin-bottom: 15px;
}
.quantity-button {
background: none;
border: none;
padding: 8px 12px;
cursor: pointer;
font-size: 1rem;
color: var(--primary-color);
transition: all 0.3s ease;
}
.quantity-button:hover {
background: rgba(255, 215, 0, 0.2);
}
.quantity-input {
width: 50px;
padding: 8px;
border: none;
font-size: 1rem;
text-align: center;
background: transparent;
color: #fff;
-webkit-appearance: none;
-moz-appearance: textfield;
}
.quantity-input:focus {
outline: none;
}
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
#orderComment, #cartDeliveryTime, #cartAddressInput {
width: 100%;
padding: 12px;
border: 2px solid var(--input-border-color);
border-radius: 15px;
font-size: 0.9rem;
background: var(--input-background);
color: #fff;
transition: all 0.3s ease;
margin-bottom: 15px;
}
#orderComment, #cartAddressInput {
resize: vertical;
min-height: 60px;
}
#cartDeliveryTime {
appearance: none;
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23FFD700%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E');
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: .8em auto;
padding-right: 2.5rem;
}
#orderComment:focus, #cartDeliveryTime:focus, #cartAddressInput:focus {
border-color: var(--input-focus-border-color);
box-shadow: 0 0 10px var(--box-shadow-medium);
outline: none;
}
.clear-cart {
background: var(--accent-color);
border: none;
color: var(--text-light);
}
.clear-cart:hover {
background: #dc2626;
box-shadow: 0 0 15px rgba(220, 38, 38, 0.5);
}
.cart-actions {
margin-top: 20px;
text-align: right;
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
}
.cart-actions .product-button {
width: auto;
display: inline-block;
}
.download-button {
background: var(--secondary-color);
border: none;
color: var(--background-dark);
}
.download-button:hover {
background: var(--primary-color);
}
#pointsSectionCart {
margin-top: 15px;
padding: 15px;
border-radius: 10px;
background: rgba(255, 215, 0, 0.05);
border: 1px solid var(--secondary-color);
}
#pointsSectionCart p {
font-size: 0.95rem;
color: var(--text-light);
margin-bottom: 8px;
}
#pointsSectionCart strong {
color: var(--primary-color);
}
.points-redeem-controls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
#pointsToRedeemInput {
width: 100px;
padding: 8px;
border: 2px solid var(--input-border-color);
border-radius: 10px;
background: var(--input-background);
color: #fff;
font-size: 0.9rem;
text-align: center;
}
#pointsToRedeemInput::placeholder {
color: var(--text-medium);
opacity: 0.7;
}
#applyPointsButton {
padding: 8px 15px;
font-size: 0.9rem;
}
#cancelPointsButton {
padding: 8px 15px;
font-size: 0.9rem;
background: var(--accent-color);
color: var(--text-light);
border-color: var(--accent-color);
display: none;
}
#cancelPointsButton:hover {
background: #dc2626;
border-color: #dc2626;
}
#cartDiscountInfo {
font-size: 0.9rem;
color: var(--success-color);
margin-top: 5px;
text-align: right;
font-weight: bold;
min-height: 1.2em;
}
.final-total {
font-size: 1.1rem;
color: var(--success-color);
font-weight: bold;
display: block;
margin-top: 5px;
}
#pointsMessageCart {
font-size: 0.85rem;
margin-top: 5px;
text-align: right;
min-height: 1.1em;
}
#pointsMessageCart.success {
color: var(--success-color);
}
#pointsMessageCart.error {
color: var(--accent-color);
}
.footer-info {
text-align: center;
margin-top: 30px;
padding: 15px;
color: var(--text-medium);
font-size: 0.8rem;
}
.profile-info p {
margin-bottom: 12px;
color: var(--text-light);
}
.auth-form label {
font-weight: 600;
margin-top: 15px;
color: var(--primary-color);
font-size: 0.9rem;
}
.auth-form input {
width: 100%;
padding: 10px;
margin-top: 5px;
margin-bottom: 10px;
border: 2px solid var(--input-border-color);
border-radius: 10px;
background: var(--input-background);
color: #fff;
transition: all 0.3s ease;
font-size: 0.9rem;
}
.auth-form input:focus {
border-color: var(--input-focus-border-color);
box-shadow: 0 0 8px var(--box-shadow-medium);
outline: none;
}
.auth-buttons {
text-align: center;
margin-top: 25px;
}
.logout-button {
background: var(--accent-color);
border: none;
color: var(--text-light);
}
.logout-button:hover {
background: #dc2626;
}
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: space-around;
padding: 8px 0;
border-top: 2px solid var(--primary-color);
backdrop-filter: var(--modal-backdrop-blur);
z-index: 1000;
}
.bottom-nav .nav-item {
color: var(--primary-color);
text-align: center;
cursor: pointer;
background: none;
border: none;
flex: 1;
padding: 5px;
}
.bottom-nav .nav-item i {
font-size: 1.6rem;
display: block;
margin-bottom: 3px;
}
.bottom-nav .nav-item span {
font-size: 0.8rem;
}
.bottom-nav .nav-item:hover {
color: #fff;
background: rgba(255, 215, 0, 0.1);
}
#newsContent, #orderHistoryContent {
color: var(--text-light);
padding: 10px;
}
.news-item, .order-history-item {
padding: 12px;
margin-bottom: 15px;
border-bottom: 1px solid var(--box-shadow-light);
background: var(--card-background);
border-radius: 12px;
}
.news-item h3, .order-history-item strong {
color: var(--primary-color);
margin-top: 0;
margin-bottom: 8px;
font-size: 1.1rem;
}
.news-item p, .order-history-item p, .order-history-item li {
margin: 4px 0;
font-size: 0.9rem;
color: var(--text-light);
}
.order-history-item p > strong {
color: var(--primary-color);
min-width: 130px;
display: inline-block;
}
.order-history-item p > span {
color: var(--text-light);
}
.news-item img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin-bottom: 8px;
}
.order-history-item ul {
list-style: none;
padding-left: 15px;
font-size: 0.9em;
margin-top: 5px;
border-left: 1px solid var(--secondary-color);
}
.order-history-item ul li {
margin-bottom: 3px;
}
.order-history-item ul li i {
color: var(--text-medium);
font-style: normal;
display: block;
margin-left: 10px;
}
.order-history-item .order-total-info strong {
color: var(--secondary-color);
}
.order-history-item .order-total-info .final-amount {
color: var(--success-color);
}
.order-history-item .redeemed-points-info {
color: var(--accent-color);
}
.options-checkbox {
display: block;
margin-bottom: 8px;
color: var(--text-light);
font-size: 0.9rem;
cursor: pointer;
}
.options-checkbox input {
margin-right: 8px;
width: 16px;
height: 16px;
vertical-align: middle;
}
.logged-in-only { display: none; }
.logged-out-only { display: block; }
@media (max-width: 600px) {
.header h1 { font-size: 2rem; }
.products-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
.product h2 { font-size: 1.1rem; }
.modal-content { width: 95%; margin: 5% auto; }
.cart-item { flex-direction: column; align-items: flex-start; gap: 10px;}
.cart-item img { margin-right: 0; margin-bottom: 10px;}
.cart-actions { justify-content: center; }
.points-redeem-controls { flex-direction: column; align-items: stretch; }
#pointsToRedeemInput { width: 100%; }
#applyPointsButton { width: 100%; }
#cancelPointsButton { width: 100%; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{ logo_url }}" alt="Логотип" class="header-logo">
<h1>Чайхана "Emir"</h1>
<p class="motto">Встречаем с улыбкой, готовим с любовью!</p>
<p class="prep-time">Время готовки: 15-30 мин | Доставка: от 30 мин</p>
<button class="theme-toggle" onclick="toggleTheme()">
<i class="fas fa-moon"></i>
</button>
</div>
<div class="filters-container">
<button class="category-filter active" data-category="all">Все категории ({{ products|length }})</button>
{% for category in categories %}
<button class="category-filter" data-category="{{ category }}">{{ category }} ({{ category_counts.get(category, 0) }})</button>
{% endfor %}
</div>
<button id="allCategoriesButton" class="category-filter" onclick="showAllCategories()" style="display:none;">Все категории</button>
<div class="search-container">
<input type="text" id="search-input" placeholder="Найти изысканное блюдо...">
</div>
<div class="products-grid" id="products-grid">
{% for product in products %}
<div class="product" data-id="{{ loop.index0 }}">
<div class="product-content" onclick="openProductModal({{ loop.index0 }})"
data-name="{{ product['name']|lower }}"
data-description="{{ product['description']|lower }}"
data-category="{{ product.get('category', 'Без категории') }}">
{% if product.get('photos') and product['photos']|length > 0 %}
<div class="product-image">
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}?t={{ range(1000, 9999) | random }}"
alt="{{ product['name'] }}"
loading="lazy">
</div>
{% endif %}
<h2>{{ product['name'] }}</h2>
<div class="product-price">{{ product['price'] }} с</div>
<p class="product-description">{{ product['description'] }}</p>
</div>
<div class="product-actions" id="actions-{{ loop.index0 }}">
<div class="stop-notice" data-product-id="{{ loop.index0 }}" style="display: none;">
Извините, блюдо на стопе, будет готово через:
<span class="stop-timer" data-until=""></span>
</div>
<div class="quantity-control logged-in-only" data-product-id="{{ loop.index0 }}" style="display: none;">
<button class="quantity-button minus" onclick="changeProductQuantity(event, {{ loop.index0 }}, -1)">-</button>
<span class="quantity" id="quantity-{{ loop.index0 }}">0</span>
<button class="quantity-button plus" onclick="changeProductQuantity(event, {{ loop.index0 }}, 1)">+</button>
</div>
<p class="login-prompt logged-out-only" style="display: none;">
<a href="#" onclick="openLoginModal(); return false;">Авторизуйтесь</a>, чтобы добавить!
</p>
</div>
</div>
{% endfor %}
</div>
<div class="footer-info">
Наслаждайтесь вкусом премиум-класса
</div>
</div>
<div id="productModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('productModal')">×</span>
<div id="modalContent"></div>
</div>
</div>
<div id="optionsModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('optionsModal')">×</span>
<h2>Параметры "<span id="optionsModalProductName"></span>"</h2>
<input type="hidden" id="optionsModalProductId">
<div class="quantity-input-container">
<button class="quantity-button" onclick="changeOptionsQuantity(-1)">-</button>
<input type="number" id="quantityInput" class="quantity-input" min="1" value="1">
<button class="quantity-button" onclick="changeOptionsQuantity(1)">+</button>
</div>
<label for="optionsList">Доп. опции:</label>
<div id="optionsList" style="margin-bottom: 15px;"></div>
<label for="orderComment">Комментарий к блюду:</label>
<textarea id="orderComment" rows="3" placeholder="Ваши пожелания..."></textarea>
<button class="product-button" onclick="confirmAddToCart()">Добавить в корзину</button>
</div>
</div>
<div id="cartModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('cartModal')">×</span>
<h2>Ваша корзина</h2>
<div id="cartContent" style="margin-bottom: 20px;"></div>
<div class="logged-in-only">
<label for="cartDeliveryTime">Время доставки:</label>
<select id="cartDeliveryTime">
<option value="now">Как можно скорее</option>
{% for i in range(2, 13) %}
<option value="{{ i * 30 }}">{{ i * 30 }} минут</option>
{% endfor %}
</select>
<label for="cartAddressInput">Адрес доставки:</label>
<textarea id="cartAddressInput" rows="3" placeholder="Введите ваш полный адрес..." required></textarea>
</div>
<div id="pointsSectionCart" class="logged-in-only" style="display: none;">
<p>Доступно баллов: <strong id="availablePointsCart">0</strong></p>
<div class="points-redeem-controls">
<input type="number" id="pointsToRedeemInput" placeholder="Списать баллы" min="0" oninput="validatePointsInput(this)">
<button id="applyPointsButton" class="product-button" onclick="applyPoints()">Применить</button>
<button id="cancelPointsButton" class="product-button" onclick="cancelAppliedPoints()">Отменить скидку</button>
</div>
<p id="pointsMessageCart"></p>
<p id="cartDiscountInfo"></p>
</div>
<div class="cart-total">
<strong>Итого: <span id="cartTotal">0</span> с</strong>
</div>
<div class="cart-actions">
<button class="product-button clear-cart" onclick="clearCart()">Очистить</button>
<button class="product-button order-button logged-in-only" onclick="orderViaWhatsApp()">Заказать (WhatsApp)</button>
<button class="product-button order-button logged-in-only" onclick="showQRPayment()">Оплатить QR</button>
<p class="logged-out-only" style="margin-top: 10px; color: var(--text-medium);">
<a href="#" onclick="openLoginModal(); closeModal('cartModal'); return false;" style="color: var(--primary-color); text-decoration: underline;">Войдите</a>, чтобы оформить заказ.
</p>
</div>
</div>
</div>
<div id="qrModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('qrModal')">×</span>
<h2>Оплата QR-кодом</h2>
<div id="qrModalContent" style="text-align: center;">
{% if qr_code_url %}
<p>Отсканируйте код или скачайте его:</p>
<img src="{{ qr_code_url }}?t={{ range(1000, 9999) | random }}" alt="QR-код для оплаты" id="qrImage" style="max-width: 80%; height: auto; margin: 15px auto; display: block; border: 1px solid var(--input-border-color); border-radius: 5px;">
<div style="margin-top: 20px; display: flex; justify-content: center; gap: 15px; flex-wrap: wrap;">
<button class="product-button download-button" onclick="downloadQR('{{ qr_code_url }}', 'emir_qr_code.png')">Скачать QR</button>
<button class="product-button order-button" onclick="orderViaWhatsAppWithQR()">Подтвердить и отправить заказ</button>
</div>
{% else %}
<p>QR-код для оплаты не настроен администратором.</p>
<button class="product-button" onclick="closeModal('qrModal')">Закрыть</button>
{% endif %}
</div>
</div>
</div>
<div id="profileModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('profileModal')">×</span>
<h2>Профиль</h2>
<div id="profileContent" class="profile-info logged-in-only">
<p><strong>Логин:</strong> <span id="profileLogin"></span></p>
<p><strong>Баллы:</strong> <span id="profilePoints"></span></p>
<p><strong>Телефон:</strong> <span id="profilePhone"></span></p>
<p><strong>Адрес:</strong> <span id="profileAddress"></span></p>
<div style="margin-top: 20px; display: flex; flex-wrap: wrap; gap: 10px;">
<button class="product-button" onclick="openEditProfileModal()">Редактировать Тел/Адрес</button>
<button class="product-button" onclick="openOrderHistoryModal()">История заказов</button>
<button class="product-button logout-button" onclick="logout()">Выйти</button>
</div>
</div>
<div id="authContent" class="auth-buttons logged-out-only">
<button class="product-button" onclick="openRegisterModal()">Регистрация</button>
<button class="product-button" onclick="openLoginModal()">Войти</button>
</div>
</div>
</div>
<div id="registerModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('registerModal'); clearRegisterMessage();">×</span>
<h2>Регистрация</h2>
<form id="registerForm" class="auth-form">
<label for="registerLogin">Логин:</label>
<input type="text" id="registerLogin" name="registerLogin" required>
<label for="registerPassword">Пароль:</label>
<input type="password" id="registerPassword" name="registerPassword" required>
<button type="submit" class="product-button">Зарегистрироваться</button>
<div id="registerMessage" style="margin-top: 10px; color: var(--accent-color);"></div>
</form>
</div>
</div>
<div id="loginModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('loginModal'); clearLoginMessage();">×</span>
<h2>Вход</h2>
<form id="loginForm" class="auth-form">
<label for="loginUsername">Логин:</label>
<input type="text" id="loginUsername" name="loginUsername" required>
<label for="loginPassword">Пароль:</label>
<input type="password" id="loginPassword" name="loginPassword" required>
<button type="submit" class="product-button">Войти</button>
<div id="loginMessage" style="margin-top: 10px; color: var(--accent-color);"></div>
</form>
</div>
</div>
<div id="editProfileModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('editProfileModal'); clearEditProfileMessage();">×</span>
<h2>Редактировать профиль</h2>
<form id="editProfileForm" class="auth-form">
<label for="editPhone">Телефон:</label>
<input type="tel" id="editPhone" name="editPhone" required>
<label for="editAddress">Адрес:</label>
<input type="text" id="editAddress" name="editAddress" required>
<button type="submit" class="product-button">Сохранить</button>
<div id="editProfileMessage" style="margin-top: 10px;"></div>
</form>
</div>
</div>
<div id="orderHistoryModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('orderHistoryModal')">×</span>
<h2>История заказов</h2>
<div id="orderHistoryContent" style="max-height: 60vh; overflow-y: auto;">
<p>Загрузка...</p>
</div>
</div>
</div>
<button id="cart-button" onclick="openCartModal()" style="display:none;">
<i class="fas fa-shopping-cart"></i>
<span id="cart-count" style="position: absolute; top: 5px; right: 5px; background: var(--accent-color); color: white; border-radius: 50%; width: 20px; height: 20px; font-size: 0.8rem; line-height: 20px; text-align: center;">0</span>
</button>
<footer class="bottom-nav">
<a href="tel:+996500131380" class="nav-item">
<i class="fas fa-phone"></i>
<span>Звонок</span>
</a>
<button class="nav-item" onclick="openCashbackModal()">
<i class="fas fa-coins"></i>
<span>Кэшбэк</span>
</button>
<button class="nav-item" onclick="openProfileModal()">
<i class="fas fa-user-circle"></i>
<span>Профиль</span>
</button>
<button class="nav-item" onclick="openNewsModal()">
<i class="fas fa-newspaper"></i>
<span>Новости</span>
</button>
</footer>
<div id="cashbackModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('cashbackModal')">×</span>
<h2>Кэшбэк</h2>
<div class="logged-in-only">
<p>Вы получаете {{ cashback_percentage }}% кэшбэка баллами с каждого заказа!</p>
<p>Ваши баллы: <span id="cashbackPoints">0</span></p>
<p>Баллы можно списать в корзине при оформлении заказа (1 балл = 1 сом).</p>
</div>
<p class="logged-out-only">Получайте {{ cashback_percentage }}% кэшбэка баллами с каждого заказа! <a href="#" onclick="openLoginModal();closeModal('cashbackModal'); return false;" style="color: var(--primary-color); text-decoration: underline;">Авторизуйтесь</a>, чтобы копить и тратить баллы.</p>
</div>
</div>
<div id="newsModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('newsModal')">×</span>
<h2>Новости</h2>
<div id="newsContent">
{% if news_for_template %}
{% for news_item in news_for_template %}
<div class="news-item">
<h3>{{ news_item.title }}</h3>
{% if news_item.photo %}
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ news_item.photo }}?t={{ range(1000, 9999) | random }}" alt="Фото новости">
{% endif %}
<p>{{ news_item.text | safe }}</p>
{% if news_item.expiry %}
<p style="font-size: 0.8rem; color: var(--text-medium);" class="news-expiry" data-expiry-utc="{{ news_item.expiry }}">Актуально до: <span class="expiry-local-time">...</span></p>
{% endif %}
</div>
{% endfor %}
{% else %}
<p>Новостей пока нет.</p>
{% endif %}
</div>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.5.3/dist/umd/popper.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
<script>
const products = {{ products|tojson }};
const repoId = "{{ repo_id }}";
const initialStoplistData = {{ stoplist_data|tojson }};
const bishkekTz = 'Asia/Bishkek';
const cashbackPercentage = {{ cashback_percentage }};
let selectedProductIndex = null;
let currentOptionsQuantity = 1;
let userProfileData = null;
let productQuantities = {};
let stoplistTimers = {};
window.cartItemsForWhatsApp = [];
let pointsAppliedSuccessfully = 0;
let currentCartOriginalTotal = 0;
const API_BASE_URL = '';
function getToken() {
return localStorage.getItem('accessToken');
}
function saveToken(token) {
localStorage.setItem('accessToken', token);
}
function removeToken() {
localStorage.removeItem('accessToken');
}
async function fetchWithAuth(url, options = {}) {
const token = getToken();
const headers = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (options.body instanceof FormData) {
delete headers['Content-Type'];
}
try {
const response = await fetch(API_BASE_URL + url, {
...options,
headers: headers,
});
if (response.status === 401) {
console.warn('Получен статус 401 Unauthorized. Разлогиниваем.');
logout();
return Promise.reject({ status: 401, message: 'Unauthorized' });
}
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
const data = await response.json();
if (!response.ok) {
console.error(`HTTP error! status: ${response.status}`, data);
return Promise.reject({ status: response.status, message: data.message || `HTTP error ${response.status}` });
}
return data;
} else if (response.ok) {
console.log(`Запрос ${url} успешен со статусом ${response.status}, но ответ не JSON.`);
return { status: 'success', statusCode: response.status };
} else {
const errorText = await response.text();
console.error(`HTTP error! status: ${response.status}`, errorText);
return Promise.reject({ status: response.status, message: errorText || `HTTP error ${response.status}` });
}
} catch (error) {
console.error(`Сетевая ошибка или ошибка при обработке запроса к ${url}:`, error);
return Promise.reject({ status: 'network_error', message: error.message || 'Network error' });
}
}
function updateUIBasedOnLoginStatus() {
const isLoggedIn = !!getToken();
console.log("Updating UI, logged in:", isLoggedIn);
document.querySelectorAll('.logged-in-only').forEach(el => el.style.display = isLoggedIn ? (el.tagName === 'DIV' || el.tagName === 'FORM' || el.tagName === 'P' || el.tagName === 'LABEL' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' ? 'block' : 'inline-block') : 'none');
document.querySelectorAll('.logged-out-only').forEach(el => el.style.display = isLoggedIn ? 'none' : 'block');
products.forEach((product, index) => {
updateProductCardActions(index);
});
if (isLoggedIn && userProfileData) {
document.getElementById('profileLogin').textContent = userProfileData.login || 'N/A';
document.getElementById('profilePoints').textContent = userProfileData.points || 0;
document.getElementById('profilePhone').textContent = userProfileData.phone || '(Не указан)';
document.getElementById('profileAddress').textContent = userProfileData.address || '(Не указан)';
document.getElementById('cashbackPoints').textContent = userProfileData.points || 0;
const availablePointsCartSpan = document.getElementById('availablePointsCart');
if(availablePointsCartSpan) availablePointsCartSpan.textContent = userProfileData.points || 0;
const pointsSectionCart = document.getElementById('pointsSectionCart');
if(pointsSectionCart) pointsSectionCart.style.display = (isLoggedIn && getCart().length > 0) ? 'block' : 'none';
} else {
document.getElementById('profileLogin').textContent = '';
document.getElementById('profilePoints').textContent = '0';
document.getElementById('profilePhone').textContent = '';
document.getElementById('profileAddress').textContent = '';
document.getElementById('cashbackPoints').textContent = '0';
const pointsSectionCart = document.getElementById('pointsSectionCart');
if(pointsSectionCart) pointsSectionCart.style.display = 'none';
}
updateCartButton();
if (!isLoggedIn || !userProfileData) {
pointsAppliedSuccessfully = 0;
}
updatePointsUI();
}
function updateProductCardActions(productId) {
const actionsDiv = document.getElementById(`actions-${productId}`);
if (!actionsDiv) return;
const stopNoticeDiv = actionsDiv.querySelector('.stop-notice');
const quantityControlDiv = actionsDiv.querySelector('.quantity-control');
const loginPromptP = actionsDiv.querySelector('.login-prompt');
const quantitySpan = document.getElementById(`quantity-${productId}`);
if (stopNoticeDiv) stopNoticeDiv.style.display = 'none';
if (quantityControlDiv) quantityControlDiv.style.display = 'none';
if (loginPromptP) loginPromptP.style.display = 'none';
const isLoggedIn = !!getToken();
const stopInfo = initialStoplistData[String(productId)];
const isActiveStop = stopInfo && new Date(stopInfo.until) > new Date();
if (isActiveStop) {
if (stopNoticeDiv) {
stopNoticeDiv.style.display = 'block';
const timerSpan = stopNoticeDiv.querySelector('.stop-timer');
if (timerSpan) {
timerSpan.dataset.until = stopInfo.until;
}
}
} else {
if (isLoggedIn) {
if (quantityControlDiv) quantityControlDiv.style.display = 'flex';
if (quantitySpan) quantitySpan.textContent = productQuantities[productId] || 0;
} else {
if (loginPromptP) loginPromptP.style.display = 'block';
}
}
}
async function fetchUserProfile() {
if (!getToken()) {
console.log("Нет токена, профиль не загружаем.");
updateUIBasedOnLoginStatus();
return null;
}
try {
console.log("Загрузка профиля...");
const data = await fetchWithAuth('/profile');
userProfileData = data;
console.log("Профиль загружен:", userProfileData);
loadCartQuantities();
updateUIBasedOnLoginStatus();
return userProfileData;
} catch (error) {
console.error('Ошибка загрузки профиля:', error);
if (error.status === 401) {
console.log("Токен недействителен или истек при загрузке профиля.");
} else {
alert(`Не удалось загрузить профиль: ${error.message || 'Проверьте соединение'}`);
logout();
}
return null;
}
}
function openEditProfileModal() {
if (!userProfileData) return;
closeModal('profileModal');
document.getElementById('editPhone').value = userProfileData.phone || '';
document.getElementById('editAddress').value = userProfileData.address || '';
clearEditProfileMessage();
document.getElementById('editProfileModal').style.display = 'block';
}
function getCart() {
try {
const cartData = localStorage.getItem('cart');
if (!cartData) {
return [];
}
const cart = JSON.parse(cartData);
return Array.isArray(cart) ? cart : [];
} catch (e) {
console.error("Ошибка чтения корзины из localStorage:", e);
return [];
}
}
function saveCart(cart) {
if (!Array.isArray(cart)) {
console.error("Попытка сохранить некорректные данные корзины:", cart);
return;
}
localStorage.setItem('cart', JSON.stringify(cart));
updateCartButton();
updateQuantitiesFromCart(cart);
}
function updateQuantitiesFromCart(cart) {
const newQuantities = {};
cart.forEach(item => {
if (item.productIndex !== undefined) {
newQuantities[item.productIndex] = (newQuantities[item.productIndex] || 0) + item.quantity;
}
});
productQuantities = newQuantities;
products.forEach((p, index) => {
const qtySpan = document.getElementById(`quantity-${index}`);
if(qtySpan) {
qtySpan.textContent = productQuantities[index] || 0;
}
});
}
function loadCartQuantities() {
const cart = getCart();
updateQuantitiesFromCart(cart);
}
function clearCart() {
localStorage.removeItem('cart');
closeModal('cartModal');
productQuantities = {};
window.cartItemsForWhatsApp = [];
pointsAppliedSuccessfully = 0;
currentCartOriginalTotal = 0;
updatePointsUI();
updateUIBasedOnLoginStatus();
}
function updateCartButton() {
const cart = getCart();
const cartButton = document.getElementById('cart-button');
const cartCountSpan = document.getElementById('cart-count');
const totalQuantity = cart.reduce((sum, item) => sum + (item.quantity || 0), 0);
if (!cartButton || !cartCountSpan) return;
if (totalQuantity > 0 && getToken()) {
cartButton.style.display = 'flex';
cartCountSpan.textContent = totalQuantity;
} else {
cartButton.style.display = 'none';
cartCountSpan.textContent = '0';
}
}
function updateCartTotalDisplay() {
const cartTotalSpan = document.getElementById('cartTotal');
const discountInfoP = document.getElementById('cartDiscountInfo');
if (!cartTotalSpan || !discountInfoP) return;
const finalTotal = Math.max(0, currentCartOriginalTotal - pointsAppliedSuccessfully);
if (pointsAppliedSuccessfully > 0) {
discountInfoP.textContent = `Скидка баллами: -${pointsAppliedSuccessfully} с`;
cartTotalSpan.innerHTML = `
<span style="text-decoration: line-through; color: var(--text-medium); font-size: 1rem; margin-right: 10px;">${currentCartOriginalTotal} с</span>
<span class="final-total">${finalTotal} с</span>
`;
} else {
discountInfoP.textContent = '';
cartTotalSpan.textContent = `${currentCartOriginalTotal} с`;
}
}
function openCartModal() {
if (!getToken()) {
openLoginModal();
return;
}
const cart = getCart();
const cartContent = document.getElementById('cartContent');
let total = 0;
window.cartItemsForWhatsApp = [];
pointsAppliedSuccessfully = 0;
currentCartOriginalTotal = 0;
updatePointsUI();
if (cart.length === 0) {
cartContent.innerHTML = '<p style="text-align: center; color: var(--text-medium);">Корзина пуста</p>';
} else {
cartContent.innerHTML = cart.map(item => {
const product = item.productIndex !== undefined ? products[item.productIndex] : null;
if (!product) {
console.warn("Продукт для элемента корзины не найден:", item);
return '';
}
const photo = product.photos && product.photos.length > 0 ? product.photos[0] : '';
const optionsTotal = (item.options || []).reduce((sum, opt) => sum + (opt.price || 0), 0);
const basePrice = typeof item.basePrice === 'number' ? item.basePrice : 0;
let effectiveBasePrice = basePrice;
let containerPrice = 0;
if (product && product.has_container && product.container_price > 0) {
containerPrice = product.container_price;
effectiveBasePrice += containerPrice;
}
const itemTotal = (effectiveBasePrice + optionsTotal) * item.quantity;
total += itemTotal;
window.cartItemsForWhatsApp.push({
...item,
itemTotal: itemTotal,
has_container: product?.has_container,
container_price: containerPrice,
product_base_price: basePrice // Store original product base price
});
return `
<div class="cart-item" data-cart-item-id="${item.id}">
${photo ? `<img src="https://huggingface.co/datasets/${repoId}/resolve/main/photos/${photo}?t=${Date.now()}" alt="${item.name}">` : '<div style="width: 60px; height: 60px; background: var(--input-background); border-radius: 8px; margin-right: 15px; flex-shrink: 0;"></div>'}
<div class="cart-item-details">
<strong>${item.name}</strong>
<p>${basePrice} с ${containerPrice > 0 ? ` (+${containerPrice}с конт.)` : ''} × ${item.quantity}${
(item.options && item.options.length > 0) ? '<br>Опции: ' + item.options.map(o => `${o.name} (+${o.price} с)`).join(', ') : ''
}${
item.comment ? '<br>Комментарий: ' + item.comment : ''
}</p>
</div>
<span>${itemTotal} с</span>
<button onclick="removeFromCart('${item.id}')" style="background: none; border: none; color: var(--accent-color); font-size: 1.4rem; cursor: pointer; margin-left: 10px; padding: 5px;" title="Удалить из корзины">&times;</button>
</div>
`;
}).join('');
}
currentCartOriginalTotal = total;
updateCartTotalDisplay();
const availablePointsCartSpan = document.getElementById('availablePointsCart');
const pointsSectionCart = document.getElementById('pointsSectionCart');
if(userProfileData && availablePointsCartSpan && pointsSectionCart){
availablePointsCartSpan.textContent = userProfileData.points || 0;
pointsSectionCart.style.display = cart.length > 0 ? 'block' : 'none';
} else if (pointsSectionCart) {
pointsSectionCart.style.display = 'none';
}
document.getElementById('cartDeliveryTime').value = 'now';
const addressInput = document.getElementById('cartAddressInput');
if(addressInput) addressInput.value = userProfileData?.address || '';
document.getElementById('cartModal').style.display = 'block';
}
function removeFromCart(cartItemId) {
let cart = getCart();
const itemIndex = cart.findIndex(item => item.id === cartItemId);
if (itemIndex > -1) {
const removedItem = cart[itemIndex];
cart.splice(itemIndex, 1);
saveCart(cart);
if (pointsAppliedSuccessfully > 0) {
console.log("Сброс примененных баллов из-за удаления товара.");
pointsAppliedSuccessfully = 0;
}
openCartModal();
if (removedItem.productIndex !== undefined) {
updateProductCardActions(removedItem.productIndex);
}
if (cart.length === 0) {
const pointsSectionCart = document.getElementById('pointsSectionCart');
if (pointsSectionCart) pointsSectionCart.style.display = 'none';
}
}
}
function updatePointsUI() {
const pointsInput = document.getElementById('pointsToRedeemInput');
const applyButton = document.getElementById('applyPointsButton');
const cancelButton = document.getElementById('cancelPointsButton');
const pointsMessage = document.getElementById('pointsMessageCart');
const discountInfo = document.getElementById('cartDiscountInfo');
if (!pointsInput || !applyButton || !cancelButton || !pointsMessage || !discountInfo) return;
pointsMessage.textContent = '';
pointsMessage.className = '';
if (pointsAppliedSuccessfully > 0) {
pointsInput.value = pointsAppliedSuccessfully;
pointsInput.disabled = true;
applyButton.style.display = 'none';
cancelButton.style.display = 'inline-block';
discountInfo.textContent = `Скидка баллами: -${pointsAppliedSuccessfully} с`;
} else {
pointsInput.value = '';
pointsInput.disabled = false;
applyButton.style.display = 'inline-block';
cancelButton.style.display = 'none';
discountInfo.textContent = '';
}
updateCartTotalDisplay();
const availablePointsCartSpan = document.getElementById('availablePointsCart');
if(availablePointsCartSpan && userProfileData) {
availablePointsCartSpan.textContent = userProfileData.points || 0;
}
}
function validatePointsInput(input) {
const maxPoints = userProfileData ? Math.floor(userProfileData.points || 0) : 0;
const maxPossibleRedeem = Math.min(maxPoints, Math.floor(currentCartOriginalTotal));
let value = parseInt(input.value) || 0;
if (value < 0) {
value = 0;
}
if (value > maxPossibleRedeem) {
value = maxPossibleRedeem;
}
input.value = value;
}
async function applyPoints() {
const pointsInput = document.getElementById('pointsToRedeemInput');
const applyButton = document.getElementById('applyPointsButton');
const pointsMessage = document.getElementById('pointsMessageCart');
if (!pointsInput || !applyButton || !pointsMessage) return;
const pointsToRedeem = parseInt(pointsInput.value) || 0;
const availablePoints = userProfileData ? Math.floor(userProfileData.points || 0) : 0;
const maxPossibleRedeem = Math.min(availablePoints, Math.floor(currentCartOriginalTotal));
pointsMessage.textContent = '';
pointsMessage.className = '';
if (pointsToRedeem <= 0) {
pointsMessage.textContent = "Введите положительное количество баллов.";
pointsMessage.className = 'error';
return;
}
if (pointsToRedeem > availablePoints) {
pointsMessage.textContent = `Недостаточно баллов. Доступно: ${availablePoints}`;
pointsMessage.className = 'error';
pointsInput.value = availablePoints;
return;
}
if (pointsToRedeem > maxPossibleRedeem) {
pointsMessage.textContent = `Максимум можно списать ${maxPossibleRedeem} баллов для этого заказа.`;
pointsMessage.className = 'error';
pointsInput.value = maxPossibleRedeem;
return;
}
applyButton.disabled = true;
pointsMessage.textContent = 'Применяем баллы...';
pointsMessage.className = '';
try {
console.log(`Попытка списания ${pointsToRedeem} баллов через /redeem_points...`);
const redeemResult = await fetchWithAuth('/redeem_points', {
method: 'POST',
body: JSON.stringify({ points: pointsToRedeem })
});
if (redeemResult.status === 'success') {
console.log("Баллы успешно списаны сервером:", redeemResult);
pointsAppliedSuccessfully = pointsToRedeem;
if (userProfileData) {
userProfileData.points = redeemResult.new_balance;
}
updatePointsUI();
const availablePointsCartSpan = document.getElementById('availablePointsCart');
if(availablePointsCartSpan) availablePointsCartSpan.textContent = userProfileData.points || 0;
const profilePointsSpan = document.getElementById('profilePoints');
if(profilePointsSpan) profilePointsSpan.textContent = userProfileData.points || 0;
const cashbackPointsSpan = document.getElementById('cashbackPoints');
if(cashbackPointsSpan) cashbackPointsSpan.textContent = userProfileData.points || 0;
pointsMessage.textContent = 'Баллы успешно применены!';
pointsMessage.className = 'success';
} else {
throw new Error(redeemResult.message || 'Не удалось применить баллы на сервере');
}
} catch (error) {
console.error('Ошибка применения/списания баллов:', error);
pointsAppliedSuccessfully = 0;
pointsMessage.textContent = `Ошибка: ${error.message || 'Не удалось применить баллы'}.`;
pointsMessage.className = 'error';
updatePointsUI();
await fetchUserProfile();
} finally {
applyButton.disabled = false;
}
}
async function cancelAppliedPoints() {
const cancelButton = document.getElementById('cancelPointsButton');
const pointsMessage = document.getElementById('pointsMessageCart');
if (!cancelButton || !pointsMessage || pointsAppliedSuccessfully <= 0) return;
const pointsToReturn = pointsAppliedSuccessfully;
cancelButton.disabled = true;
pointsMessage.textContent = 'Отменяем скидку...';
pointsMessage.className = '';
try {
console.log(`Попытка возврата ${pointsToReturn} баллов через /earn_points...`);
const returnResult = await fetchWithAuth('/earn_points', {
method: 'POST',
body: JSON.stringify({ points: pointsToReturn })
});
if (returnResult.status === 'success') {
console.log("Баллы успешно возвращены сервером:", returnResult);
pointsAppliedSuccessfully = 0;
if (userProfileData) {
userProfileData.points = returnResult.new_balance;
}
updatePointsUI();
const availablePointsCartSpan = document.getElementById('availablePointsCart');
if(availablePointsCartSpan) availablePointsCartSpan.textContent = userProfileData.points || 0;
const profilePointsSpan = document.getElementById('profilePoints');
if(profilePointsSpan) profilePointsSpan.textContent = userProfileData.points || 0;
const cashbackPointsSpan = document.getElementById('cashbackPoints');
if(cashbackPointsSpan) cashbackPointsSpan.textContent = userProfileData.points || 0;
pointsMessage.textContent = 'Скидка отменена.';
pointsMessage.className = 'success';
} else {
throw new Error(returnResult.message || 'Не удалось вернуть баллы на сервере');
}
} catch (error) {
console.error('Ошибка отмены скидки/возврата баллов:', error);
pointsMessage.textContent = `Ошибка отмены: ${error.message || 'Не удалось вернуть баллы'}.`;
pointsMessage.className = 'error';
} finally {
cancelButton.disabled = false;
}
}
function openProductModal(index) {
loadProductDetails(index);
document.getElementById('productModal').style.display = "block";
}
function loadProductDetails(index) {
fetch(API_BASE_URL + '/product/' + index)
.then(response => {
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.text();
})
.then(data => {
document.getElementById('modalContent').innerHTML = data;
initializeSwiper('#productModal');
})
.catch(error => {
console.error('Ошибка загрузки деталей продукта:', error);
document.getElementById('modalContent').innerHTML = '<p>Не удалось загрузить информацию о блюде.</p>';
});
}
function openOptionsModal(productIndex) {
if (!getToken()) {
openLoginModal();
return;
}
selectedProductIndex = productIndex;
const product = products[productIndex];
if (!product) {
console.error("Продукт не найден для индекса:", productIndex);
return;
}
document.getElementById('optionsModalProductId').value = productIndex;
document.getElementById('optionsModalProductName').textContent = product.name;
const optionsList = document.getElementById('optionsList');
optionsList.innerHTML = (product.options && product.options.length > 0) ?
product.options.map(option => `
<label class="options-checkbox">
<input type="checkbox" class="option-checkbox" data-name="${option.name}" data-price="${option.price || 0}">
${option.name} (+${option.price || 0} с)
</label>
`).join('') : '<p>Нет дополнительных опций</p>';
currentOptionsQuantity = 1;
document.getElementById('quantityInput').value = currentOptionsQuantity;
document.getElementById('orderComment').value = '';
document.getElementById('optionsModal').style.display = 'block';
}
function changeOptionsQuantity(change) {
const input = document.getElementById('quantityInput');
let currentVal = parseInt(input.value) || 1;
currentOptionsQuantity = Math.max(1, currentVal + change);
input.value = currentOptionsQuantity;
}
function confirmAddToCart() {
const productIndex = parseInt(document.getElementById('optionsModalProductId').value);
if (isNaN(productIndex) || productIndex < 0 || productIndex >= products.length) {
console.error("Неверный productIndex в модалке опций");
return;
}
const product = products[productIndex];
const quantity = parseInt(document.getElementById('quantityInput').value) || 1;
if (quantity <= 0) {
alert("Укажите количество больше 0");
return;
}
let cart = getCart();
const selectedOptions = Array.from(document.querySelectorAll('#optionsList .option-checkbox:checked'))
.map(cb => ({
name: cb.dataset.name,
price: parseFloat(cb.dataset.price) || 0
}));
const comment = document.getElementById('orderComment').value.trim();
const optionsString = selectedOptions.map(o => o.name).sort().join('-');
const cartItemId = `${productIndex}-${optionsString}-${Date.now()}`;
cart.push({
id: cartItemId,
productIndex: productIndex,
name: product.name,
basePrice: product.price,
quantity: quantity,
options: selectedOptions,
comment: comment
});
saveCart(cart);
closeModal('optionsModal');
updateProductCardActions(productIndex);
console.log("Товар добавлен:", cart[cart.length-1]);
console.log("Количества:", productQuantities);
updateCartButton();
if (pointsAppliedSuccessfully > 0) {
console.log("Сброс примененных баллов из-за добавления товара.");
pointsAppliedSuccessfully = 0;
updatePointsUI();
}
}
function changeProductQuantity(event, index, change) {
event.stopPropagation();
if (!getToken()) {
openLoginModal();
return;
}
let currentQuantity = productQuantities[index] || 0;
let newQuantity = Math.max(0, currentQuantity + change);
let cartChanged = false;
if (currentQuantity === 0 && newQuantity === 1) {
openOptionsModal(index);
} else if (newQuantity === 0 && currentQuantity > 0) {
let cart = getCart();
const initialLength = cart.length;
cart = cart.filter(item => item.productIndex !== index);
if (cart.length < initialLength) {
console.log(`Количество стало 0, удаляем товар ${products[index]?.name} (индекс ${index}) из корзины.`);
saveCart(cart);
updateProductCardActions(index);
cartChanged = true;
} else {
productQuantities[index] = 0;
updateProductCardActions(index);
}
} else if (newQuantity > 0) {
let cart = getCart();
const itemIndexToUpdate = cart.findIndex(item => item.productIndex === index);
if(itemIndexToUpdate > -1) {
if (cart[itemIndexToUpdate].quantity !== newQuantity) {
cart[itemIndexToUpdate].quantity = newQuantity;
saveCart(cart);
updateProductCardActions(index);
cartChanged = true;
}
} else {
console.warn("Не найден элемент в корзине для обновления количества > 0");
openOptionsModal(index);
}
}
if (cartChanged && pointsAppliedSuccessfully > 0) {
console.log("Сброс примененных баллов из-за изменения количества товара.");
pointsAppliedSuccessfully = 0;
updatePointsUI();
}
}
async function placeOrder(paymentMethod) {
console.log(`Начало оформления заказа (${paymentMethod})`);
const cart = getCart();
if (cart.length === 0) {
alert("Корзина пуста!");
return;
}
let currentProfile = userProfileData;
if (!currentProfile) {
currentProfile = await fetchUserProfile();
if (!currentProfile) {
alert("Не удалось получить данные пользователя. Пожалуйста, войдите снова.");
return;
}
}
const deliveryAddressInput = document.getElementById('cartAddressInput');
const deliveryAddress = deliveryAddressInput ? deliveryAddressInput.value.trim() : '';
if (!deliveryAddress) {
alert("Пожалуйста, укажите адрес доставки в корзине.");
if(deliveryAddressInput) deliveryAddressInput.focus();
return;
}
const userPhone = currentProfile.phone;
if (!userPhone) {
alert("Пожалуйста, укажите ваш телефон в профиле (в разделе 'Редактировать Тел/Адрес').");
openEditProfileModal();
return;
}
const orderItemsForMsg = window.cartItemsForWhatsApp.length > 0 ? window.cartItemsForWhatsApp : getCart().map(item => {
const product = item.productIndex !== undefined ? products[item.productIndex] : null;
if (!product) return null;
const optionsTotal = (item.options || []).reduce((sum, opt) => sum + (opt.price || 0), 0);
const basePrice = typeof item.basePrice === 'number' ? item.basePrice : 0;
let effectiveBasePrice = basePrice;
let containerPrice = 0;
if (product && product.has_container && product.container_price > 0) {
containerPrice = product.container_price;
effectiveBasePrice += containerPrice;
}
const itemTotal = (effectiveBasePrice + optionsTotal) * item.quantity;
return { ...item, itemTotal: itemTotal, has_container: product?.has_container, container_price: containerPrice, product_base_price: basePrice };
}).filter(item => item !== null);
const originalTotal = orderItemsForMsg.reduce((sum, item) => sum + (item.itemTotal || 0), 0);
const pointsToRedeem = pointsAppliedSuccessfully;
const finalTotal = Math.max(0, originalTotal - pointsToRedeem);
const deliveryTimeSelected = document.getElementById('cartDeliveryTime')?.value || 'now';
const deliveryText = deliveryTimeSelected === 'now' ? 'Как можно скорее' : `Через ${deliveryTimeSelected} мин`;
let orderText = `*Новый заказ (${paymentMethod === 'qr' ? 'Оплата QR' : 'Оплата при получении'})* %0A`;
orderText += `*Адрес доставки:* ${deliveryAddress}%0A`;
orderText += `*Телефон клиента:* ${userPhone}%0A`;
orderText += `*Время доставки:* ${deliveryText}%0A`;
orderText += `---%0A`;
orderItemsForMsg.forEach((item, index) => {
const optionsText = (item.options && item.options.length > 0)
? ` (${item.options.map(o => `${o.name}${o.price > 0 ? ` +${o.price}с` : ''}`).join(', ')})`
: '';
const commentText = item.comment ? ` [Коммент: ${item.comment}]` : '';
let priceBreakdown = `${item.product_base_price}с`; // Use stored product base price
if (item.has_container && item.container_price > 0) {
priceBreakdown += ` + ${item.container_price}с конт.`;
}
orderText += `${index + 1}. *${item.name}*${optionsText} - (${priceBreakdown}) × ${item.quantity} = *${item.itemTotal}с*${commentText}%0A`;
});
orderText += `---%0A`;
orderText += `*Сумма заказа:* ${originalTotal} с%0A`;
if (pointsToRedeem > 0) {
orderText += `*Списано баллов:* ${pointsToRedeem}%0A`;
}
orderText += `*Итого к оплате:* ${finalTotal} с%0A`;
orderText += `---%0A`;
orderText += `*Логин клиента:* ${currentProfile.login}%0A`;
console.log("Формирование ссылки WhatsApp...");
const whatsappUrl = `https://api.whatsapp.com/send?phone=+996500131380&text=${orderText}`;
try {
console.log("Попытка открытия WhatsApp через <a target='_blank'>:", whatsappUrl);
const link = document.createElement('a');
link.href = whatsappUrl;
link.target = '_blank';
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log("Клик по ссылке WhatsApp инициирован.");
} catch (error) {
console.error("Ошибка при попытке открыть WhatsApp через ссылку:", error);
alert("Не удалось автоматически открыть WhatsApp. Пожалуйста, проверьте настройки браузера или откройте приложение вручную.");
}
(async () => {
try {
const pointsToEarn = Math.floor(originalTotal * (cashbackPercentage / 100));
if (pointsToEarn > 0) {
console.log(`(Фон) Попытка начисления ${pointsToEarn} баллов кешбэка...`);
try {
const earnResult = await fetchWithAuth('/earn_points', {
method: 'POST',
body: JSON.stringify({ points: pointsToEarn })
});
console.log("(Фон) Результат начисления кешбэка:", earnResult);
if (userProfileData && earnResult.status === 'success' && earnResult.new_balance !== undefined) {
userProfileData.points = earnResult.new_balance;
}
} catch (earnError) {
console.error('(Фон) Ошибка начисления кешбэка:', earnError);
}
} else {
console.log("(Фон) Кешбэк не начисляется.");
}
} catch (backgroundError) {
console.error("(Фон) Ошибка при выполнении фоновых задач:", backgroundError);
}
})();
console.log("Очистка корзины и закрытие модалок после инициирования перехода в WhatsApp...");
clearCart();
closeModal('cartModal');
if (paymentMethod === 'qr') {
closeModal('qrModal');
}
console.log("Переход в WhatsApp инициирован, UI очищен.");
}
function orderViaWhatsApp() {
placeOrder('whatsapp');
}
function orderViaWhatsAppWithQR() {
placeOrder('qr');
}
function showQRPayment() {
document.getElementById('qrModal').style.display = 'block';
}
async function downloadQR(qrImageUrl, filename = 'qr_code.png') {
if (!qrImageUrl) {
console.error("URL QR-кода отсутствует.");
alert("Не удалось найти QR-код для скачивания.");
return;
}
console.log(`Запрос на скачивание QR: ${qrImageUrl}`);
try {
const response = await fetch(qrImageUrl);
if (!response.ok) {
throw new Error(`Не удалось загрузить QR-код (Статус: ${response.status})`);
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(objectUrl);
console.log("Скачивание QR инициировано.");
} catch (error) {
console.error("Ошибка скачивания QR:", error);
alert(`Не удалось скачать QR-код: ${error.message}`);
}
}
function initializeSwiper(modalSelector) {
try {
const modalElement = document.querySelector(modalSelector);
if (!modalElement) return;
const swiperContainer = modalElement.querySelector('.swiper-container');
if (swiperContainer && swiperContainer.querySelectorAll('.swiper-slide').length > 0 && !swiperContainer.swiper) {
new Swiper(swiperContainer, {
slidesPerView: 1,
spaceBetween: 20,
loop: swiperContainer.querySelectorAll('.swiper-slide').length > 1,
grabCursor: true,
pagination: {
el: swiperContainer.querySelector('.swiper-pagination'),
clickable: true
},
navigation: {
nextEl: swiperContainer.querySelector('.swiper-button-next'),
prevEl: swiperContainer.querySelector('.swiper-button-prev')
},
zoom: { maxRatio: 3 },
preloadImages: false,
lazy: true,
});
console.log("Swiper инициализирован для", modalSelector);
}
} catch (e) {
console.error("Ошибка инициализации Swiper:", e);
}
}
function closeModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = "none";
}
if (modalId === 'optionsModal') {
currentOptionsQuantity = 1;
selectedProductIndex = null;
document.getElementById('optionsModalProductId').value = '';
document.getElementById('optionsModalProductName').textContent = '';
const optionsListDiv = document.getElementById('optionsList');
optionsListDiv.innerHTML = '';
}
if (modalId === 'loginModal') clearLoginMessage();
if (modalId === 'registerModal') clearRegisterMessage();
if (modalId === 'editProfileModal') clearEditProfileMessage();
if (modalId === 'cartModal') {
window.cartItemsForWhatsApp = [];
pointsAppliedSuccessfully = 0;
updatePointsUI();
currentCartOriginalTotal = 0;
}
}
window.onclick = function(event) {
if (event.target.classList.contains('modal')) {
closeModal(event.target.id);
}
}
function toggleTheme() {
const icon = document.querySelector('.theme-toggle i');
if (!icon) return;
const isDark = document.body.classList.toggle('dark-theme');
icon.classList.toggle('fa-moon', !isDark);
icon.classList.toggle('fa-sun', isDark);
console.log("Смена темы (TODO: Реализовать)");
}
function filterProducts() {
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
const activeCategoryButton = document.querySelector('.category-filter.active');
const activeCategory = activeCategoryButton ? activeCategoryButton.dataset.category : 'all';
const grid = document.getElementById('products-grid');
let hasVisibleProducts = false;
grid.querySelectorAll('.product').forEach(productContainer => {
const contentDiv = productContainer.querySelector('.product-content');
if (!contentDiv) return;
const name = contentDiv.getAttribute('data-name') || '';
const description = contentDiv.getAttribute('data-description') || '';
const category = contentDiv.getAttribute('data-category') || 'Без категории';
const matchesSearch = searchTerm === '' || name.includes(searchTerm) || description.includes(searchTerm);
const matchesCategory = activeCategory === 'all' || category === activeCategory;
if (matchesSearch && matchesCategory) {
productContainer.style.display = 'flex';
hasVisibleProducts = true;
} else {
productContainer.style.display = 'none';
}
});
}
document.getElementById('search-input').addEventListener('input', filterProducts);
document.querySelectorAll('.category-filter').forEach(filter => {
filter.addEventListener('click', function() {
document.querySelectorAll('.category-filter').forEach(f => f.classList.remove('active'));
this.classList.add('active');
filterProducts();
const allCatsButton = document.getElementById('allCategoriesButton');
if (this.dataset.category !== 'all') {
allCatsButton.style.display = 'block';
} else {
allCatsButton.style.display = 'none';
}
});
});
function showAllCategories() {
document.querySelectorAll('.category-filter').forEach(f => f.classList.remove('active'));
const allButton = document.querySelector('.category-filter[data-category="all"]');
if(allButton) allButton.classList.add('active');
filterProducts();
document.getElementById('allCategoriesButton').style.display = 'none';
}
function openLoginModal() { closeModal('profileModal'); clearLoginMessage(); document.getElementById('loginModal').style.display = 'block'; }
function openRegisterModal() { closeModal('profileModal'); clearRegisterMessage(); document.getElementById('registerModal').style.display = 'block'; }
function openProfileModal() { document.getElementById('profileModal').style.display = 'block'; }
function openCashbackModal() { document.getElementById('cashbackModal').style.display = 'block'; }
function openNewsModal() {
formatNewsDates();
document.getElementById('newsModal').style.display = 'block';
}
function clearLoginMessage() { document.getElementById('loginMessage').textContent = ''; }
function clearRegisterMessage() { document.getElementById('registerMessage').textContent = ''; }
function clearEditProfileMessage() { document.getElementById('editProfileMessage').textContent = ''; }
$('#registerForm').on('submit', async function(event) {
event.preventDefault();
const formData = $(this).serializeArray();
const data = {};
formData.forEach(item => data[item.name] = item.value);
const messageDiv = $('#registerMessage');
messageDiv.css('color', 'var(--accent-color)');
messageDiv.text('Регистрация...');
if (!data.registerLogin || !data.registerPassword) {
messageDiv.text('Логин и пароль обязательны!');
return;
}
try {
const response = await fetch(API_BASE_URL + '/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
login: data.registerLogin,
password: data.registerPassword
})
});
const result = await response.json();
if (response.ok && result.status === 'success') {
messageDiv.text('Регистрация успешна. Теперь войдите.');
messageDiv.css('color', 'var(--primary-color)');
$(this).trigger('reset');
setTimeout(() => {
closeModal('registerModal');
openLoginModal();
}, 1500);
} else {
messageDiv.text('Ошибка: ' + (result.message || 'Неизвестная ошибка'));
}
} catch (error) {
console.error("Ошибка регистрации fetch:", error);
messageDiv.text('Ошибка сети или сервера.');
}
});
$('#loginForm').on('submit', async function(event) {
event.preventDefault();
const formData = $(this).serializeArray();
const data = {};
formData.forEach(item => data[item.name] = item.value);
const messageDiv = $('#loginMessage');
messageDiv.css('color', 'var(--accent-color)');
messageDiv.text('Вход...');
if (!data.loginUsername || !data.loginPassword) {
messageDiv.text('Введите логин и пароль!');
return;
}
try {
const response = await fetch(API_BASE_URL + '/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login: data.loginUsername, password: data.loginPassword })
});
const result = await response.json();
if (response.ok && result.status === 'success' && result.access_token) {
saveToken(result.access_token);
userProfileData = result.user_profile;
console.log("Токен сохранен, профиль получен:", userProfileData);
closeModal('loginModal');
loadCartQuantities();
updateUIBasedOnLoginStatus();
} else {
removeToken();
userProfileData = null;
productQuantities = {};
updateUIBasedOnLoginStatus();
messageDiv.text('Ошибка: ' + (result.message || 'Неверный логин или пароль'));
}
} catch (error) {
console.error("Ошибка входа fetch:", error);
removeToken();
userProfileData = null;
productQuantities = {};
updateUIBasedOnLoginStatus();
messageDiv.text('Ошибка сети или сервера.');
}
});
$('#editProfileForm').on('submit', async function(event) {
event.preventDefault();
const messageDiv = $('#editProfileMessage');
messageDiv.text('Сохранение...');
messageDiv.css('color', 'var(--text-medium)');
const phone = $('#editPhone').val().trim();
const address = $('#editAddress').val().trim();
if (!phone || !address) {
messageDiv.text('Телефон и адрес не могут быть пустыми.');
messageDiv.css('color', 'var(--accent-color)');
return;
}
try {
const result = await fetchWithAuth('/update_profile', {
method: 'POST',
body: JSON.stringify({ phone: phone, address: address })
});
messageDiv.text('Профиль обновлен.');
messageDiv.css('color', 'var(--primary-color)');
if (userProfileData) {
userProfileData.phone = phone;
userProfileData.address = address;
}
$('#profilePhone').text(phone);
$('#profileAddress').text(address);
setTimeout(() => {
closeModal('editProfileModal');
}, 1500);
} catch (error) {
console.error("Ошибка обновления профиля:", error);
messageDiv.text(`Ошибка: ${error.message || 'Не удалось обновить профиль'}`);
messageDiv.css('color', 'var(--accent-color)');
}
});
function logout() {
console.log("Выход пользователя...");
removeToken();
userProfileData = null;
productQuantities = {};
clearCart();
updateUIBasedOnLoginStatus();
closeModal('profileModal');
closeModal('cartModal');
closeModal('editProfileModal');
closeModal('orderHistoryModal');
closeModal('optionsModal');
console.log("Пользователь вышел");
}
document.addEventListener('DOMContentLoaded', () => {
console.log("DOM загружен. Проверяем токен и инициализируем...");
initializeStoplistTimers();
fetchUserProfile();
});
function formatRemainingTime(untilISO) {
try {
const now = new Date();
const until = new Date(untilISO);
const diffMs = until - now;
if (diffMs <= 0) return null;
const diffSec = Math.floor(diffMs / 1000);
const minutes = Math.floor(diffSec / 60);
const seconds = diffSec % 60;
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
} catch (e) {
console.error("Ошибка форматирования времени стоп-листа:", e, "ISO:", untilISO);
return "?:??";
}
}
function updateDisplayForStopTimers() {
document.querySelectorAll('.product').forEach((productDiv, index) => {
const productId = String(index);
const stopInfo = initialStoplistData[productId];
const timerSpan = productDiv.querySelector(`.stop-timer`);
if (timerSpan && timerSpan.closest('.stop-notice') && timerSpan.closest('.stop-notice').style.display !== 'none') {
if (stopInfo) {
const remainingTimeStr = formatRemainingTime(stopInfo.until);
if (remainingTimeStr !== null) {
timerSpan.textContent = remainingTimeStr;
} else {
console.log(`Таймер для продукта ${productId} истек.`);
delete initialStoplistData[productId];
if (stoplistTimers[productId]) {
clearInterval(stoplistTimers[productId]);
delete stoplistTimers[productId];
}
updateProductCardActions(productId);
}
} else {
updateProductCardActions(productId);
}
} else if (!stopInfo && stoplistTimers[productId]){
clearInterval(stoplistTimers[productId]);
delete stoplistTimers[productId];
updateProductCardActions(productId);
} else if (!stopInfo) {
updateProductCardActions(productId);
}
});
}
function initializeStoplistTimers() {
Object.values(stoplistTimers).forEach(clearInterval);
stoplistTimers = {};
console.log("Инициализация таймеров стоп-листа...");
const globalTimerInterval = setInterval(updateDisplayForStopTimers, 1000);
stoplistTimers['global'] = globalTimerInterval;
updateDisplayForStopTimers();
}
async function openOrderHistoryModal() {
const historyContent = document.getElementById('orderHistoryContent');
historyContent.innerHTML = '<p>Загрузка...</p>';
document.getElementById('orderHistoryModal').style.display = 'block';
try {
const historyData = await fetchWithAuth('/order_history');
if (Array.isArray(historyData)) {
if (historyData.length === 0) {
historyContent.innerHTML = '<p>У вас пока нет заказов в истории.</p>';
} else {
historyContent.innerHTML = historyData.map(order => {
const localTimeStr = order.timestamp_local || 'Дата неизвестна';
const originalTotal = order.original_total || 0;
const finalAmount = order.final_amount !== undefined ? order.final_amount : originalTotal;
const redeemedPoints = order.redeemed_points || 0;
const paymentMethodText = { 'qr': 'QR-код', 'whatsapp': 'При получении' }[order.payment_method] || 'Не указан';
const deliveryTimeText = order.delivery_time_preference === 'now' ? 'Как можно скорее' : `Через ${order.delivery_time_preference} мин`;
return `
<div class="order-history-item">
<p><strong>Дата:</strong> <span>${localTimeStr}</span></p>
<p><strong>Адрес:</strong> <span>${order.delivery_address || '(не указан)'}</span></p>
<p class="order-total-info"><strong>Сумма заказа:</strong> <span>${originalTotal} с</span></p>
${redeemedPoints > 0 ? `<p class="redeemed-points-info"><strong>Списано баллов:</strong> <span>${redeemedPoints}</span></p>` : ''}
<p class="order-total-info"><strong>Итого к оплате:</strong> <strong class="final-amount">${finalAmount} с</strong></p>
<p><strong>Способ оплаты:</strong> <span>${paymentMethodText}</span></p>
<p><strong>Доставка:</strong> <span>${deliveryTimeText}</span></p>
<p><strong>Состав заказа:</strong></p>
<ul>
${(order.items || []).map(item => {
const optionsText = (item.options && item.options.length > 0)
? ` (${item.options.map(o=>o.name).join(', ')})` : '';
const commentText = item.comment ? `<i>Коммент: "${item.comment}"</i>` : '';
return `<li>- ${item.name} (${item.quantity} шт.)${optionsText}${commentText}</li>`;
}).join('')}
</ul>
</div>
`}).join('');
}
} else {
throw new Error("Некорректный формат истории заказов");
}
} catch (error) {
console.error("Ошибка загрузки истории заказов:", error);
historyContent.innerHTML = `<p>Не удалось загрузить историю заказов. Ошибка: ${error.message || 'Проверьте соединение'}</p>`;
}
}
function formatNewsDates() {
const newsItems = document.querySelectorAll('.news-expiry');
newsItems.forEach(item => {
const utcDateString = item.dataset.expiryUtc;
const localTimeSpan = item.querySelector('.expiry-local-time');
if (utcDateString && localTimeSpan) {
try {
const date = new Date(utcDateString);
const options = { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' };
localTimeSpan.textContent = date.toLocaleDateString('ru-RU', options);
} catch (e) {
console.error("Ошибка форматирования даты новости:", e);
localTimeSpan.textContent = utcDateString;
}
}
});
}
</script>
</body>
</html>
'''
return render_template_string(
menu_html,
products=products,
categories=categories,
category_counts=category_counts,
stoplist_data=stoplist_for_template,
repo_id=REPO_ID,
qr_code_url=qr_code_url,
news_for_template=news_for_template,
logo_url=LOGO_URL,
cashback_percentage=CASHBACK_PERCENTAGE
)
@app.route('/product/<int:index>')
def product_detail(index):
data = load_data()
products = data.get('products', [])
if 0 <= index < len(products):
product = products[index]
else:
return jsonify({'status': 'error', 'message': 'Блюдо не найдено'}), 404
detail_html = '''
<h2 style="font-family: 'Cinzel', serif; font-size: 2rem; color: var(--primary-color); margin-bottom: 20px; text-align: center;">{{ product['name'] }}</h2>
<div class="swiper-container product-swiper" style="max-width: 500px; margin: 0 auto 25px; border-radius: 10px; overflow: hidden; background: rgba(0,0,0,0.2);">
<div class="swiper-wrapper">
{% if product.get('photos') and product['photos']|length > 0 %}
{% for photo in product['photos'] %}
<div class="swiper-slide swiper-lazy" style="display: flex; justify-content: center; align-items: center;">
<div class="swiper-zoom-container">
<img data-src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}?t={{ range(1000, 9999)|random }}"
class="swiper-lazy"
alt="{{ product['name'] }}"
style="max-width: 100%; max-height: 400px; object-fit: contain;">
</div>
<div class="swiper-lazy-preloader swiper-lazy-preloader-white"></div>
</div>
{% endfor %}
{% else %}
<div class="swiper-slide" style="height: 300px; display: flex; align-items: center; justify-content: center; color: var(--text-medium); background: var(--input-background);">
Фото отсутствует
</div>
{% endif %}
</div>
{% if product.get('photos') and product['photos']|length > 1 %}
<div class="swiper-pagination" style="bottom: 10px; --swiper-pagination-color: var(--primary-color);"></div>
<div class="swiper-button-next" style="color: var(--primary-color); right: 10px;"></div>
<div class="swiper-button-prev" style="color: var(--primary-color); left: 10px;"></div>
{% endif %}
</div>
<div style="padding: 0 10px;">
<p style="color: var(--text-medium);"><strong style="color: var(--text-light);">Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
<p style="color: var(--secondary-color); font-size: 1.5rem; font-weight: bold; margin: 10px 0;">Цена: {{ product['price'] }} с</p>
<p style="color: var(--text-medium); margin-bottom: 15px;"><strong style="color: var(--text-light);">Описание:</strong><br>{{ product['description'] | safe }}</p>
{% if product.get('options') and product['options']|length > 0 %}
<p style="color: var(--text-light); margin-bottom: 5px;"><strong>Возможные опции:</strong></p>
<ul style="color: var(--text-medium); list-style: none; padding-left: 0; font-size: 0.9rem;">
{% for option in product['options'] %}
<li>- {{ option.name }} (+{{ option.price or 0 }} c)</li>
{% endfor %}
</ul>
{% endif %}
{% if product.has_container and product.container_price > 0 %}
<p style="color: var(--text-light); margin-top: 10px; font-size: 0.9rem;">* К этому блюду будет добавлен контейнер (+{{ product.container_price }} c)</p>
{% endif %}
</div>
'''
return render_template_string(detail_html, product=product, repo_id=REPO_ID)
@app.route('/register', methods=['POST'])
def register():
data = request.get_json()
if not data:
return jsonify({'status': 'error', 'message': 'Некорректный запрос (ожидается JSON)'}), 400
login = data.get('login')
password = data.get('password')
if not login or not password:
return jsonify({'status': 'error', 'message': 'Логин и пароль обязательны'}), 400
success, message = register_user(login, password)
status_code = 201 if success else 400
return jsonify({'status': 'success' if success else 'error', 'message': message}), status_code
@app.route('/login', methods=['POST'])
def login():
data = request.get_json()
if not data:
return jsonify({'status': 'error', 'message': 'Некорректный запрос (ожидается JSON)'}), 400
login_attempt = data.get('login')
password_attempt = data.get('password')
if not login_attempt or not password_attempt:
return jsonify({'status': 'error', 'message': 'Требуется логин и пароль'}), 400
user_info = authenticate_user(login_attempt, password_attempt)
if user_info:
access_token = create_access_token(identity=user_info['login'])
if access_token:
profile_data = get_user_profile_data(user_info['login'])
return jsonify({
'status': 'success',
'access_token': access_token,
'user_profile': profile_data
})
else:
logging.error("Не удалось создать JWT токен после успешной аутентификации.")
return jsonify({'status': 'error', 'message': 'Ошибка сервера при создании сессии'}), 500
else:
return jsonify({'status': 'error', 'message': 'Неверный логин или пароль'}), 401
@app.route('/profile', methods=['GET'])
@token_required
def get_profile(current_user_login):
user_data = get_user_profile_data(current_user_login)
if user_data:
return jsonify(user_data)
else:
logging.error(f"Пользователь {current_user_login} из валидного токена не найден в базе get_profile.")
return jsonify({'message': 'Пользователь не найден'}), 404
@app.route('/update_profile', methods=['POST'])
@token_required
def update_profile(current_user_login):
data = request.get_json()
if not data:
return jsonify({'status': 'error', 'message': 'Ожидается JSON'}), 400
phone = data.get('phone', '').strip()
address = data.get('address', '').strip()
if not phone or not address:
return jsonify({'status': 'error', 'message': 'Телефон и адрес обязательны'}), 400
success, message = update_user_profile(current_user_login, phone, address)
status_code = 200 if success else 400
return jsonify({'status': 'success' if success else 'error', 'message': message}), status_code
@app.route('/redeem_points', methods=['POST'])
@token_required
def redeem_points(current_user_login):
data = request.get_json()
if not data:
return jsonify({'status': 'error', 'message': 'Ожидается JSON'}), 400
try:
points = int(data.get('points', 0))
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Некорректное количество баллов'}), 400
if points <= 0:
return jsonify({'status': 'error', 'message': 'Количество баллов для списания должно быть положительным'}), 400
success, message, new_balance = redeem_points_from_user(current_user_login, points)
status_code = 200 if success else 400
return jsonify({
'status': 'success' if success else 'error',
'message': message,
'new_balance': new_balance
}), status_code
@app.route('/earn_points', methods=['POST'])
@token_required
def earn_points(current_user_login):
data = request.get_json()
if not data:
return jsonify({'status': 'error', 'message': 'Ожидается JSON'}), 400
try:
points_raw = data.get('points', 0)
points = float(points_raw) if '.' in str(points_raw) else int(points_raw)
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Некорректное количество баллов'}), 400
if points <= 0:
return jsonify({'status': 'success', 'message': 'Баллы не изменены (сумма <= 0).'}), 200
success, message = add_points_to_user(current_user_login, points)
status_code = 200 if success else 500
new_balance = 0
if success:
user_data = get_user_profile_data(current_user_login)
if user_data:
new_balance = user_data.get('points', 0)
return jsonify({
'status': 'success' if success else 'error',
'message': message,
'new_balance': new_balance
}), status_code
@app.route('/place_order', methods=['POST'])
@token_required
def place_order_route(current_user_login):
return jsonify({'status': 'info', 'message': 'Этот эндпоинт больше не используется для оформления заказа клиентом.'}), 404
@app.route('/order_history', methods=['GET'])
@token_required
def get_order_history_route(current_user_login):
history = get_order_history(current_user_login)
return jsonify(history)
@app.route('/stoplist', methods=['GET', 'POST'])
def stoplist_route():
data = load_data()
products = data.get('products', [])
stoplist_dict = data.get('stoplist', {})
if request.method == 'POST':
action = request.form.get('action')
product_id_str = request.form.get('product_id')
if not product_id_str or not product_id_str.isdigit():
return jsonify({'status': 'error', 'message': 'Некорректный ID продукта'}), 400
product_index = int(product_id_str)
if not (0 <= product_index < len(products)):
return jsonify({'status': 'error', 'message': 'Продукт с таким ID не найден'}), 404
if action == 'add':
try:
minutes = int(request.form.get('minutes', 0))
if minutes <= 0:
raise ValueError("Время должно быть положительным")
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Некорректное время (требуется положительное число минут)'}), 400
until_datetime_utc = datetime.now(timezone.utc) + timedelta(minutes=minutes)
stoplist_dict[product_id_str] = {
'until': until_datetime_utc,
'minutes': minutes
}
data['stoplist'] = stoplist_dict
save_data(data)
return jsonify({
'status': 'success',
'message': f"Продукт {products[product_index]['name']} добавлен в стоп-лист на {minutes} минут.",
'productId': product_id_str,
'until': until_datetime_utc.isoformat(),
'minutes': minutes
}), 200
elif action == 'remove':
if product_id_str in stoplist_dict:
del stoplist_dict[product_id_str]
data['stoplist'] = stoplist_dict
save_data(data)
return jsonify({
'status': 'success',
'message': f"Продукт {products[product_index]['name']} снят со стоп-листа.",
'productId': product_id_str
}), 200
else:
return jsonify({'status': 'success', 'message': 'Продукт не найден в активном стоп-листе.'}), 200
else:
return jsonify({'status': 'error', 'message': 'Неизвестное действие'}), 400
stoplist_for_template_get = {
k: {
'until': v['until'].isoformat(),
'minutes': v.get('minutes', 0)
}
for k, v in stoplist_dict.items()
}
stoplist_html = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Стоп-лист</title>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
<style>
:root {
--primary-color: #FFD700;
--secondary-color: #D4AF37;
--accent-color: #EF4444;
--background-dark: #1A1A1A;
--background-medium: #2D2D2D;
--text-light: #F5F5F5;
--text-medium: #B0B0B0;
--box-shadow-light: rgba(255, 215, 0, 0.2);
--box-shadow-medium: rgba(255, 215, 0, 0.5);
--modal-background: rgba(0, 0, 0, 0.8);
--modal-backdrop-blur: blur(10px);
--button-hover-background: #FFD700;
--button-hover-text-color: #1A1A1A;
--input-border-color: #FFD700;
--input-focus-border-color: #FFF;
--input-background: rgba(255, 255, 255, 0.1);
--card-background: rgba(255, 255, 255, 0.05);
}
body {
font-family: 'Nunito', sans-serif;
background: linear-gradient(135deg, var(--background-dark), var(--background-medium));
color: var(--text-light);
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
font-weight: 600;
color: var(--primary-color);
margin-bottom: 20px;
text-align: center;
}
.search-container {
margin: 20px 0;
text-align: center;
}
#search-input-stoplist {
width: 80%;
max-width: 600px;
padding: 12px 15px;
font-size: 1rem;
border: 2px solid var(--input-border-color);
border-radius: 50px;
background: var(--input-background);
color: var(--text-light);
transition: all 0.3s ease;
margin-bottom: 20px;
}
#search-input-stoplist:focus {
border-color: var(--input-focus-border-color);
box-shadow: 0 0 10px var(--box-shadow-medium);
outline: none;
}
.product-list {
display: grid;
gap: 15px;
}
.product-item {
background: var(--card-background);
padding: 15px;
border-radius: 12px;
box-shadow: 0 6px 15px rgba(0,0,0,0.3);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.product-info {
flex-grow: 1;
min-width: 200px;
}
.product-controls {
flex-shrink: 0;
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.product-item h3 {
color: var(--primary-color);
margin: 0 0 5px 0;
font-size: 1.1rem;
}
button, .button-like {
padding: 8px 15px;
border: 2px solid var(--primary-color);
border-radius: 25px;
background: transparent;
color: var(--primary-color);
cursor: pointer;
transition: all 0.3s ease;
font-size: 0.85rem;
text-decoration: none;
display: inline-block;
text-align: center;
white-space: nowrap;
line-height: 1.2;
}
button:hover, .button-like:hover {
background: var(--button-hover-background);
color: var(--button-hover-text-color);
border-color: var(--button-hover-background);
}
.timer {
color: var(--accent-color);
font-weight: bold;
min-width: 60px;
text-align: left;
display: inline-block;
margin-left: 5px;
}
.stop-notice {
color: var(--accent-color);
font-size: 0.9rem;
display: inline;
}
.stop-form button {
background-color: rgba(255, 215, 0, 0.1);
}
.stop-form button:hover {
background-color: var(--primary-color);
color: var(--background-dark);
}
.remove-stop-button {
background: var(--secondary-color);
border: none;
color: var(--background-dark);
}
.remove-stop-button:hover {
background: var(--primary-color);
color: var(--background-dark);
border-color: var(--primary-color);
}
.status-container {
min-height: 30px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.stop-form {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.loading-indicator {
display: inline-block;
margin-left: 10px;
color: var(--text-medium);
font-size: 0.8rem;
}
</style>
</head>
<body>
<div class="container">
<h1>Управление стоп-листом</h1>
<div class="search-container">
<input type="text" id="search-input-stoplist" placeholder="Поиск блюда...">
</div>
<div class="product-list" id="product-list">
{% if products %}
{% for product in products %}
<div class="product-item" data-id="{{ loop.index0 }}" data-name="{{ product['name']|lower }}">
<div class="product-info">
<h3>{{ product['name'] }}</h3>
<div class="status-container" id="status-container-{{ loop.index0 }}">
<span class="loading-indicator">Загрузка статуса...</span>
</div>
</div>
<div class="product-controls">
</div>
</div>
{% endfor %}
{% else %}
<p style="text-align: center; color: var(--text-medium);">Список продуктов пуст. Добавьте продукты в админ-панели.</p>
{% endif %}
</div>
</div>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
let initialStoplist = {{ stoplist_page_data|tojson }};
const allProducts = {{ products|tojson }};
let stoplistPageTimers = {};
function formatStopTime(untilISO) {
if (!untilISO) return null;
try {
const now = new Date();
const until = new Date(untilISO);
const diffMs = until - now;
if (diffMs <= 0) return null;
const diffSecTotal = Math.floor(diffMs / 1000);
const minutes = Math.floor(diffSecTotal / 60);
const seconds = diffSecTotal % 60;
return `${minutes}м ${seconds}с`;
} catch (e) {
console.error("Ошибка форматирования времени стоп-листа:", e);
return "Ошибка";
}
}
function updateProductStopStatusUI(productId) {
const productItem = document.querySelector(`.product-item[data-id="${productId}"]`);
if (!productItem) return;
const statusContainer = productItem.querySelector(`#status-container-${productId}`);
const controlsContainer = productItem.querySelector('.product-controls');
if (!statusContainer || !controlsContainer) return;
const stopInfo = initialStoplist[productId];
const remainingTimeStr = stopInfo ? formatStopTime(stopInfo.until) : null;
statusContainer.innerHTML = '';
controlsContainer.innerHTML = '';
if (remainingTimeStr !== null && new Date(stopInfo.until) > new Date()) {
statusContainer.innerHTML = `
<span class="stop-notice">На стопе. Осталось:</span>
<span class="timer" data-until="${stopInfo.until}">${remainingTimeStr}</span>
`;
const removeButton = document.createElement('button');
removeButton.className = 'remove-stop-button';
removeButton.id = `remove-button-${productId}`;
removeButton.textContent = 'Снять стоп';
removeButton.onclick = function() { removeFromStoplist(productId, this); };
controlsContainer.appendChild(removeButton);
startStoplistPageTimer(productId);
} else {
if (initialStoplist[productId]) delete initialStoplist[productId];
if (stoplistPageTimers[productId]) {
clearInterval(stoplistPageTimers[productId]);
delete stoplistPageTimers[productId];
}
statusContainer.innerHTML = `
<form class="stop-form" data-id="${productId}">
<button type="button" onclick="addToStoplist('${productId}', 30, this)">30 м</button>
<button type="button" onclick="addToStoplist('${productId}', 60, this)">60 м</button>
<button type="button" onclick="addToStoplist('${productId}', 90, this)">90 м</button>
<input type="number" min="1" placeholder="Или введите минуты" style="width: 120px; padding: 6px; font-size: 0.8rem;" id="custom-minutes-${productId}">
<button type="button" onclick="addCustomStoplist('${productId}', this)">OK</button>
</form>
`;
}
}
function updateAllTimersOnPage() {
document.querySelectorAll('.product-item').forEach(item => {
const productId = item.dataset.id;
if (initialStoplist[productId]) {
const timerSpan = item.querySelector(`.timer[data-until]`);
if (timerSpan) {
const remainingTime = formatStopTime(initialStoplist[productId].until);
if (remainingTime !== null) {
timerSpan.textContent = remainingTime;
} else {
updateProductStopStatusUI(productId);
}
} else {
updateProductStopStatusUI(productId);
}
}
});
}
function startStoplistPageTimer(productId) {
if (!stoplistPageTimers[productId]) {
const timerSpan = document.querySelector(`.product-item[data-id="${productId}"] .timer`);
if (timerSpan && initialStoplist[productId]) {
const remainingTime = formatStopTime(initialStoplist[productId].until);
if(remainingTime) timerSpan.textContent = remainingTime;
else updateProductStopStatusUI(productId);
}
stoplistPageTimers[productId] = setInterval(() => {
const timerSpanInner = document.querySelector(`.product-item[data-id="${productId}"] .timer`);
if (timerSpanInner && initialStoplist[productId]) {
const remainingTimeInner = formatStopTime(initialStoplist[productId].until);
if (remainingTimeInner !== null) {
timerSpanInner.textContent = remainingTimeInner;
} else {
updateProductStopStatusUI(productId);
}
} else {
if(stoplistPageTimers[productId]) {
clearInterval(stoplistPageTimers[productId]);
delete stoplistPageTimers[productId];
}
updateProductStopStatusUI(productId);
}
}, 1000);
}
}
function addToStoplist(productId, minutes, buttonElement) {
setLoadingState(buttonElement, true);
$.ajax({
url: '/stoplist',
type: 'POST',
data: {
action: 'add',
product_id: productId,
minutes: minutes
},
success: function(response) {
if (response.status === 'success') {
initialStoplist[productId] = { until: response.until, minutes: response.minutes };
updateProductStopStatusUI(productId);
} else {
alert('Ошибка добавления в стоп-лист: ' + response.message);
}
},
error: function(jqXHR, textStatus, errorThrown) {
alert(`Ошибка сети или сервера (${jqXHR.status}): ${jqXHR.responseJSON?.message || errorThrown}`);
},
complete: function() {
setLoadingState(buttonElement?.closest('.stop-form')?.querySelector('button:last-of-type') || buttonElement, false);
}
});
}
function addCustomStoplist(productId, buttonElement) {
const input = document.getElementById(`custom-minutes-${productId}`);
const minutes = parseInt(input.value);
if (!minutes || minutes <= 0) {
alert("Введите положительное число минут.");
input.focus();
return;
}
addToStoplist(productId, minutes, buttonElement);
}
function removeFromStoplist(productId, buttonElement) {
setLoadingState(buttonElement, true);
$.ajax({
url: '/stoplist',
type: 'POST',
data: {
action: 'remove',
product_id: productId
},
success: function(response) {
if (response.status === 'success') {
delete initialStoplist[productId];
if (stoplistPageTimers[productId]) {
clearInterval(stoplistPageTimers[productId]);
delete stoplistPageTimers[productId];
}
updateProductStopStatusUI(productId);
} else {
if (response.message && response.message.includes("не найден")) {
delete initialStoplist[productId];
updateProductStopStatusUI(productId);
} else {
alert('Ошибка снятия со стоп-листа: ' + response.message);
}
}
},
error: function(jqXHR, textStatus, errorThrown) {
alert(`Ошибка сети или сервера (${jqXHR.status}): ${jqXHR.responseJSON?.message || errorThrown}`);
},
complete: function() {
setLoadingState(buttonElement, false);
}
});
}
function setLoadingState(button, isLoading) {
if (!button) return;
button.disabled = isLoading;
button.style.opacity = isLoading ? 0.6 : 1;
button.style.cursor = isLoading ? 'wait' : 'pointer';
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.product-item').forEach(item => {
const productId = item.dataset.id;
updateProductStopStatusUI(productId);
});
});
const productItems = Array.from(document.querySelectorAll('.product-item'));
document.getElementById('search-input-stoplist').addEventListener('input', function() {
const searchTerm = this.value.toLowerCase().trim();
productItems.forEach(item => {
const productName = item.dataset.name || '';
const matches = productName.includes(searchTerm);
item.style.display = matches ? 'flex' : 'none';
});
});
</script>
</body>
</html>
'''
return render_template_string(stoplist_html, products=products, stoplist_page_data=stoplist_for_template_get)
@app.route('/admin', methods=['GET', 'POST'])
def admin():
data = load_data()
products = data.get('products', [])
categories = data.get('categories', [])
stoplist = data.get('stoplist', {})
news_list = data.get('news', [])
qr_code_filename = data.get('qr_code')
if request.method == 'POST':
action = request.form.get('action')
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)
save_data(data)
else:
logging.warning(f"Попытка добавить пустую или существующую категорию: '{category_name}'")
return redirect(url_for('admin'))
elif action == 'delete_category':
try:
category_index = int(request.form.get('category_index'))
if 0 <= category_index < len(categories):
category_to_delete = categories.pop(category_index)
for product in products:
if product.get('category') == category_to_delete:
product['category'] = 'Без категории'
save_data(data)
logging.info(f"Категория '{category_to_delete}' удалена.")
else:
logging.warning("Попытка удалить категорию с неверным индексом.")
except (ValueError, TypeError):
logging.error("Некорректный индекс категории для удаления.")
return redirect(url_for('admin'))
elif action == 'move_category_up':
try:
category_index = int(request.form.get('category_index'))
if category_index > 0 and category_index < len(categories):
categories.insert(category_index - 1, categories.pop(category_index))
save_data(data)
else:
logging.warning("Невозможно переместить категорию вверх (неверный индекс).")
except (ValueError, TypeError):
logging.error("Некорректный индекс категории для перемещения вверх.")
return redirect(url_for('admin'))
elif action == 'move_category_down':
try:
category_index = int(request.form.get('category_index'))
if category_index >= 0 and category_index < len(categories) - 1:
categories.insert(category_index + 1, categories.pop(category_index))
save_data(data)
else:
logging.warning("Невозможно переместить категорию вниз (неверный индекс).")
except (ValueError, TypeError):
logging.error("Некорректный индекс категории для перемещения вниз.")
return redirect(url_for('admin'))
elif action == 'add':
try:
name = request.form.get('name', '').strip()
price_str = request.form.get('price', '0').replace(',', '.')
price = float(price_str)
description = request.form.get('description', '').strip()
category = request.form.get('category')
photos_files = request.files.getlist('photos')
option_names = request.form.getlist('option_names')
option_prices = request.form.getlist('option_prices')
has_container = request.form.get('has_container') == 'on'
container_price_str = request.form.get('container_price', '0').replace(',', '.')
container_price = float(container_price_str) if has_container else 0
if not name or price < 0 or (has_container and container_price < 0):
logging.error("Имя, цена (>=0) и цена контейнера (>=0, если выбран) обязательны.")
return redirect(url_for('admin'))
photos_list = []
options_list = []
if photos_files and HF_TOKEN_WRITE:
api = HfApi()
uploads_dir = 'uploads'
os.makedirs(uploads_dir, exist_ok=True)
for photo in photos_files[:10]:
if photo and photo.filename:
base, ext = os.path.splitext(secure_filename(photo.filename))
photo_filename_hf = f"photo_{int(time.time() * 1000)}_{len(photos_list)}{ext}"
temp_path = os.path.join(uploads_dir, photo_filename_hf)
try:
photo.save(temp_path)
api.upload_file(
path_or_fileobj=temp_path,
path_in_repo=f"photos/{photo_filename_hf}",
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Добавлено фото для блюда {name}"
)
photos_list.append(photo_filename_hf)
logging.info(f"Фото {photo_filename_hf} загружено на HF.")
except Exception as e:
logging.error(f"Ошибка загрузки фото {photo.filename} (-> {photo_filename_hf}) на HF: {e}")
finally:
if os.path.exists(temp_path):
try: os.remove(temp_path)
except OSError as rm_err: logging.error(f"Не удалось удалить временный файл {temp_path}: {rm_err}")
elif photos_files and not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN (write) не установлен. Фотографии не будут загружены на HF.")
for opt_name, opt_price_str in zip(option_names, option_prices):
opt_name = opt_name.strip()
opt_price_str = opt_price_str.replace(',', '.')
if opt_name and opt_price_str:
try:
opt_price = float(opt_price_str)
if opt_price >= 0:
options_list.append({'name': opt_name, 'price': opt_price})
else:
logging.warning(f"Цена опции '{opt_name}' должна быть неотрицательной. Пропуск.")
except ValueError:
logging.warning(f"Некорректная цена для опции '{opt_name}': {opt_price_str}. Пропуск.")
new_product_id = len(products)
new_product = {
'name': name,
'price': price,
'description': description,
'category': category if category in categories else 'Без категории',
'photos': photos_list,
'options': options_list,
'has_container': has_container,
'container_price': container_price if has_container else 0
}
products.append(new_product)
save_data(data)
logging.info(f"Добавлен новый продукт: {name}")
except Exception as e:
logging.error(f"Ошибка при добавлении продукта: {e}", exc_info=True)
return redirect(url_for('admin'))
elif action == 'edit':
try:
product_index = int(request.form.get('product_index'))
if not (0 <= product_index < len(products)):
logging.error("Неверный индекс продукта для редактирования.")
return redirect(url_for('admin'))
product_to_edit = products[product_index]
name = request.form.get('name', '').strip()
price_str = request.form.get('price', '0').replace(',', '.')
price = float(price_str)
description = request.form.get('description', '').strip()
category = request.form.get('category')
photos_files = request.files.getlist('photos')
existing_photos = request.form.getlist('existing_photos')
option_names = request.form.getlist('option_names')
option_prices = request.form.getlist('option_prices')
has_container = request.form.get('has_container') == 'on'
container_price_str = request.form.get('container_price', '0').replace(',', '.')
container_price = float(container_price_str) if has_container else 0
if not name or price < 0 or (has_container and container_price < 0):
logging.error("Имя, цена (>=0) и цена контейнера (>=0, если выбран) обязательны для редактирования.")
return redirect(url_for('admin'))
photos_list = existing_photos
options_list = []
if photos_files and HF_TOKEN_WRITE:
api = HfApi()
uploads_dir = 'uploads'
os.makedirs(uploads_dir, exist_ok=True)
for photo in photos_files[:max(0, 10 - len(photos_list))]:
if photo and photo.filename:
base, ext = os.path.splitext(secure_filename(photo.filename))
photo_filename_hf = f"photo_{int(time.time() * 1000)}_{len(photos_list)}{ext}"
temp_path = os.path.join(uploads_dir, photo_filename_hf)
try:
photo.save(temp_path)
api.upload_file(
path_or_fileobj=temp_path,
path_in_repo=f"photos/{photo_filename_hf}",
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Обновлено фото для блюда {name}"
)
photos_list.append(photo_filename_hf)
logging.info(f"Новое фото {photo_filename_hf} загружено на HF при редактировании.")
except Exception as e:
logging.error(f"Ошибка загрузки нового фото {photo_filename_hf} на HF при редактировании: {e}")
finally:
if os.path.exists(temp_path):
try: os.remove(temp_path)
except OSError as rm_err: logging.error(f"Не удалось удалить временный файл {temp_path}: {rm_err}")
elif photos_files and not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN (write) не установлен. Новые фотографии не будут загружены на HF.")
for opt_name, opt_price_str in zip(option_names, option_prices):
opt_name = opt_name.strip()
opt_price_str = opt_price_str.replace(',', '.')
if opt_name and opt_price_str:
try:
opt_price = float(opt_price_str)
if opt_price >= 0:
options_list.append({'name': opt_name, 'price': opt_price})
else:
logging.warning(f"Цена опции '{opt_name}' должна быть неотрицательной при редактировании. Пропуск.")
except ValueError:
logging.warning(f"Некорректная цена для опции '{opt_name}' при редактировании: {opt_price_str}. Пропуск.")
product_to_edit['name'] = name
product_to_edit['price'] = price
product_to_edit['description'] = description
product_to_edit['category'] = category if category in categories else 'Без категории'
product_to_edit['photos'] = photos_list
product_to_edit['options'] = options_list
product_to_edit['has_container'] = has_container
product_to_edit['container_price'] = container_price if has_container else 0
save_data(data)
logging.info(f"Продукт '{name}' (индекс {product_index}) обновлен.")
except Exception as e:
logging.error(f"Ошибка при редактировании продукта: {e}", exc_info=True)
return redirect(url_for('admin'))
elif action == 'delete':
try:
product_index = int(request.form.get('product_index'))
if 0 <= product_index < len(products):
deleted_product = products.pop(product_index)
logging.info(f"Удален продукт: {deleted_product.get('name')} (бывший индекс {product_index})")
product_id_str_deleted = str(product_index)
new_stoplist_after_delete = {}
current_stoplist = data.get('stoplist', {})
for pid_str, stop_info in current_stoplist.items():
try:
pid_int = int(pid_str)
if pid_int == product_index:
logging.info(f"Удалена запись стоп-листа для удаленного продукта {product_index}.")
continue
elif pid_int > product_index:
new_index_str = str(pid_int - 1)
new_stoplist_after_delete[new_index_str] = stop_info
logging.debug(f"Сдвинут индекс стоп-листа: {pid_str} -> {new_index_str}")
else:
new_stoplist_after_delete[pid_str] = stop_info
except ValueError:
logging.warning(f"Некорректный ID (не число) '{pid_str}' в стоп-листе при удалении продукта. Запись пропущена.")
data['stoplist'] = new_stoplist_after_delete
save_data(data)
else:
logging.warning("Попытка удалить продукт с неверным индексом.")
except (ValueError, TypeError):
logging.error("Некорректный индекс продукта для удаления.")
return redirect(url_for('admin'))
elif action == 'upload_qr':
qr_file = request.files.get('qr_file')
if qr_file and qr_file.filename and HF_TOKEN_WRITE:
try:
base, ext = os.path.splitext(secure_filename(qr_file.filename))
qr_filename_hf = f"payment_qr_{int(time.time())}{ext}"
uploads_dir = 'uploads'
os.makedirs(uploads_dir, exist_ok=True)
temp_path = os.path.join(uploads_dir, qr_filename_hf)
qr_file.save(temp_path)
api = HfApi()
api.upload_file(
path_or_fileobj=temp_path,
path_in_repo=qr_filename_hf,
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message="Обновлен QR-код для оплаты"
)
data['qr_code'] = qr_filename_hf
save_data(data)
logging.info(f"QR-код {qr_filename_hf} успешно загружен.")
except Exception as e:
logging.error(f"Ошибка загрузки QR-кода на HF: {e}", exc_info=True)
finally:
if 'temp_path' in locals() and os.path.exists(temp_path):
try: os.remove(temp_path)
except OSError as rm_err: logging.error(f"Не удалось удалить временный QR файл {temp_path}: {rm_err}")
elif qr_file and not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN (write) не установлен. QR-код не будет загружен на HF.")
else:
logging.warning("Файл QR-кода не был предоставлен или имеет некорректное имя.")
return redirect(url_for('admin'))
elif action == 'add_news':
try:
news_title = request.form.get('news_title', '').strip()
news_text = request.form.get('news_text', '').strip()
news_photo_file = request.files.get('news_photo')
expiry_days_str = request.form.get('expiry_days') or '0'
expiry_hours_str = request.form.get('expiry_hours') or '0'
expiry_minutes_str = request.form.get('expiry_minutes') or '0'
expiry_days = int(expiry_days_str)
expiry_hours = int(expiry_hours_str)
expiry_minutes = int(expiry_minutes_str)
if not news_title or not news_text:
logging.error("Заголовок и текст новости обязательны.")
return redirect(url_for('admin'))
news_photo_filename_hf = None
if news_photo_file and news_photo_file.filename and HF_TOKEN_WRITE:
try:
base, ext = os.path.splitext(secure_filename(news_photo_file.filename))
news_photo_filename_hf = f"news_{int(time.time() * 1000)}{ext}"
uploads_dir = 'uploads'
os.makedirs(uploads_dir, exist_ok=True)
temp_path = os.path.join(uploads_dir, news_photo_filename_hf)
news_photo_file.save(temp_path)
api = HfApi()
api.upload_file(
path_or_fileobj=temp_path,
path_in_repo=f"photos/{news_photo_filename_hf}",
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Добавлено фото для новости: {news_title}"
)
logging.info(f"Фото новости {news_photo_filename_hf} загружено.")
except Exception as e:
logging.error(f"Ошибка загрузки фото новости на HF: {e}")
news_photo_filename_hf = None
finally:
if 'temp_path' in locals() and os.path.exists(temp_path):
try: os.remove(temp_path)
except OSError as rm_err: logging.error(f"Не удалось удалить временный файл новости {temp_path}: {rm_err}")
elif news_photo_file and not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN (write) не установлен. Фото новости не будет загружено на HF.")
expiry_time_iso = None
total_delta_seconds = expiry_days * 86400 + expiry_hours * 3600 + expiry_minutes * 60
if total_delta_seconds > 0:
expiry_datetime_utc = datetime.now(timezone.utc) + timedelta(seconds=total_delta_seconds)
expiry_time_iso = expiry_datetime_utc.isoformat()
new_news_item = {
'title': news_title,
'text': news_text,
'photo': news_photo_filename_hf,
'expiry': expiry_time_iso
}
if 'news' not in data or not isinstance(data['news'], list):
data['news'] = []
data['news'].append(new_news_item)
save_data(data)
logging.info(f"Добавлена новость: {news_title}")
except ValueError:
logging.error("Некорректное значение времени для срока действия новости.")
except Exception as e:
logging.error(f"Ошибка при добавлении новости: {e}", exc_info=True)
return redirect(url_for('admin'))
elif action == 'delete_news':
try:
news_index = int(request.form.get('news_index'))
if 'news' in data and isinstance(data['news'], list) and 0 <= news_index < len(data['news']):
deleted_news = data['news'].pop(news_index)
logging.info(f"Новость '{deleted_news.get('title')}' удалена.")
save_data(data)
else:
logging.warning("Попытка удалить новость с неверным индексом.")
except (ValueError, TypeError):
logging.error("Некорректный индекс новости для удаления.")
return redirect(url_for('admin'))
qr_code_admin_url = None
if qr_code_filename:
qr_code_admin_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{qr_code_filename}"
def get_expiry_dt_admin(item):
exp_str = item.get('expiry')
if exp_str:
try: return datetime.fromisoformat(exp_str.replace('Z', '+00:00')).astimezone(timezone.utc)
except: return datetime.max.replace(tzinfo=timezone.utc)
return datetime.max.replace(tzinfo=timezone.utc)
news_list_with_indices = [{'index': i, **item} for i, item in enumerate(news_list)]
sorted_news_list_admin = sorted(news_list_with_indices, key=get_expiry_dt_admin, reverse=True)
admin_html = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Админ-панель</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=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
<style>
:root {
--primary-color: #FFD700;
--secondary-color: #D4AF37;
--accent-color: #EF4444;
--background-dark: #1A1A1A;
--background-medium: #2D2D2D;
--text-light: #F5F5F5;
--text-medium: #B0B0B0;
--box-shadow-light: rgba(255, 215, 0, 0.2);
--box-shadow-medium: rgba(255, 215, 0, 0.5);
--modal-background: rgba(0, 0, 0, 0.8);
--modal-backdrop-blur: blur(10px);
--button-hover-background: #FFD700;
--button-hover-text-color: #1A1A1A;
--input-border-color: #FFD700;
--input-focus-border-color: #FFF;
--input-background: rgba(255, 255, 255, 0.1);
--card-background: rgba(255, 255, 255, 0.05);
}
body {
font-family: 'Nunito', sans-serif;
background: linear-gradient(135deg, var(--background-dark), var(--background-medium));
color: var(--text-light);
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1, h2 {
font-weight: 600;
color: var(--primary-color);
margin-bottom: 20px;
border-bottom: 1px solid var(--secondary-color);
padding-bottom: 10px;
}
h1 { text-align: center; border-bottom: none;}
.form-section, .category-section, .product-section, .news-section {
background: var(--card-background);
padding: 20px;
border-radius: 15px;
box-shadow: 0 6px 15px rgba(0,0,0,0.3);
margin-bottom: 20px;
border: 1px solid var(--primary-color);
}
label {
display: block;
margin: 12px 0 3px;
color: var(--primary-color);
font-size: 0.9rem;
font-weight: 600;
}
input, select, textarea {
width: 100%;
padding: 10px;
border: 2px solid var(--input-border-color);
border-radius: 10px;
background: var(--input-background);
color: #fff;
font-size: 0.9rem;
transition: all 0.3s ease;
margin-bottom: 8px;
font-family: inherit;
}
input[type="file"] {
border-color: transparent;
padding: 5px;
color: var(--text-medium);
}
input[type="number"] {
-moz-appearance: textfield;
}
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="checkbox"] {
width: auto; /* Reset width for checkbox */
margin-right: 5px;
vertical-align: middle;
}
.checkbox-label { /* Label next to checkbox */
display: inline-block;
margin: 12px 0 3px 0;
vertical-align: middle;
}
input:focus, select:focus, textarea:focus {
border-color: var(--input-focus-border-color);
box-shadow: 0 0 8px var(--box-shadow-medium);
outline: none;
}
button, .button-like {
padding: 10px 20px;
border: 2px solid var(--primary-color);
border-radius: 25px;
background: transparent;
color: var(--primary-color);
cursor: pointer;
margin: 8px 5px 0 0;
transition: all 0.3s ease;
font-size: 0.9rem;
text-decoration: none;
display: inline-block;
text-align: center;
font-weight: 600;
}
button:hover, .button-like:hover {
background: var(--button-hover-background);
color: var(--button-hover-text-color);
border-color: var(--button-hover-background);
}
button[type="submit"] {
background: var(--secondary-color);
color: var(--background-dark);
border-color: var(--secondary-color);
}
button[type="submit"]:hover {
background: var(--primary-color);
border-color: var(--primary-color);
}
.delete-button {
background: var(--accent-color);
border: none;
color: var(--text-light);
padding: 8px 15px;
font-size: 0.85rem;
}
.delete-button:hover {
background: #dc2626;
border-color: #dc2626;
}
.edit-button {
background: var(--secondary-color);
border: none;
color: var(--background-dark);
padding: 8px 15px;
font-size: 0.85rem;
}
.edit-button:hover {
background: var(--primary-color);
border-color: var(--primary-color);
}
.category-list, .product-list, .news-list {
margin-top: 15px;
}
.category-item, .product-item, .news-item {
display: flex;
align-items: flex-start;
padding: 12px;
background: rgba(255, 255, 255, 0.03);
border-radius: 12px;
margin-bottom: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
gap: 15px;
flex-wrap: wrap;
}
.item-content {
flex-grow: 1;
color: var(--text-light);
font-size: 0.9rem;
min-width: 250px;
}
.item-actions {
flex-shrink: 0;
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.item-actions form {
margin: 0;
}
.category-item span { flex-grow: 1; font-weight: 600; }
.product-item img, .news-item img {
max-width: 60px;
max-height: 60px;
object-fit: contain;
border-radius: 8px;
background: var(--input-background);
flex-shrink: 0;
}
.product-details span, .news-details span {
display: block;
margin-bottom: 3px;
color: var(--text-medium);
font-size: 0.85rem;
line-height: 1.4;
}
.product-details strong, .news-details strong {
color: var(--text-light);
font-weight: 600;
}
.product-details .name { font-size: 1.1rem; color: var(--text-light); margin-bottom: 5px;}
.product-details .price { color: var(--secondary-color); font-weight: bold; font-size: 1rem;}
.product-details .category { font-style: italic; }
.product-details .description { white-space: pre-wrap; }
.news-details .title { font-size: 1.1rem; color: var(--text-light); margin-bottom: 5px;}
.options-list, .photos-list {
margin: 15px 0;
padding-left: 15px;
border-left: 2px solid var(--secondary-color);
}
.options-list label, .photos-list label {
margin-bottom: 10px;
font-size: 1rem;
color: var(--secondary-color);
}
.option-item, .photo-item {
display: flex;
gap: 10px;
margin-bottom: 8px;
align-items: center;
flex-wrap: wrap;
}
.option-item input[type="text"],
.option-item input[type="number"] {
flex-grow: 1;
min-width: 150px;
}
.option-item .delete-button, .photo-item .delete-button {
padding: 5px 10px;
font-size: 0.8rem;
margin: 0;
background: var(--accent-color);
border: none;
color: white;
flex-shrink: 0;
}
.option-item .delete-button:hover, .photo-item .delete-button:hover {
background: #dc2626;
}
.photo-item img {
max-width: 80px;
max-height: 80px;
object-fit: contain;
border-radius: 5px;
border: 1px solid var(--input-border-color);
background: var(--input-background);
}
.photo-item span {
word-break: break-all;
flex-grow: 1;
font-size: 0.8rem;
color: var(--text-medium);
}
.add-option-button {
background: #4CAF50;
border: none;
color: var(--text-light);
margin-top: 10px;
padding: 8px 15px;
}
.add-option-button:hover {
background: #45a049;
border-color: #45a049;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: var(--modal-background);
backdrop-filter: var(--modal-backdrop-blur);
overflow-y: auto;
}
.modal-content {
background: var(--background-medium);
margin: 3% auto;
padding: 25px;
border-radius: 15px;
width: 90%;
max-width: 700px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
border: 1px solid var(--primary-color);
position: relative;
max-height: 85vh;
overflow-y: auto;
}
.modal .close-modal {
position: absolute;
top: 10px;
right: 15px;
font-size: 2rem;
color: var(--primary-color);
cursor: pointer;
transition: all 0.3s ease;
opacity: 0.7;
line-height: 1;
}
.modal .close-modal:hover {
color: #fff;
opacity: 1;
transform: rotate(90deg);
}
.modal h2 { border-bottom: none; }
.category-item-actions {
display: flex;
align-items: center;
gap: 8px;
}
.move-button {
background: var(--secondary-color);
border: none;
color: var(--background-dark);
padding: 5px 10px;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s ease;
font-size: 0.8rem;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
width: 30px;
height: 30px;
}
.move-button:hover {
background: var(--primary-color);
}
.move-button i { margin: 0; }
.expiry-time-inputs {
display: flex;
gap: 10px;
align-items: flex-end;
flex-wrap: wrap;
margin-bottom: 10px;
}
.expiry-time-inputs > div {
flex: 1;
min-width: 80px;
}
.expiry-time-inputs label {
margin-bottom: 2px;
font-size: 0.8rem;
}
.expiry-time-inputs input {
padding: 8px;
}
.qr-code-preview {
margin-top: 15px;
padding: 10px;
background: var(--input-background);
border-radius: 10px;
text-align: center;
}
.qr-code-preview img {
max-width: 200px;
height: auto;
border: 1px solid var(--input-border-color);
border-radius: 5px;
margin: 10px auto;
display: block;
}
.qr-code-preview p {
font-size: 0.8rem; color: var(--text-medium);
}
.container-section { /* Style for container inputs */
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--input-border-color);
}
.container-price-input { /* Initially hidden price input */
display: none;
margin-top: 5px;
}
</style>
</head>
<body>
<div class="container">
<h1>Админ-панель</h1>
<div style="margin-bottom: 20px; text-align: center;">
<a href="{{ url_for('stoplist_route') }}" class="button-like edit-button">Управление стоп-листом</a>
<a href="{{ url_for('menu') }}" class="button-like" target="_system" rel="noopener">Открыть меню <i class="fas fa-external-link-alt fa-xs"></i></a>
</div>
<div class="category-section">
<h2>Управление категориями</h2>
<form method="POST">
<input type="hidden" name="action" value="add_category">
<label for="category_name">Новая категория:</label>
<input type="text" name="category_name" id="category_name" required>
<button type="submit">Добавить категорию</button>
</form>
<div class="category-list">
{% if categories %}
{% for category in categories %}
<div class="category-item">
<span>{{ category }}</span>
<div class="category-item-actions">
{% if not loop.first %}
<form method="POST" style="display:inline;">
<input type="hidden" name="action" value="move_category_up">
<input type="hidden" name="category_index" value="{{ loop.index0 }}">
<button type="submit" class="move-button" title="Вверх"><i class="fas fa-arrow-up"></i></button>
</form>
{% else %}
<span style="width: 30px; display: inline-block;"></span>
{% endif %}
{% if not loop.last %}
<form method="POST" style="display:inline;">
<input type="hidden" name="action" value="move_category_down">
<input type="hidden" name="category_index" value="{{ loop.index0 }}">
<button type="submit" class="move-button" title="Вниз"><i class="fas fa-arrow-down"></i></button>
</form>
{% else %}
<span style="width: 30px; display: inline-block;"></span>
{% endif %}
<form method="POST" style="display:inline;" onsubmit="return confirm('Вы уверены, что хотите удалить категорию \'{{ category }}\'? Товары этой категории станут \'Без категории\'.');">
<input type="hidden" name="action" value="delete_category">
<input type="hidden" name="category_index" value="{{ loop.index0 }}">
<button type="submit" class="delete-button" title="Удалить"><i class="fas fa-trash"></i></button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<p>Категорий пока нет.</p>
{% endif %}
</div>
</div>
<div class="form-section">
<h2>Добавить блюдо</h2>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="action" value="add">
<label for="name">Название:</label>
<input type="text" name="name" id="name" required>
<label for="price">Цена (сом):</label>
<input type="number" step="0.01" name="price" id="price" required min="0">
<label for="description">Описание:</label>
<textarea name="description" id="description" rows="4"></textarea>
<label for="category">Категория:</label>
<select name="category" id="category">
<option value="Без категории">Без категории</option>
{% for category in categories %}
<option value="{{ category }}">{{ category }}</option>
{% endfor %}
</select>
<label for="photos">Фотографии (до 10 шт.):</label>
<input type="file" name="photos" id="photos" multiple accept="image/*">
<div class="container-section">
<input type="checkbox" name="has_container" id="has_container_add" onchange="toggleContainerPrice('add')">
<label for="has_container_add" class="checkbox-label">Добавить контейнер</label>
<div id="container-price-input-add" class="container-price-input">
<label for="container_price_add">Цена контейнера (сом):</label>
<input type="number" step="0.01" name="container_price" id="container_price_add" min="0" value="0">
</div>
</div>
<div class="options-list" id="options-list-add">
<label>Опции (название и цена):</label>
</div>
<button type="button" class="add-option-button" onclick="addOptionField('add')"> <i class="fas fa-plus"></i> Добавить опцию</button>
<button type="submit" style="margin-top: 20px; width: 100%;">Добавить блюдо</button>
</form>
</div>
<div class="product-section">
<h2>Список блюд ({{ products|length }})</h2>
<div class="search-container" style="margin: 0 0 15px 0;">
<input type="text" id="search-input-admin-products" placeholder="Поиск блюда по названию...">
</div>
<div class="product-list" id="admin-product-list">
{% if products %}
{% for product in products %}
<div class="product-item" data-name="{{ product['name']|lower }}">
<div style="flex-shrink: 0;">
{% if product.get('photos') and product['photos']|length > 0 %}
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}?t={{ range(1000, 9999) | random }}" alt="{{ product['name'] }}">
{% else %}
<div style="width: 60px; height: 60px; background: var(--input-background); border-radius: 8px; display: flex; align-items: center; justify-content: center; color: var(--text-medium); font-size: 0.8rem; text-align: center; flex-shrink: 0;">Нет фото</div>
{% endif %}
</div>
<div class="item-content product-details">
<strong class="name">{{ product['name'] }}</strong>
<span class="price">{{ product['price'] }} с {% if product.has_container and product.container_price > 0 %} (+{{ product.container_price }}с конт.) {% endif %}</span>
<span class="category">Категория: {{ product.get('category', 'Без категории') }}</span>
{% if product.get('description') %}
<span class="description"><strong>Описание:</strong><br>{{ product['description'] | safe }}</span>
{% endif %}
{% if product.get('options') %}
<span><strong>Опции:</strong> {{ product['options']|map(attribute='name')|join(', ') if product['options'] else 'Нет' }}</span>
{% endif %}
{% if product.get('photos') %}
<span title="{{ product['photos']|join(', ') }}"><strong>Фото:</strong> {{ product['photos']|length }} шт.</span>
{% endif %}
</div>
<div class="item-actions">
<button class="edit-button" onclick="openEditModal({{ loop.index0 }})"><i class="fas fa-edit"></i> Редакт.</button>
<form method="POST" onsubmit="return confirm('Вы уверены, что хотите удалить блюдо \'{{ product['name'] }}\'? Это также удалит его из стоп-листа.');">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="product_index" value="{{ loop.index0 }}">
<button type="submit" class="delete-button"><i class="fas fa-trash"></i> Удалить</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<p>Блюд пока нет.</p>
{% endif %}
</div>
</div>
<div class="news-section">
<h2>Управление новостями</h2>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="action" value="add_news">
<label for="news_title">Заголовок новости:</label>
<input type="text" name="news_title" id="news_title" required>
<label for="news_text">Текст новости (можно использовать HTML):</label>
<textarea name="news_text" id="news_text" rows="4" required></textarea>
<label for="news_photo">Фотография для новости (необязательно):</label>
<input type="file" name="news_photo" id="news_photo" accept="image/*">
<label>Время действия новости (необязательно, время UTC):</label>
<div class="expiry-time-inputs">
<div>
<label for="expiry_days">Дни:</label>
<input type="number" name="expiry_days" id="expiry_days" value="0" min="0">
</div>
<div>
<label for="expiry_hours">Часы:</label>
<input type="number" name="expiry_hours" id="expiry_hours" value="0" min="0" max="23">
</div>
<div>
<label for="expiry_minutes">Минуты:</label>
<input type="number" name="expiry_minutes" id="expiry_minutes" value="0" min="0" max="59">
</div>
</div>
<button type="submit" style="margin-top: 15px; width: 100%;">Добавить новость</button>
</form>
<div class="news-list">
<h3 style="margin-top: 25px; color: var(--secondary-color); border-top: 1px solid var(--secondary-color); padding-top: 15px;">Текущие новости</h3>
{% if sorted_news_list_admin %}
{% for news_item_indexed in sorted_news_list_admin %}
{% set news_item = news_item_indexed %}
<div class="news-item">
<div style="flex-shrink: 0;">
{% if news_item.get('photo') %}
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ news_item['photo'] }}?t={{ range(1000, 9999) | random }}" alt="Фото новости">
{% endif %}
</div>
<div class="item-content news-details">
<strong class="title">{{ news_item.get('title', 'Без заголовка') }}</strong>
<span>{{ news_item.get('text', '') | safe }}</span>
{% if news_item.get('expiry') %}
<span style="color: var(--accent-color); font-size: 0.8rem;">Актуально до (UTC): {{ news_item.expiry[:16].replace('T', ' ') }}</span>
{% else %}
<span style="color: var(--text-medium); font-size: 0.8rem;">Действует бессрочно</span>
{% endif %}
</div>
<div class="item-actions">
<form method="POST" onsubmit="return confirm('Вы уверены, что хотите удалить новость \'{{ news_item['title'] }}\'?');">
<input type="hidden" name="action" value="delete_news">
<input type="hidden" name="news_index" value="{{ news_item_indexed.index }}">
<button type="submit" class="delete-button"><i class="fas fa-trash"></i> Удалить</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<p>Новостей пока нет.</p>
{% endif %}
</div>
</div>
<div class="form-section">
<h2>QR-код для оплаты</h2>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="action" value="upload_qr">
<label for="qr_file">Загрузить новый QR-код (заменит старый):</label>
<input type="file" name="qr_file" id="qr_file" accept="image/*" required>
<button type="submit">Загрузить QR</button>
</form>
<div class="qr-code-preview">
{% if qr_code_admin_url %}
<p>Текущий QR-код:</p>
<img src="{{ qr_code_admin_url }}?t={{ range(1000, 9999) | random }}"
alt="Текущий QR-код">
<p>Имя файла: {{ qr_code_filename }}</p>
{% else %}
<p>QR-код еще не загружен.</p>
{% endif %}
</div>
</div>
</div>
<div id="editModal" class="modal">
<div class="modal-content">
<span class="close-modal" onclick="closeEditModal()">×</span>
<h2>Редактировать блюдо</h2>
<form method="POST" enctype="multipart/form-data" id="editForm">
<input type="hidden" name="action" value="edit">
<input type="hidden" name="product_index" id="editProductIndex">
<label for="editName">Название:</label>
<input type="text" name="name" id="editName" required>
<label for="editPrice">Цена (сом):</label>
<input type="number" step="0.01" name="price" id="editPrice" required min="0">
<label for="editDescription">Описание:</label>
<textarea name="description" id="editDescription" rows="4"></textarea>
<label for="editCategory">Категория:</label>
<select name="category" id="editCategory">
<option value="Без категории">Без категории</option>
{% for category in categories %}
<option value="{{ category }}">{{ category }}</option>
{% endfor %}
</select>
<div class="photos-list">
<label>Фотографии:</label>
<div id="editPhotosList">
</div>
<label for="editPhotos">Добавить новые (старые останутся, если не удалены):</label>
<input type="file" name="photos" id="editPhotos" multiple accept="image/*">
</div>
<div class="container-section">
<input type="checkbox" name="has_container" id="has_container_edit" onchange="toggleContainerPrice('edit')">
<label for="has_container_edit" class="checkbox-label">Добавить контейнер</label>
<div id="container-price-input-edit" class="container-price-input">
<label for="container_price_edit">Цена контейнера (сом):</label>
<input type="number" step="0.01" name="container_price" id="container_price_edit" min="0" value="0">
</div>
</div>
<div class="options-list" id="options-list-edit">
<label>Опции (название и цена):</label>
</div>
<button type="button" class="add-option-button" onclick="addOptionField('edit')"><i class="fas fa-plus"></i> Добавить опцию</button>
<button type="submit" style="margin-top: 20px; width: 100%;">Сохранить изменения</button>
</form>
</div>
</div>
<script>
const adminProducts = {{ products|tojson }};
const adminRepoId = "{{ repo_id }}";
const adminCategories = {{ categories|tojson }};
document.getElementById('search-input-admin-products')?.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase().trim();
document.querySelectorAll('#admin-product-list .product-item').forEach(item => {
const name = item.dataset.name || '';
item.style.display = name.includes(searchTerm) ? 'flex' : 'none';
});
});
function toggleContainerPrice(context) {
const checkbox = document.getElementById(`has_container_${context}`);
const priceInputDiv = document.getElementById(`container-price-input-${context}`);
const priceInput = document.getElementById(`container_price_${context}`);
if (checkbox && priceInputDiv && priceInput) {
priceInputDiv.style.display = checkbox.checked ? 'block' : 'none';
if (!checkbox.checked) {
priceInput.value = 0; // Reset price if checkbox is unchecked
}
}
}
function openEditModal(index) {
const product = adminProducts[index];
if (!product) return;
document.getElementById('editProductIndex').value = index;
document.getElementById('editName').value = product.name || '';
document.getElementById('editPrice').value = product.price || 0;
document.getElementById('editDescription').value = product.description || '';
document.getElementById('editCategory').value = product.category || 'Без категории';
const hasContainerCheckbox = document.getElementById('has_container_edit');
const containerPriceInput = document.getElementById('container_price_edit');
const containerPriceDiv = document.getElementById('container-price-input-edit');
if(hasContainerCheckbox && containerPriceInput && containerPriceDiv) {
hasContainerCheckbox.checked = product.has_container || false;
containerPriceInput.value = product.container_price || 0;
containerPriceDiv.style.display = hasContainerCheckbox.checked ? 'block' : 'none';
}
const optionsListDiv = document.getElementById('options-list-edit');
optionsListDiv.innerHTML = '<label>Опции (название и цена):</label>';
if (product.options && Array.isArray(product.options)) {
product.options.forEach(option => {
const optionDiv = document.createElement('div');
optionDiv.className = 'option-item';
optionDiv.innerHTML = `
<input type="text" name="option_names" value="${escapeHtml(option.name || '')}" placeholder="Название опции">
<input type="number" step="0.01" name="option_prices" value="${option.price || 0}" placeholder="Цена (сом)" min="0">
<button type="button" class="delete-button" onclick="this.closest('.option-item').remove()" title="Удалить опцию">×</button>
`;
optionsListDiv.appendChild(optionDiv);
});
}
const photosListDiv = document.getElementById('editPhotosList');
photosListDiv.innerHTML = '';
if (product.photos && Array.isArray(product.photos)) {
product.photos.forEach(photoFilename => {
const photoDiv = document.createElement('div');
photoDiv.className = 'photo-item';
const photoUrl = `https://huggingface.co/datasets/${adminRepoId}/resolve/main/photos/${photoFilename}?t=${Date.now()}`;
photoDiv.innerHTML = `
<img src="${photoUrl}" alt="${photoFilename}">
<span>${photoFilename}</span>
<input type="hidden" name="existing_photos" value="${photoFilename}">
<button type="button" class="delete-button" onclick="this.closest('.photo-item').remove()" title="Удалить фото">×</button>
`;
photosListDiv.appendChild(photoDiv);
});
}
document.getElementById('editPhotos').value = null;
document.getElementById('editModal').style.display = 'block';
}
function closeEditModal() {
document.getElementById('editModal').style.display = 'none';
}
window.addEventListener('click', function(event) {
const editModal = document.getElementById('editModal');
if (event.target === editModal) {
closeEditModal();
}
});
function deleteExistingPhoto(button) {
button.closest('.photo-item').remove();
}
function addOptionField(context) {
const optionsList = document.getElementById(`options-list-${context}`);
if (!optionsList) return;
const optionDiv = document.createElement('div');
optionDiv.className = 'option-item';
optionDiv.innerHTML = `
<input type="text" name="option_names" placeholder="Название опции">
<input type="number" step="0.01" name="option_prices" placeholder="Цена (сом)" min="0">
<button type="button" class="delete-button" onclick="this.closest('.option-item').remove()" title="Удалить опцию">×</button>
`;
optionsList.appendChild(optionDiv);
}
function escapeHtml(unsafe) {
if (typeof unsafe !== 'string') return unsafe;
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
document.addEventListener('DOMContentLoaded', () => {
addOptionField('add');
toggleContainerPrice('add');
});
</script>
</body>
</html>
'''
return render_template_string(
admin_html,
products=products,
categories=categories,
repo_id=REPO_ID,
qr_code_filename=qr_code_filename,
qr_code_admin_url=qr_code_admin_url,
news_list=news_list,
sorted_news_list_admin=sorted_news_list_admin,
stoplist_admin_data=stoplist
)
@app.route('/qrmenu')
def qrmenu():
data = load_data()
products = data.get('products', [])
categories = data.get('categories', [])
stoplist_raw = data.get('stoplist', {})
category_counts = get_category_counts(products)
news_list = data.get('news', [])
current_time_utc = datetime.now(timezone.utc)
active_stoplist_qr = {}
for product_id, stop_info in stoplist_raw.items():
if isinstance(stop_info.get('until'), datetime):
if stop_info['until'] > current_time_utc:
active_stoplist_qr[product_id] = stop_info
else:
logging.warning(f"Некорректная запись stoplist для ID {product_id} при запросе /qrmenu: {stop_info}")
stoplist_for_template_qr = {
k: {
'until': v['until'].isoformat(),
'minutes': v.get('minutes', 0)
}
for k, v in active_stoplist_qr.items()
}
now_utc_aware_qr = datetime.now(timezone.utc)
news_for_template_qr = sorted(
news_list,
key=lambda item: get_expiry_dt_admin(item),
reverse=True
)
news_for_template_qr = [item for item in news_for_template_qr if not item.get('expiry') or get_expiry_dt_admin(item) > now_utc_aware_qr]
qrmenu_html = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Чайхана Emir QR menu</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=Nunito:wght@400;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 {
--primary-color: #FFD700;
--secondary-color: #D4AF37;
--accent-color: #EF4444;
--background-dark: #1A1A1A;
--background-medium: #2D2D2D;
--text-light: #F5F5F5;
--text-medium: #B0B0B0;
--box-shadow-light: rgba(255, 215, 0, 0.2);
--box-shadow-medium: rgba(255, 215, 0, 0.5);
--modal-background: rgba(0, 0, 0, 0.8);
--modal-backdrop-blur: blur(10px);
--button-hover-background: #FFD700;
--button-hover-text-color: #1A1A1A;
--input-border-color: #FFD700;
--input-focus-border-color: #FFF;
--input-background: rgba(255, 255, 255, 0.1);
--card-background: rgba(255, 255, 255, 0.05);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
font-family: 'Nunito', sans-serif;
background: linear-gradient(135deg, var(--background-dark), var(--background-medium));
color: var(--text-light);
line-height: 1.6;
overflow-x: hidden;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
padding-bottom: 60px;
}
.header {
text-align: center;
padding: 30px 0;
background: rgba(0, 0, 0, 0.7);
border-bottom: 2px solid var(--primary-color);
margin-bottom: 30px;
position: relative;
backdrop-filter: var(--modal-backdrop-blur);
}
.header-logo {
width: 100px; height: 100px; border-radius: 50%;
object-fit: cover; border: 3px solid var(--primary-color);
transition: transform 0.5s ease;
}
.header-logo:hover { transform: rotate(360deg) scale(1.1); }
.header h1 {
font-size: 2.5rem; color: var(--primary-color); margin: 15px 0;
text-shadow: 0 0 10px var(--box-shadow-medium);
}
.motto { font-size: 1.1rem; color: var(--secondary-color); font-style: italic; }
.prep-time { font-size: 0.9rem; color: var(--text-medium); }
.theme-toggle {
position: absolute; top: 15px; right: 20px; background: none;
border: none; font-size: 1.6rem; cursor: pointer;
color: var(--primary-color); transition: transform 0.3s ease;
}
.theme-toggle:hover{ transform: scale(1.2); color: #fff; }
.filters-container {
margin: 20px 0; display: flex; flex-wrap: wrap;
gap: 10px; justify-content: center;
}
.search-container { margin: 20px 0; text-align: center; }
#search-input {
width: 80%; max-width: 600px; padding: 12px 15px; font-size: 1rem;
border: 2px solid var(--input-border-color); border-radius: 50px;
background: var(--input-background); color: var(--text-light);
transition: all 0.3s ease;
}
#search-input:focus {
border-color: var(--input-focus-border-color);
box-shadow: 0 0 10px var(--box-shadow-medium); outline: none;
}
.category-filter {
padding: 8px 18px; border: 2px solid var(--primary-color); border-radius: 25px;
background: rgba(255, 215, 0, 0.1); color: var(--primary-color); cursor: pointer;
transition: all 0.4s ease; font-weight: 600; font-size: 0.9rem; white-space: nowrap;
}
.category-filter.active, .category-filter:hover {
background: var(--primary-color); color: var(--background-dark);
box-shadow: 0 0 10px var(--box-shadow-medium);
}
.products-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px; padding: 15px;
}
.product {
background: var(--card-background); border-radius: 15px; padding: 15px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); transition: all 0.4s ease;
cursor: pointer; position: relative; overflow: hidden;
display: flex; flex-direction: column;
}
.product:hover {
transform: translateY(-8px) scale(1.03);
box-shadow: 0 10px 30px var(--box-shadow-light);
background: rgba(255, 255, 255, 0.1);
}
.product-image {
width: 100%; aspect-ratio: 1; background: var(--input-background);
border-radius: 12px; overflow: hidden; display: flex;
justify-content: center; align-items: center; transition: all 0.3s ease;
}
.product-image img {
max-width: 90%; max-height: 90%; object-fit: contain;
transition: transform 0.5s ease;
}
.product:hover .product-image img { transform: scale(1.1); }
.product h2 {
font-size: 1.2rem; color: var(--primary-color); margin: 10px 0; text-align: center;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.product-price {
font-size: 1.1rem;
color: var(--secondary-color);
font-weight: 700;
text-align: center;
margin-bottom: 8px;
}
.product-description {
font-size: 0.85rem;
color: var(--text-medium);
text-align: center;
margin-bottom: 15px;
overflow: hidden;
text-overflow: ellipsis;
height: 3.2em;
line-height: 1.6em;
flex-grow: 1;
}
.product-actions-qr {
margin-top: 10px;
min-height: 20px;
display: flex;
justify-content: center;
align-items: center;
}
.modal {
display: none;
position: fixed;
z-index: 1001;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: var(--modal-background);
backdrop-filter: var(--modal-backdrop-blur);
overflow-y: auto;
}
.modal-content {
background: var(--card-background);
margin: 5% auto;
padding: 20px;
border-radius: 15px;
width: 95%;
max-width: 600px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
border: 1px solid var(--primary-color);
animation: fadeInUp 0.4s ease-out;
overflow-y: auto;
max-height: 90vh;
position: relative;
}
@keyframes fadeInUp {
from { transform: translateY(30px); opacity: 0; }
to { transform: translateY(30px); opacity: 1; }
}
.close {
position: absolute; top: 10px; right: 15px;
float: none; font-size: 1.8rem; color: var(--primary-color);
cursor: pointer; transition: all 0.3s ease; opacity: 0.7;
line-height: 1;
}
.close:hover { color: #fff; opacity: 1; transform: rotate(90deg); }
.stop-notice {
color: var(--accent-color);
font-size: 0.9rem;
text-align: center;
font-weight: 600;
}
.stop-timer-qr {
font-weight: bold;
}
.footer-info {
text-align: center;
margin-top: 30px;
padding: 15px;
color: var(--text-medium);
font-size: 0.8rem;
}
.news-item {
padding: 12px;
margin-bottom: 15px;
border-bottom: 1px solid var(--box-shadow-light);
background: var(--card-background);
border-radius: 12px;
}
.news-item h3 {
color: var(--primary-color);
margin-top: 0;
margin-bottom: 8px;
font-size: 1.1rem;
}
.news-item p {
margin: 4px 0;
font-size: 0.9rem;
}
.news-item img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin-bottom: 8px;
}
.news-expiry .news-expiry .expiry-local-time-qr {
font-weight: normal;
}
.news-button {
padding: 10px 25px;
border: 2px solid var(--primary-color);
border-radius: 25px;
background: transparent;
color: var(--primary-color);
cursor: pointer;
transition: all 0.3s ease;
font-size: 1rem;
font-weight: 600;
}
.news-button:hover {
background: var(--primary-color);
color: var(--background-dark);
border-color: var(--primary-color);
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{ logo_url }}" alt="Логотип" class="header-logo">
<h1>Чайхана "Emir"</h1>
<p class="motto">Встречаем с улыбкой, готовим с любовью!</p>
<p class="prep-time">Время готовки: 15-30 мин</p>
<button class="theme-toggle" onclick="toggleThemeQr()">
<i class="fas fa-moon"></i>
</button>
</div>
<div class="filters-container">
<button class="category-filter active" data-category="all">Все категории ({{ products|length }})</button>
{% for category in categories %}
<button class="category-filter" data-category="{{ category }}">{{ category }} ({{ category_counts.get(category, 0) }})</button>
{% endfor %}
</div>
<div class="search-container">
<input type="text" id="search-input" placeholder="Найти изысканное блюдо...">
</div>
<div class="products-grid" id="products-grid">
{% for product in products %}
<div class="product"
onclick="openProductModalQr({{ loop.index0 }})"
data-name="{{ product['name']|lower }}"
data-description="{{ product['description']|lower }}"
data-category="{{ product.get('category', 'Без категории') }}"
data-id="{{ loop.index0 }}">
{% if product.get('photos') and product['photos']|length > 0 %}
<div class="product-image">
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}?t={{ range(1000, 9999) | random }}"
alt="{{ product['name'] }}"
loading="lazy">
</div>
{% else %}
<div class="product-image" style="justify-content: center; align-items: center; color: var(--text-medium);">
<span>Фото нет</span>
</div>
{% endif %}
<h2>{{ product['name'] }}</h2>
<div class="product-price">{{ product['price'] }} с {% if product.has_container and product.container_price > 0 %} (+{{ product.container_price }}с конт.) {% endif %}</div>
<p class="product-description">{{ product['description'] | safe }}</p>
<div class="product-actions-qr" id="stop-status-{{ loop.index0 }}">
{% set product_id_str = loop.index0|string %}
{% if stoplist_qr.get(product_id_str) %}
<p class="stop-notice">Временно недоступно (Стоп-лист)</p>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<div style="text-align: center; margin-top: 30px;">
<button class="news-button" onclick="openNewsModalQr()">
<i class="fas fa-newspaper"></i> Новости и Акции
</button>
</div>
<div class="footer-info">
Приятного аппетита!
</div>
</div>
<div id="productModalQr" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModalQr('productModalQr')">×</span>
<div id="modalContentQr"><p style="text-align:center;">Загрузка...</p></div>
</div>
</div>
<div id="newsModalQr" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModalQr('newsModalQr')">×</span>
<h2>Новости и Акции</h2>
<div id="newsContentQr">
{% if news_for_template_qr %}
{% for news_item in news_for_template_qr %}
<div class="news-item">
<h3>{{ news_item.title }}</h3>
{% if news_item.photo %}
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ news_item.photo }}?t={{ range(1000, 9999) | random }}" alt="Фото новости">
{% endif %}
<p>{{ news_item.text | safe }}</p>
{% if news_item.expiry %}
<p style="font-size: 0.8rem; color: var(--text-medium);" class="news-expiry" data-expiry-utc="{{ news_item.expiry }}">Актуально до: <span class="expiry-local-time-qr">...</span></p>
{% endif %}
</div>
{% endfor %}
{% else %}
<p style="text-align:center; color: var(--text-medium);">Новости пока нет.</p>
{% endif %}
</div>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
<script>
const productsQr = {{ products|tojson }};
const repoIdQr = "{{ repo_id }}";
const stoplistQrData = {{ stoplist_qr|tojson }};
function toggleThemeQr() {
const icon = document.querySelector('.theme-toggle i');
if (!icon) return;
const isDark = document.body.classList.toggle('dark-theme-qr');
icon.classList.toggle('fa-moon', !isDark);
icon.classList.toggle('fa-sun', isDark);
console.log("Смена темы QR (TODO: Реализовать)");
}
function openProductModalQr(index) {
loadProductDetailsQr(index);
document.getElementById('productModalQr').style.display = "block";
}
function closeModalQr(modalId) {
const modal = document.getElementById(modalId);
if(modal) modal.style.display = "none";
if (modalId === 'productModalQr') {
document.getElementById('modalContentQr').innerHTML = '<p style="text-align:center;">Загрузка...</p>';
}
}
function loadProductDetailsQr(index) {
const contentDiv = document.getElementById('modalContentQr');
contentDiv.innerHTML = '<p style="text-align:center;">Загрузка деталей...</p>';
fetch('/product/' + index)
.then(response => {
if (!response.ok) throw new Error(`Ошибка сети: ${response.status}`);
return response.text();
})
.then(htmlData => {
contentDiv.innerHTML = htmlData;
initializeSwiperQr('#productModalQr');
})
.catch(error => {
console.error('Ошибка загрузки деталей продукта (QR):', error);
contentDiv.innerHTML = '<p style="text-align:center; color: var(--accent-color);">Не удалось загрузить информацию.</p>';
});
}
function initializeSwiperQr(modalSelector) {
try {
const modalElement = document.querySelector(modalSelector);
if (!modalElement) return;
const swiperContainer = modalElement.querySelector('.swiper-container');
if (swiperContainer && swiperContainer.querySelectorAll('.swiper-slide').length > 0 && !swiperContainer.swiper) {
new Swiper(swiperContainer, {
slidesPerView: 1,
spaceBetween: 20,
loop: swiperContainer.querySelectorAll('.swiper-slide').length > 1,
grabCursor: true,
pagination: { el: swiperContainer.querySelector('.swiper-pagination'), clickable: true },
navigation: { nextEl: swiperContainer.querySelector('.swiper-button-next'), prevEl: swiperContainer.querySelector('.swiper-button-prev') },
zoom: { maxRatio: 3 },
});
console.log("Swiper (QR) инициализирован для", modalSelector);
}
} catch (e) {
console.error("Ошибка инициализации Swiper (QR):", e);
}
}
function openNewsModalQr() {
formatNewsDatesQr();
document.getElementById('newsModalQr').style.display = 'block';
}
window.onclick = function(event) {
if (event.target.classList.contains('modal')) {
closeModalQr(event.target.id);
}
}
function filterProductsQr() {
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
const activeCategoryButton = document.querySelector('.category-filter.active');
const activeCategory = activeCategoryButton ? activeCategoryButton.dataset.category : 'all';
let hasVisibleProducts = false;
document.querySelectorAll('.product').forEach(productElement => {
const name = productElement.getAttribute('data-name') || '';
const description = productElement.getAttribute('data-description') || '';
const category = productElement.getAttribute('data-category') || 'Без категории';
const matchesSearch = searchTerm === '' || name.includes(searchTerm) || description.includes(searchTerm);
const matchesCategory = activeCategory === 'all' || category === activeCategory;
if (matchesSearch && matchesCategory) {
productElement.style.display = 'flex';
hasVisibleProducts = true;
} else {
productElement.style.display = 'none';
}
});
}
document.getElementById('search-input').addEventListener('input', filterProductsQr);
document.querySelectorAll('.category-filter').forEach(filter => {
filter.addEventListener('click', function() {
document.querySelectorAll('.category-filter').forEach(f => f.classList.remove('active'));
this.classList.add('active');
filterProductsQr();
});
});
function formatNewsDatesQr() {
const newsItems = document.querySelectorAll('#newsModalQr .news-expiry');
newsItems.forEach(item => {
const utcDateString = item.dataset.expiryUtc;
const localTimeSpan = item.querySelector('.expiry-local-time-qr');
if (utcDateString && localTimeSpan) {
try {
const date = new Date(utcDateString);
const options = { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' };
localTimeSpan.textContent = date.toLocaleDateString('ru-RU', options);
} catch (e) {
console.error("Ошибка форматирования даты новости (QR):", e);
localTimeSpan.textContent = utcDateString;
}
}
});
}
</script>
</body>
</html>
'''
return render_template_string(
qrmenu_html,
products=products,
categories=categories,
category_counts=category_counts,
stoplist_qr=stoplist_for_template_qr,
repo_id=REPO_ID,
news_for_template_qr=news_for_template_qr,
logo_url=LOGO_URL
)
if __name__ == '__main__':
if HF_TOKEN_WRITE:
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
backup_thread.start()
else:
logging.warning("HF_TOKEN (write) не установлен. Периодический бэкап на Hugging Face отключен.")
port = int(os.environ.get("PORT", 7860))
logging.info(f"Запуск Flask приложения на порту {port}")
app.run(debug=False, host='0.0.0.0', port=port)