|
|
| |
| 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="Удалить из корзины">×</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, "&") |
| .replace(/</g, "<") |
| .replace(/>/g, ">") |
| .replace(/"/g, """) |
| .replace(/'/g, "'"); |
| } |
| |
| 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) |
|
|
|
|