diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,5 +1,5 @@ -# -*- coding: utf-8 -*- +# Импортируем необходимые библиотеки from flask import Flask, render_template_string, request, redirect, url_for, session, send_file, flash import json import os @@ -8,7 +8,7 @@ import threading import time from datetime import datetime from huggingface_hub import HfApi, hf_hub_download -from huggingface_hub.utils import RepositoryNotFoundError, hf_raise_for_status # Import hf_raise_for_status for detailed errors +from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError # Добавляем HfHubHTTPError from werkzeug.utils import secure_filename # Импортируем dotenv для загрузки переменных окружения из .env файла from dotenv import load_dotenv @@ -16,87 +16,84 @@ from dotenv import load_dotenv # Загружаем переменные окружения из файла .env (если он есть) load_dotenv() +# Инициализация Flask приложения app = Flask(__name__) -# Новый уникальный секретный ключ, ОБЯЗАТЕЛЬНО смените его в продакшене или используйте переменную окружения -app.secret_key = os.getenv('FLASK_SECRET_KEY', 'fallback_secret_key_soola_cosmetics_12345_CHANGE_ME') +# Установите СВОЙ уникальный и надежный секретный ключ +app.secret_key = os.getenv('FLASK_SECRET_KEY', 'fallback_very_secret_key_soola_12345') # Загружаем из .env или используем запасной + +# Константы для файлов данных DATA_FILE = 'data_soola.json' USERS_FILE = 'users_soola.json' -# Список файлов для синхронизации +# Список файлов для синхронизации с Hugging Face SYNC_FILES = [DATA_FILE, USERS_FILE] -# Настройки Hugging Face -# Убедитесь, что REPO_ID соответствует вашему репозиторию на Hugging Face -REPO_ID = os.getenv("HF_REPO_ID", "Kgshop/Soola") # Используем переменную окружения или значение по умолчанию -HF_TOKEN = os.getenv("HF_TOKEN") # Универсальный токен для чтения и записи -# Убраны отдельные токены WRITE/READ, так как HF_TOKEN используется для обоих -# HF_TOKEN_WRITE = os.getenv("HF_TOKEN") -# HF_TOKEN_READ = os.getenv("HF_TOKEN") - -# Адрес магазина -STORE_ADDRESS = os.getenv("STORE_ADDRESS", "Рынок Дордой, Джунхай, терминал, 38") # Единый адрес +# Настройки Hugging Face (Замените REPO_ID на ваш) +REPO_ID = os.getenv("HF_REPO_ID", "Kgshop/Soola") # Загружаем из .env или используем значение по умолчанию +HF_TOKEN_WRITE = os.getenv("HF_TOKEN") # Токен для записи (должен быть установлен в .env) +HF_TOKEN_READ = os.getenv("HF_TOKEN_READ", HF_TOKEN_WRITE) # Токен для чтения (может быть тем же или отдельным, или отсутствовать для публичных репо) -# Валюта (только KGS) +# Настройки магазина +STORE_ADDRESS = "Рынок Дордой, Джунхай, терминал, 38" CURRENCY_CODE = 'KGS' CURRENCY_NAME = 'Кыргызский сом (с)' # Настройка логирования -# Уровни: DEBUG, INFO, WARNING, ERROR, CRITICAL -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - [%(threadName)s] - %(message)s') - -# Путь для временной загрузки фото перед отправкой на HF -UPLOAD_FOLDER = 'uploads_temp' -os.makedirs(UPLOAD_FOLDER, exist_ok=True) +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - [%(threadName)s] - %(message)s', # Добавляем имя потока + handlers=[ + logging.FileHandler("app_soola.log", encoding='utf-8'), # Логирование в файл + logging.StreamHandler() # Логирование в консоль + ] +) # --- Функции работы с данными и пользователями --- def load_data(): """Загрузка данных о товарах и категориях.""" - logging.info(f"Попытка загрузки данных из {DATA_FILE}...") + # Сначала пытаемся скачать с HF try: - # Сначала пытаемся скачать свежие данные download_db_from_hf(specific_file=DATA_FILE) + logging.info(f"Успешно проверена/скачана свежая версия {DATA_FILE} с HF.") except Exception as e: - logging.warning(f"Ошибка при попытке скачивания {DATA_FILE} перед загрузкой: {e}. Продолжаем с локальным ��айлом.") + logging.warning(f"Не удалось скачать {DATA_FILE} с HF перед загрузкой: {e}. Используется локальная версия.") + # Затем читаем локальный файл try: with open(DATA_FILE, 'r', encoding='utf-8') as file: data = json.load(file) logging.info(f"Данные успешно загружены из локального {DATA_FILE}") - # Проверка и инициализация базовой структуры + # Проверка и инициализация структуры if not isinstance(data, dict): - logging.warning(f"{DATA_FILE} не является словарем. Инициализация пустой структурой.") - data = {'products': [], 'categories': []} - if 'products' not in data: - data['products'] = [] - if 'categories' not in data: - data['categories'] = [] + logging.warning(f"{DATA_FILE} не является словарем. Инициализация пустой структурой.") + return {'products': [], 'categories': []} + if 'products' not in data: data['products'] = [] + if 'categories' not in data: data['categories'] = [] return data except FileNotFoundError: - logging.warning(f"Локальный файл {DATA_FILE} не найден. Возврат пустой структуры.") - # Файл мог быть скачан выше, но если нет, создаем пустой - if not os.path.exists(DATA_FILE): - with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'products': [], 'categories': []}, f) - logging.info(f"Создан пустой файл {DATA_FILE}") - return {'products': [], 'categories': []} + logging.warning(f"Локальный файл {DATA_FILE} не найден. Создание пустой структуры.") + return {'products': [], 'categories': []} # Возвращаем пустую структуру, не создаем файл здесь except json.JSONDecodeError: logging.error(f"Ошибка декодирования JSON в локальном {DATA_FILE}. Файл может быть поврежден. Возврат пустой структуры.") - # Можно добавить логику восстановления из бэкапа или принудительного скачивания + # TODO: Возможно, стоит попытаться восстановить из бэкапа или принудительно скачать с HF return {'products': [], 'categories': []} except Exception as e: - logging.error(f"Неизвестная ошибка при загрузке данных ({DATA_FILE}): {e}", exc_info=True) + logging.error(f"Неизвестная ошибка при загрузке данных из {DATA_FILE}: {e}", exc_info=True) return {'products': [], 'categories': []} def save_data(data): """Сохранение данных о товарах и категориях.""" try: - # Сортировка продуктов по имени перед сохранением - if 'products' in data and isinstance(data['products'], list): - data['products'].sort(key=lambda x: x.get('name', '').lower()) - # Сортировка категорий перед сохранением - if 'categories' in data and isinstance(data['categories'], list): - data['categories'].sort() + # Создаем резервную копию перед перезаписью + if os.path.exists(DATA_FILE): + backup_file = f"{DATA_FILE}.bak_{datetime.now().strftime('%Y%m%d%H%M%S')}" + try: + os.rename(DATA_FILE, backup_file) + logging.info(f"Создана резервная копия: {backup_file}") + except OSError as e: + logging.warning(f"Не удалось создать резервную копию {DATA_FILE}: {e}") with open(DATA_FILE, 'w', encoding='utf-8') as file: json.dump(data, file, ensure_ascii=False, indent=4) @@ -105,39 +102,45 @@ def save_data(data): upload_db_to_hf(specific_file=DATA_FILE) except Exception as e: logging.error(f"Ошибка при сохранении данных в {DATA_FILE}: {e}", exc_info=True) - flash("Произошла ошибка при сохранении данных. Изменения могут быть не сохранены.", 'error') - + flash("Произошла ошибка при сохранении данных товаров/категорий.", 'error') def load_users(): """Загрузка данных пользователей.""" - logging.info(f"Попытка загрузки пользователей из {USERS_FILE}...") + # Сначала пытаемся скачать с HF try: - # Сначала пытаемся скачать свежие данные download_db_from_hf(specific_file=USERS_FILE) + logging.info(f"Успешно проверена/скачана свежая версия {USERS_FILE} с HF.") except Exception as e: - logging.warning(f"Ошибка при попытке скачивания {USERS_FILE} перед загрузкой: {e}. Продолжаем с локальным файлом.") + logging.warning(f"Не удалось скачать {USERS_FILE} с HF перед загрузкой: {e}. Используется локальная версия.") + # Затем читаем локальный файл try: with open(USERS_FILE, 'r', encoding='utf-8') as file: users = json.load(file) logging.info(f"Данные пользователей успешно загружены из локального {USERS_FILE}") return users if isinstance(users, dict) else {} except FileNotFoundError: - logging.warning(f"Локальный файл {USERS_FILE} не найден. Возврат пустого словаря.") - if not os.path.exists(USERS_FILE): - with open(USERS_FILE, 'w', encoding='utf-8') as f: json.dump({}, f) - logging.info(f"Создан пустой файл {USERS_FILE}") + logging.warning(f"Локальный файл {USERS_FILE} не найден. Создание пустого словаря пользователей.") return {} except json.JSONDecodeError: logging.error(f"Ошибка декодирования JSON в локальном {USERS_FILE}. Файл может быть поврежден. Возврат пустого словаря.") return {} except Exception as e: - logging.error(f"Неизвестная ошибка при загрузке пользователей ({USERS_FILE}): {e}", exc_info=True) + logging.error(f"Неизвестная ошибка при загрузке пользователей из {USERS_FILE}: {e}", exc_info=True) return {} def save_users(users): """Сохранение данных пользователей.""" try: + # Создаем резервную копию + if os.path.exists(USERS_FILE): + backup_file = f"{USERS_FILE}.bak_{datetime.now().strftime('%Y%m%d%H%M%S')}" + try: + os.rename(USERS_FILE, backup_file) + logging.info(f"Создана резервная копия: {backup_file}") + except OSError as e: + logging.warning(f"Не удалось создать резервную копию {USERS_FILE}: {e}") + with open(USERS_FILE, 'w', encoding='utf-8') as file: json.dump(users, file, ensure_ascii=False, indent=4) logging.info(f"Данные пользователей успешно сохранены в {USERS_FILE}") @@ -150,102 +153,107 @@ def save_users(users): # --- Функции синхронизации с Hugging Face --- def upload_db_to_hf(specific_file=None): - """Загрузка файлов данных на Hugging Face. - Если specific_file указан, загружает только его. - """ - if not HF_TOKEN: - logging.warning("Переменная окружения HF_TOKEN не установлена. Загрузка на Hugging Face пропущена.") - return False # Возвращаем False при пропуске + """Загрузка файлов данных на Hugging Face.""" + if not HF_TOKEN_WRITE: + logging.warning("HF_TOKEN (для записи) не установлен. Загрузка на Hugging Face пропущена.") + return False # Возвращаем статус неудачи + + files_to_upload = [specific_file] if specific_file else SYNC_FILES + success = True # Флаг успешности для всех файлов + api = None # Инициализируем None try: api = HfApi() - files_to_upload = [specific_file] if specific_file else SYNC_FILES logging.info(f"Начало загрузки файлов {files_to_upload} на HF репозиторий {REPO_ID}...") - success = True + for file_name in files_to_upload: if os.path.exists(file_name): try: - logging.debug(f"Загрузка файла: {file_name}...") + logging.info(f"Загрузка файла {file_name}...") api.upload_file( path_or_fileobj=file_name, path_in_repo=file_name, repo_id=REPO_ID, repo_type="dataset", - token=HF_TOKEN, + token=HF_TOKEN_WRITE, commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" ) logging.info(f"Файл {file_name} успешно загружен на Hugging Face.") + except HfHubHTTPError as http_err: + logging.error(f"HTTP ошибка при загрузке файла {file_name} на Hugging Face: {http_err}") + # Проверяем код ответа, если это 401/403, возможно, проблема с токеном + if http_err.response.status_code in [401, 403]: + logging.error("Ошибка авторизации (401/403). Проверьте HF_TOKEN.") + success = False except Exception as e: - logging.error(f"Ошибка при загрузке файла {file_name} на Hugging Face: {e}") - success = False # Отмечаем неудачу - # Можно добавить flash сообщение об ошибке, но это серверная функция + logging.error(f"Общая ошибка при загрузке файла {file_name} на Hugging Face: {e}", exc_info=True) + success = False else: logging.warning(f"Файл {file_name} не найден локально, пропуск загрузки.") + # Не считаем это полной неудачей, если другие файлы загрузятся logging.info("Загрузка файлов на HF завершена.") return success except Exception as e: - logging.error(f"Общая ошибка при инициализации или загрузке на Hugging Face: {e}", exc_info=True) + logging.error(f"Критическая ошибка при инициализации HfApi или процессе загрузки: {e}", exc_info=True) return False def download_db_from_hf(specific_file=None): - """Скачивание файлов данных с Hugging Face. - Если specific_file указан, скачивает только его. - Возвращает True, если хотя бы один файл успешно скачан, иначе False. - """ - # HF_TOKEN необязателен для публичных репо, но нужен для приватных - # Логика теперь использует HF_TOKEN если он есть - token_msg = "с использованием токена" if HF_TOKEN else "без токена (для публичных репо)" + """Скачивание файлов данных с Hugging Face.""" + if not REPO_ID: + logging.warning("HF_REPO_ID не установлен. Скачивание с Hugging Face невозможно.") + return False + + token_to_use = HF_TOKEN_READ or None # Используем None если HF_TOKEN_READ пустой + if not token_to_use: + logging.info("HF_TOKEN_READ не установлен. Попытка скачивания без токена (для публичных репозиториев).") + files_to_download = [specific_file] if specific_file else SYNC_FILES - logging.info(f"Начало скачивания файлов {files_to_download} с HF репозитория {REPO_ID} {token_msg}...") + logging.info(f"Начало скачивания файлов {files_to_download} с HF репозитория {REPO_ID}...") downloaded_files_count = 0 - try: - for file_name in files_to_download: - try: - logging.debug(f"Попытка скачивания файла: {file_name}...") - local_path = hf_hub_download( - repo_id=REPO_ID, - filename=file_name, - repo_type="dataset", - token=HF_TOKEN, # Передаем токен, если он есть - local_dir=".", - local_dir_use_symlinks=False, # Важно для Render/контейнеров - force_download=True # Принудительно скачивать свежую версию - ) - logging.info(f"Файл {file_name} успешно скачан из Hugging Face в {local_path}.") - downloaded_files_count += 1 - except RepositoryNotFoundError: - logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание прервано.") - return False # Прерываем цикл и возвращаем неудачу - except Exception as e: - # Используем hf_raise_for_status для более детальной информации, если это HTTP ошибка - try: - hf_raise_for_status(e.response) # Проверяем статус HTTP, если это HTTPError - except Exception as http_e: - # Ловим ошибку файла не найдено (404) отдельно - if "404" in str(http_e): - logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID}. Пропуск скачивания этого файла.") - continue # Переходим к следующему файлу - else: - logging.error(f"Ошибка HTTP при скачивании файла {file_name} с Hugging Face: {http_e}") - continue # Переходим к следующему файлу, но логируем ошибку - - # Если это не HTTP ошибка или не 404 - logging.error(f"Не-HTTP ошибка при скачивании файла {file_name} с Hugging Face: {e}", exc_info=False) # Не печатаем весь traceback для FileNotFoundError - - logging.info(f"Скачивание файлов с HF завершено. Скачано файлов: {downloaded_files_count}/{len(files_to_download)}.") - return downloaded_files_count > 0 - - except RepositoryNotFoundError: - logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание не выполнено.") - return False - except Exception as e: - logging.error(f"Общая ошибка при попытке скачивания с Hugging Face: {e}", exc_info=True) - return False + success = True # Флаг общего успеха + + for file_name in files_to_download: + try: + # Скачиваем в текущую директорию, перезаписывая существующие файлы + local_path = hf_hub_download( + repo_id=REPO_ID, + filename=file_name, + repo_type="dataset", + token=token_to_use, + local_dir=".", # Скачать в текущую директорию + local_dir_use_symlinks=False, # Важно для Windows и Docker + force_download=True, # Принудительно скачать свежую версию + resume_download=False # Не возобновлять, а скачивать заново + ) + logging.info(f"Файл {file_name} успешно скачан из Hugging Face в {local_path}.") + downloaded_files_count += 1 + except RepositoryNotFoundError: + logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание прервано.") + success = False + break # Прерываем цикл, если репозиторий не найден + except HfHubHTTPError as http_err: + # Проверяем, является ли ошибка 'Not Found' для конкретного файла (404) + if http_err.response.status_code == 404: + logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID}. Пропуск скачивания этого файла.") + # Не считаем это полной неудачей, если другие файлы скачаются + elif http_err.response.status_code in [401, 403]: + logging.error(f"Ошибка авторизации (401/403) при скачивании файла {file_name}. Проверьте HF_TOKEN_READ (если репозиторий приватный).") + success = False + else: + # Логируем другие HTTP ошибки + logging.error(f"HTTP ошибка при скачивании файла {file_name} с Hugging Face: {http_err}", exc_info=True) + success = False + except Exception as e: # Ловим другие возможные исключения (например, проблемы с сетью) + logging.error(f"Общая ошибка при скачивании файла {file_name} с Hugging Face: {e}", exc_info=True) + success = False + + logging.info(f"Скачивание файлов с HF завершено. Скачано файлов: {downloaded_files_count}/{len(files_to_download)}.") + return success def periodic_backup(): """Периодическая загрузка данных на HF.""" backup_interval = 1800 # 30 минут (в секундах) - logging.info(f"Настройка периодического резервного копирования каждые {backup_interval} секунд.") + logging.info(f"Запуск потока периодического резервного копирования каждые {backup_interval} секунд.") while True: time.sleep(backup_interval) logging.info("Запуск периодического резервного копирования...") @@ -254,10 +262,13 @@ def periodic_backup(): if success: logging.info("Периодическое резервное копирование успешно завершено.") else: - logging.warning("Периодическое резервное копирование завершено с ошибками.") + logging.warning("Периодическое резервное копирование завершено с ошибками.") except Exception as e: - logging.error(f"Критическая ошибка в потоке периодического бэкапа: {e}", exc_info=True) - + # Ловим непредвиденные ошибки в самом потоке + logging.error(f"Критическая ошибка в потоке периодического бэкапа: {e}", exc_info=True) + # Продолжаем работу потока, но логируем ошибку + # Добавим небольшую паузу на всякий случай, чтобы не зациклиться при мгновенной ошибке + time.sleep(5) # --- Маршруты Flask --- @@ -267,9 +278,12 @@ def catalog(): data = load_data() products = data.get('products', []) categories = data.get('categories', []) + # Сортировка для отображения + products.sort(key=lambda x: x.get('name', '').lower()) + categories.sort() is_authenticated = 'user' in session - # Убираем артефакты {/**/} из HTML шаблона + # Шаблон каталога (HTML+CSS+JS) catalog_html = ''' @@ -286,7 +300,7 @@ def catalog(): body { font-family: 'Poppins', sans-serif; background: #f0f9f4; color: #2d332f; line-height: 1.6; transition: background 0.3s, color 0.3s; } body.dark-mode { background: #1a2b26; color: #c8d8d3; } .container { max-width: 1300px; margin: 0 auto; padding: 20px; } - .header { display: flex; justify-content: space-between; align-items: center; padding: 15px 0; border-bottom: 1px solid #d1e7dd; } + .header { display: flex; justify-content: space-between; align-items: center; padding: 15px 0; border-bottom: 1px solid #d1e7dd; flex-wrap: wrap; gap: 10px;} body.dark-mode .header { border-bottom-color: #2c4a41; } .header h1 { font-size: 1.8rem; font-weight: 600; color: #1C6758; } /* Логотип Soola - Темно-зеленый */ .auth-links { display: flex; gap: 15px; align-items: center; } @@ -337,9 +351,9 @@ def catalog(): .product-button i { margin-right: 5px; } /* Стили корзины */ - .add-to-cart { background-color: #38a169; } /* Зеленый для корзины */ + .add-to-cart { background-color: #38a169; } /* Зеленый для корзины (можно оставить) */ .add-to-cart:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); } - #cart-button { position: fixed; bottom: 25px; right: 25px; background-color: #1C6758; color: white; border: none; border-radius: 50%; width: 55px; height: 55px; font-size: 1.5rem; cursor: pointer; display: none; align-items: center; justify-content: center; box-shadow: 0 4px 15px rgba(28, 103, 88, 0.4); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 1000; } /* Плавающая кнопка - темно-зеленая */ + #cart-button { position: fixed; bottom: 25px; right: 25px; background-color: #1C6758; color: white; border: none; border-radius: 50%; width: 55px; height: 55px; font-size: 1.5rem; cursor: pointer; display: none; /* Изначально скрыта */ align-items: center; justify-content: center; box-shadow: 0 4px 15px rgba(28, 103, 88, 0.4); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 1000; } /* Плавающая кнопка - темно-зеленая */ #cart-button .fa-shopping-cart { margin-right: 0; } #cart-button span { position: absolute; top: -5px; right: -5px; background-color: #38a169; color: white; border-radius: 50%; padding: 2px 6px; font-size: 0.7rem; font-weight: bold; } /* Счетчик остается зеленым */ @@ -359,10 +373,10 @@ def catalog(): .cart-item:last-child { border-bottom: none; } .cart-item img { width: 60px; height: 60px; object-fit: contain; border-radius: 8px; background-color: #fff; padding: 5px; grid-column: 1; } .cart-item-details { grid-column: 2; } - .cart-item-details strong { display: block; margin-bottom: 5px; font-weight: 600; } + .cart-item-details strong { display: block; margin-bottom: 5px; } .cart-item-price { font-size: 0.9rem; color: #44524c; } body.dark-mode .cart-item-price { color: #8aa39a; } - .cart-item-total { font-weight: bold; text-align: right; grid-column: 3; color: #1C6758; } + .cart-item-total { font-weight: bold; text-align: right; grid-column: 3; color: #1C6758; } /* Общая цена за позицию - темно-зеленая */ body.dark-mode .cart-item-total { color: #55a683; } .cart-item-remove { grid-column: 4; background:none; border:none; color:#f56565; cursor:pointer; font-size: 1.3em; padding: 5px; line-height: 1; } /* Удаление - красный */ .cart-item-remove:hover { color: #c53030; } @@ -370,23 +384,20 @@ def catalog(): body.dark-mode .quantity-input, body.dark-mode .color-select { background-color: #1a2b26; border-color: #2c4a41; color: #c8d8d3; } .cart-summary { margin-top: 20px; text-align: right; border-top: 1px solid #d1e7dd; padding-top: 15px; } body.dark-mode .cart-summary { border-top-color: #2c4a41; } - .cart-summary strong { font-size: 1.2rem; color: #1C6758;} - body.dark-mode .cart-summary strong { color: #55a683;} + .cart-summary strong { font-size: 1.2rem; color: #1C6758; } /* Итоговая сумма - темно-зеленая */ + body.dark-mode .cart-summary strong { color: #55a683; } .cart-actions { margin-top: 25px; display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; } .cart-actions .product-button { width: auto; flex-grow: 1; } /* Кнопки растягиваются */ .clear-cart { background-color: #7a8d85; } /* Кнопка очистки - серо-зеленый */ .clear-cart:hover { background-color: #5e6e68; box-shadow: 0 4px 15px rgba(94, 110, 104, 0.4); } - .order-button { background-color: #25D366; } /* Кнопка заказа - цвет WhatsApp */ - .order-button:hover { background-color: #1DAA50; box-shadow: 0 4px 15px rgba(37, 211, 102, 0.4); } + .order-button { background-color: #25D366; } /* Кнопка заказа через WhatsApp - фирменный зеленый WhatsApp */ + .order-button:hover { background-color: #1DAE51; box-shadow: 0 4px 15px rgba(37, 211, 102, 0.4); } /* Уведомления и сообщения */ .notification { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); background-color: #38a169; color: white; padding: 10px 20px; border-radius: 20px; box-shadow: 0 4px 10px rgba(0,0,0,0.2); z-index: 1002; opacity: 0; transition: opacity 0.5s ease, transform 0.5s ease; font-size: 0.9rem;} /* Уведомление - ярко-зеленое */ - .notification.show { opacity: 1; transform: translateX(-50%) translateY(0); } - .notification { transform: translateX(-50%) translateY(20px); } + .notification.show { opacity: 1; transform: translate(-50%, -10px);} /* Слегка поднимается при появлении */ .no-results-message { grid-column: 1 / -1; text-align: center; padding: 40px; font-size: 1.1rem; color: #5e6e68; } body.dark-mode .no-results-message { color: #8aa39a; } - .admin-link a { background-color: #ffc107; color: #333; padding: 5px 10px; border-radius: 5px; font-weight: bold; text-decoration: none; } /* Кнопка админа */ - .admin-link a:hover { background-color: #e0a800; } @@ -396,13 +407,11 @@ def catalog(): + {% else %} + {# Можно добавить кнопку "Войти, чтобы заказать", если нужно #} {% endif %} {% endfor %} + {# Конец цикла по товарам #} + {# Сообщение, если нет товаров ПОСЛЕ фильтрации, будет добавлено через JS #} {% if not products %}

Товары пока не добавлены.

@@ -468,7 +483,7 @@ def catalog(): @@ -476,12 +491,13 @@ def catalog(): @@ -489,7 +505,7 @@ def catalog():